From b4779735bde03d80859bda545adefd90eb7629b1 Mon Sep 17 00:00:00 2001 From: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> Date: Thu, 17 Sep 2026 11:16:39 +0800 Subject: [PATCH] feat(suite): publish suites from multi-skill bundles --- Makefile | 5 +- design-qa.md | 35 + docs/skillhub/.vitepress/config.ts | 2 + docs/skillhub/en/guide/suite-bundle.md | 50 + docs/skillhub/guide/suite-bundle.md | 75 + .../.openspec.yaml | 2 + .../add-suite-bundle-publishing/README.md | 10 + .../add-suite-bundle-publishing/design.md | 305 ++++ .../add-suite-bundle-publishing/proposal.md | 97 ++ .../specs/suite-bundle-publishing/spec.md | 558 +++++++ .../specs/suite-member-discovery/spec.md | 70 + .../specs/suite-metadata/spec.md | 173 +++ .../add-suite-bundle-publishing/tasks.md | 49 + openspec/config.yaml | 1 + scripts/suite-bundle-smoke-test.sh | 407 +++++ scripts/suite-smoke-test.sh | 35 +- .../skillhub/config/DomainBeanConfig.java | 6 + .../config/SkillSuiteBundleProperties.java | 34 + .../portal/MySkillSuiteController.java | 15 + .../portal/ResourceDiscoveryController.java | 4 +- .../controller/portal/ReviewController.java | 3 +- .../controller/portal/SkillController.java | 35 +- .../portal/SkillSuiteBundleController.java | 164 +++ .../portal/SkillSuiteLabelController.java | 100 ++ .../dto/MySkillSuiteWorkspaceResponse.java | 17 + .../skillhub/dto/ResourceSummaryResponse.java | 4 +- .../skillhub/dto/SkillDetailResponse.java | 3 +- .../dto/SkillSuiteBundleConfirmRequest.java | 8 + ...illSuiteBundleOperationDetailResponse.java | 48 + ...SkillSuiteBundleOperationPageResponse.java | 13 + .../SkillSuiteBundleOperationResponse.java | 8 + ...llSuiteBundleOperationSummaryResponse.java | 22 + .../dto/SkillSuiteBundlePreviewResponse.java | 61 + .../dto/SkillSuiteReferenceResponse.java | 10 +- .../skillhub/dto/SkillSuiteResponse.java | 7 + .../dto/SkillSuiteSiblingMemberResponse.java | 13 + .../dto/SkillSuiteVersionSummaryResponse.java | 3 + .../exception/GlobalExceptionHandler.java | 4 + .../SkillSuiteBundleEventListener.java | 59 + .../JpaReviewProgressQueryRepository.java | 20 +- .../MySkillSuiteQueryRepository.java | 96 +- .../ReviewProgressQueryRepository.java | 2 + ...llSuiteBundleOperationQueryRepository.java | 175 +++ .../SkillSuiteLabelQueryRepository.java | 87 ++ .../SkillSuiteReferenceQueryRepository.java | 259 +++- .../service/GovernanceWorkflowAppService.java | 3 +- .../service/ResourceDiscoveryAppService.java | 50 +- .../service/ReviewPortalAppService.java | 5 + .../service/ReviewSkillDetailAppService.java | 4 +- .../service/SkillSuiteAppService.java | 67 +- .../service/SkillSuiteLabelAppService.java | 159 ++ .../SkillSuiteBundleActorContextService.java | 50 + .../SkillSuiteBundleArchiveService.java | 363 +++++ ...killSuiteBundleConfirmationAppService.java | 177 +++ .../bundle/SkillSuiteBundleCoordinator.java | 75 + .../SkillSuiteBundleDraftCreationService.java | 124 ++ ...killSuiteBundleMemberExecutionService.java | 302 ++++ ...SkillSuiteBundleMemberProgressService.java | 195 +++ ...illSuiteBundleOperationCommandService.java | 168 +++ ...SkillSuiteBundleOperationQueryService.java | 183 +++ ...SkillSuiteBundleOperationStateService.java | 84 ++ .../SkillSuiteBundlePackageAnalyzer.java | 308 ++++ .../SkillSuiteBundlePreviewAppService.java | 108 ++ ...lSuiteBundlePreviewPersistenceService.java | 26 + .../SkillSuiteBundlePreviewPlanner.java | 719 +++++++++ ...SuiteBundlePreviewRevalidationService.java | 92 ++ .../SkillSuiteBundleResponseMapper.java | 81 + .../SkillSuiteBundleStagedCleanupService.java | 145 ++ .../bundle/SkillSuiteBundleStagedEntry.java | 19 + .../skillhub/service/bundle/package-info.java | 2 + .../task/SkillSuiteBundleRecoveryTask.java | 36 + .../SkillSuiteBundleStagedCleanupTask.java | 51 + .../src/main/resources/application.yml | 4 + .../V54__skill_suite_bundle_operations.sql | 97 ++ .../V55__suite_bundle_staged_cleanup.sql | 18 + .../V56__suite_member_reverse_lookup.sql | 4 + .../db/migration/V57__skill_suite_labels.sql | 11 + ...ndex_active_bundle_operations_by_actor.sql | 2 + ...ndex_bundle_operation_history_by_actor.sql | 11 + .../src/main/resources/messages.properties | 22 +- .../src/main/resources/messages_ru.properties | 21 + .../src/main/resources/messages_zh.properties | 22 +- .../ReviewPortalControllerTest.java | 7 +- .../controller/SkillControllerTest.java | 13 +- .../SkillSuiteLabelControllerTest.java | 50 + .../SkillSuiteBundleControllerTest.java | 230 +++ ...iteBundleOperationQueryRepositoryTest.java | 269 ++++ .../SkillSuiteLabelPersistenceTest.java | 117 ++ .../SuiteDiscoveryIntegrationTest.java | 245 +++- .../SkillSuiteBundleEventListenerTest.java | 39 + .../JpaReviewProgressQueryRepositoryTest.java | 42 +- .../SkillSuiteBundlePersistenceTest.java | 544 +++++++ .../ResourceDiscoveryAppServiceTest.java | 56 + .../service/SkillSuiteAppServiceTest.java | 108 +- .../SkillSuiteLabelAppServiceTest.java | 104 ++ .../SkillSuiteLabelProjectionServiceTest.java | 71 + .../SkillSuiteBundleArchiveServiceTest.java | 320 ++++ ...SuiteBundleConfirmationAppServiceTest.java | 232 +++ .../SkillSuiteBundleCoordinatorTest.java | 112 ++ ...llSuiteBundleDraftCreationServiceTest.java | 147 ++ ...SuiteBundleMemberExecutionServiceTest.java | 205 +++ ...lSuiteBundleMemberProgressServiceTest.java | 182 +++ ...uiteBundleOperationCommandServiceTest.java | 249 ++++ ...lSuiteBundleOperationQueryServiceTest.java | 235 +++ .../SkillSuiteBundlePackageAnalyzerTest.java | 210 +++ ...SkillSuiteBundlePreviewAppServiceTest.java | 180 +++ .../SkillSuiteBundlePreviewPlannerTest.java | 698 +++++++++ ...eBundlePreviewRevalidationServiceTest.java | 148 ++ .../SkillSuiteBundleResponseMapperTest.java | 49 + ...llSuiteBundleStagedCleanupServiceTest.java | 166 +++ .../SkillSuiteBundleRecoveryTaskTest.java | 37 + ...SkillSuiteBundleStagedCleanupTaskTest.java | 46 + .../policy/RouteSecurityPolicyRegistry.java | 30 + .../RouteSecurityPolicyRegistryTest.java | 39 + ...SkillSuiteBundleAdvanceRequestedEvent.java | 5 + .../domain/label/LabelPermissionChecker.java | 22 + .../domain/label/SkillSuiteLabel.java | 68 + .../label/SkillSuiteLabelRepository.java | 13 + .../domain/label/SkillSuiteLabelService.java | 122 ++ .../domain/namespace/NamespaceRepository.java | 1 + .../domain/security/SecurityScanService.java | 6 +- .../domain/skill/SkillFileRepository.java | 1 + .../domain/skill/SkillRepository.java | 1 + .../domain/skill/SkillVersionRepository.java | 5 + .../skill/service/SkillPublishService.java | 329 ++++- .../service/SkillReviewSubmitService.java | 19 +- .../domain/skill/validation/PackageEntry.java | 87 +- .../suite/SkillSuiteLifecycleService.java | 6 +- .../suite/SkillSuitePublicationValidator.java | 14 + .../domain/suite/SkillSuiteQueryService.java | 5 +- .../bundle/SkillSuiteBundleCoordinate.java | 9 + .../SkillSuiteBundleExecutionOperation.java | 244 +++ ...iteBundleExecutionOperationRepository.java | 20 + .../bundle/SkillSuiteBundleManifest.java | 37 + .../SkillSuiteBundleManifestParser.java | 311 ++++ .../suite/bundle/SkillSuiteBundleMember.java | 16 + .../bundle/SkillSuiteBundleMemberResult.java | 193 +++ ...killSuiteBundleMemberResultRepository.java | 11 + .../SkillSuiteBundleMemberResultStatus.java | 11 + .../SkillSuiteBundleMemberSourceType.java | 6 + .../suite/bundle/SkillSuiteBundleMode.java | 7 + ...iteBundleOperationAuthorizationPolicy.java | 29 + .../SkillSuiteBundleOperationStatus.java | 10 + .../SkillSuiteBundlePreviewSession.java | 161 ++ ...llSuiteBundlePreviewSessionRepository.java | 16 + .../bundle/SkillSuiteBundlePreviewStatus.java | 7 + .../bundle/SkillSuiteBundlePublishAction.java | 9 + .../SkillSuiteBundleRelationshipChange.java | 8 + .../domain/suite/bundle/package-info.java | 2 + .../label/LabelPermissionCheckerTest.java | 51 + .../label/SkillSuiteLabelServiceTest.java | 144 ++ .../service/SkillPublishServiceTest.java | 237 +++ .../skill/validation/PackageEntryTest.java | 42 + .../suite/SkillSuiteDraftServiceTest.java | 36 + .../suite/SkillSuiteLifecycleServiceTest.java | 40 +- .../SkillSuitePublicationValidatorTest.java | 76 + .../suite/SkillSuiteQueryServiceTest.java | 27 +- .../SkillSuiteBundleManifestParserTest.java | 180 +++ .../SkillSuiteBundlePreviewSessionTest.java | 55 + .../invalid-both-sources/SUITE.yaml | 20 + .../invalid-dangerous-path/SUITE.yaml | 18 + .../suite-bundle/valid-create/SUITE.yaml | 25 + .../suite-bundle/valid-update/SUITE.yaml | 21 + .../infra/jpa/JpaSkillRepositoryAdapter.java | 5 + .../infra/jpa/NamespaceJpaRepository.java | 1 + .../infra/jpa/SkillFileJpaRepository.java | 1 + .../infra/jpa/SkillJpaRepository.java | 1 + ...BundleExecutionOperationJpaRepository.java | 21 + ...lSuiteBundleMemberResultJpaRepository.java | 33 + ...uiteBundlePreviewSessionJpaRepository.java | 41 + .../jpa/SkillSuiteLabelJpaRepository.java | 18 + .../infra/jpa/SkillVersionJpaRepository.java | 1 + .../search/ResourceDiscoveryQueryService.java | 3 +- ...PostgresResourceDiscoveryQueryService.java | 12 + web/src/api/client.ts | 24 +- web/src/api/generated/schema.d.ts | 1306 ++++++++++++++++- web/src/api/types.ts | 107 +- web/src/app/router.tsx | 56 +- web/src/features/publish/folder-zip.ts | 20 +- web/src/features/publish/upload-zone.test.ts | 40 +- web/src/features/publish/upload-zone.tsx | 29 +- .../features/review/use-my-review-progress.ts | 1 + web/src/features/search/search-bar.test.tsx | 25 + web/src/features/search/search-bar.tsx | 1 - .../features/skill/skill-label-panel.test.ts | 1 + web/src/features/skill/skill-label-panel.tsx | 162 +- web/src/features/suite/resource-card.test.tsx | 2 + web/src/features/suite/resource-card.tsx | 13 + .../suite/suite-bundle-folder.test.ts | 81 + web/src/features/suite/suite-bundle-folder.ts | 54 + .../suite/suite-bundle-import.test.tsx | 412 ++++++ .../features/suite/suite-bundle-import.tsx | 376 +++++ .../suite-bundle-operation-detail.test.tsx | 309 ++++ .../suite/suite-bundle-operation-detail.tsx | 352 +++++ .../suite/suite-bundle-problem.test.ts | 20 + .../features/suite/suite-bundle-problem.ts | 27 + web/src/features/suite/suite-labels.ts | 10 +- .../suite/suite-management-actions.test.tsx | 2 + .../suite/suite-management-actions.tsx | 26 +- web/src/features/suite/suite-member-table.tsx | 124 ++ web/src/features/suite/suite-overview.test.ts | 16 + web/src/features/suite/suite-overview.ts | 13 + .../features/suite/suite-version-ledger.tsx | 249 ++++ .../features/suite/suite-workspace-header.tsx | 47 + web/src/i18n/locales/en.json | 390 ++++- web/src/i18n/locales/ru.json | 390 ++++- web/src/i18n/locales/zh.json | 390 ++++- web/src/pages/dashboard.tsx | 43 +- web/src/pages/dashboard/my-skills.tsx | 18 +- web/src/pages/dashboard/my-suites.test.tsx | 95 ++ web/src/pages/dashboard/my-suites.tsx | 164 ++- .../pages/dashboard/review-progress.test.tsx | 80 +- web/src/pages/dashboard/review-progress.tsx | 53 +- web/src/pages/dashboard/suite-editor.test.tsx | 175 ++- web/src/pages/dashboard/suite-editor.tsx | 525 ++++++- .../pages/dashboard/suite-management.test.tsx | 137 ++ web/src/pages/dashboard/suite-management.tsx | 383 +++++ .../dashboard/suite-publishing-task.test.tsx | 50 + .../pages/dashboard/suite-publishing-task.tsx | 36 + web/src/pages/search.tsx | 24 - web/src/pages/skill-detail.test.tsx | 184 ++- web/src/pages/skill-detail.tsx | 127 +- web/src/pages/suite-detail.test.tsx | 144 +- web/src/pages/suite-detail.tsx | 427 +++--- web/src/pages/suites.test.tsx | 56 + web/src/pages/suites.tsx | 51 +- web/src/shared/hooks/query-keys.ts | 4 + .../shared/hooks/use-label-queries.test.ts | 3 + web/src/shared/hooks/use-label-queries.ts | 54 +- .../shared/hooks/use-suite-queries.test.ts | 61 + web/src/shared/hooks/use-suite-queries.ts | 136 +- web/src/shared/ui/button.test.ts | 6 + web/src/shared/ui/button.tsx | 2 + 233 files changed, 22249 insertions(+), 926 deletions(-) create mode 100644 design-qa.md create mode 100644 docs/skillhub/en/guide/suite-bundle.md create mode 100644 docs/skillhub/guide/suite-bundle.md create mode 100644 openspec/changes/add-suite-bundle-publishing/.openspec.yaml create mode 100644 openspec/changes/add-suite-bundle-publishing/README.md create mode 100644 openspec/changes/add-suite-bundle-publishing/design.md create mode 100644 openspec/changes/add-suite-bundle-publishing/proposal.md create mode 100644 openspec/changes/add-suite-bundle-publishing/specs/suite-bundle-publishing/spec.md create mode 100644 openspec/changes/add-suite-bundle-publishing/specs/suite-member-discovery/spec.md create mode 100644 openspec/changes/add-suite-bundle-publishing/specs/suite-metadata/spec.md create mode 100644 openspec/changes/add-suite-bundle-publishing/tasks.md create mode 100644 openspec/config.yaml create mode 100755 scripts/suite-bundle-smoke-test.sh create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/config/SkillSuiteBundleProperties.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillSuiteBundleController.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillSuiteLabelController.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/MySkillSuiteWorkspaceResponse.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillSuiteBundleConfirmRequest.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillSuiteBundleOperationDetailResponse.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillSuiteBundleOperationPageResponse.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillSuiteBundleOperationResponse.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillSuiteBundleOperationSummaryResponse.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillSuiteBundlePreviewResponse.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillSuiteSiblingMemberResponse.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/listener/SkillSuiteBundleEventListener.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/SkillSuiteBundleOperationQueryRepository.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/SkillSuiteLabelQueryRepository.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillSuiteLabelAppService.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/service/bundle/SkillSuiteBundleActorContextService.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/service/bundle/SkillSuiteBundleArchiveService.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/service/bundle/SkillSuiteBundleConfirmationAppService.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/service/bundle/SkillSuiteBundleCoordinator.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/service/bundle/SkillSuiteBundleDraftCreationService.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/service/bundle/SkillSuiteBundleMemberExecutionService.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/service/bundle/SkillSuiteBundleMemberProgressService.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/service/bundle/SkillSuiteBundleOperationCommandService.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/service/bundle/SkillSuiteBundleOperationQueryService.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/service/bundle/SkillSuiteBundleOperationStateService.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/service/bundle/SkillSuiteBundlePackageAnalyzer.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/service/bundle/SkillSuiteBundlePreviewAppService.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/service/bundle/SkillSuiteBundlePreviewPersistenceService.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/service/bundle/SkillSuiteBundlePreviewPlanner.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/service/bundle/SkillSuiteBundlePreviewRevalidationService.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/service/bundle/SkillSuiteBundleResponseMapper.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/service/bundle/SkillSuiteBundleStagedCleanupService.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/service/bundle/SkillSuiteBundleStagedEntry.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/service/bundle/package-info.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/task/SkillSuiteBundleRecoveryTask.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/task/SkillSuiteBundleStagedCleanupTask.java create mode 100644 server/skillhub-app/src/main/resources/db/migration/V54__skill_suite_bundle_operations.sql create mode 100644 server/skillhub-app/src/main/resources/db/migration/V55__suite_bundle_staged_cleanup.sql create mode 100644 server/skillhub-app/src/main/resources/db/migration/V56__suite_member_reverse_lookup.sql create mode 100644 server/skillhub-app/src/main/resources/db/migration/V57__skill_suite_labels.sql create mode 100644 server/skillhub-app/src/main/resources/db/migration/V58__index_active_bundle_operations_by_actor.sql create mode 100644 server/skillhub-app/src/main/resources/db/migration/V59__index_bundle_operation_history_by_actor.sql create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillSuiteLabelControllerTest.java create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillSuiteBundleControllerTest.java create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/integration/SkillSuiteBundleOperationQueryRepositoryTest.java create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/integration/SkillSuiteLabelPersistenceTest.java create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/listener/SkillSuiteBundleEventListenerTest.java create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/repository/SkillSuiteBundlePersistenceTest.java create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/service/ResourceDiscoveryAppServiceTest.java create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillSuiteLabelAppServiceTest.java create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillSuiteLabelProjectionServiceTest.java create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/service/bundle/SkillSuiteBundleArchiveServiceTest.java create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/service/bundle/SkillSuiteBundleConfirmationAppServiceTest.java create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/service/bundle/SkillSuiteBundleCoordinatorTest.java create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/service/bundle/SkillSuiteBundleDraftCreationServiceTest.java create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/service/bundle/SkillSuiteBundleMemberExecutionServiceTest.java create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/service/bundle/SkillSuiteBundleMemberProgressServiceTest.java create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/service/bundle/SkillSuiteBundleOperationCommandServiceTest.java create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/service/bundle/SkillSuiteBundleOperationQueryServiceTest.java create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/service/bundle/SkillSuiteBundlePackageAnalyzerTest.java create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/service/bundle/SkillSuiteBundlePreviewAppServiceTest.java create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/service/bundle/SkillSuiteBundlePreviewPlannerTest.java create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/service/bundle/SkillSuiteBundlePreviewRevalidationServiceTest.java create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/service/bundle/SkillSuiteBundleResponseMapperTest.java create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/service/bundle/SkillSuiteBundleStagedCleanupServiceTest.java create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/task/SkillSuiteBundleRecoveryTaskTest.java create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/task/SkillSuiteBundleStagedCleanupTaskTest.java create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/event/SkillSuiteBundleAdvanceRequestedEvent.java create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/label/SkillSuiteLabel.java create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/label/SkillSuiteLabelRepository.java create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/label/SkillSuiteLabelService.java create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/bundle/SkillSuiteBundleCoordinate.java create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/bundle/SkillSuiteBundleExecutionOperation.java create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/bundle/SkillSuiteBundleExecutionOperationRepository.java create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/bundle/SkillSuiteBundleManifest.java create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/bundle/SkillSuiteBundleManifestParser.java create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/bundle/SkillSuiteBundleMember.java create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/bundle/SkillSuiteBundleMemberResult.java create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/bundle/SkillSuiteBundleMemberResultRepository.java create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/bundle/SkillSuiteBundleMemberResultStatus.java create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/bundle/SkillSuiteBundleMemberSourceType.java create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/bundle/SkillSuiteBundleMode.java create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/bundle/SkillSuiteBundleOperationAuthorizationPolicy.java create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/bundle/SkillSuiteBundleOperationStatus.java create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/bundle/SkillSuiteBundlePreviewSession.java create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/bundle/SkillSuiteBundlePreviewSessionRepository.java create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/bundle/SkillSuiteBundlePreviewStatus.java create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/bundle/SkillSuiteBundlePublishAction.java create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/bundle/SkillSuiteBundleRelationshipChange.java create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/bundle/package-info.java create mode 100644 server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/label/LabelPermissionCheckerTest.java create mode 100644 server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/label/SkillSuiteLabelServiceTest.java create mode 100644 server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/validation/PackageEntryTest.java create mode 100644 server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/suite/SkillSuitePublicationValidatorTest.java create mode 100644 server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/suite/bundle/SkillSuiteBundleManifestParserTest.java create mode 100644 server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/suite/bundle/SkillSuiteBundlePreviewSessionTest.java create mode 100644 server/skillhub-domain/src/test/resources/suite-bundle/invalid-both-sources/SUITE.yaml create mode 100644 server/skillhub-domain/src/test/resources/suite-bundle/invalid-dangerous-path/SUITE.yaml create mode 100644 server/skillhub-domain/src/test/resources/suite-bundle/valid-create/SUITE.yaml create mode 100644 server/skillhub-domain/src/test/resources/suite-bundle/valid-update/SUITE.yaml create mode 100644 server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillSuiteBundleExecutionOperationJpaRepository.java create mode 100644 server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillSuiteBundleMemberResultJpaRepository.java create mode 100644 server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillSuiteBundlePreviewSessionJpaRepository.java create mode 100644 server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillSuiteLabelJpaRepository.java create mode 100644 web/src/features/search/search-bar.test.tsx create mode 100644 web/src/features/suite/suite-bundle-folder.test.ts create mode 100644 web/src/features/suite/suite-bundle-folder.ts create mode 100644 web/src/features/suite/suite-bundle-import.test.tsx create mode 100644 web/src/features/suite/suite-bundle-import.tsx create mode 100644 web/src/features/suite/suite-bundle-operation-detail.test.tsx create mode 100644 web/src/features/suite/suite-bundle-operation-detail.tsx create mode 100644 web/src/features/suite/suite-bundle-problem.test.ts create mode 100644 web/src/features/suite/suite-bundle-problem.ts create mode 100644 web/src/features/suite/suite-member-table.tsx create mode 100644 web/src/features/suite/suite-overview.test.ts create mode 100644 web/src/features/suite/suite-overview.ts create mode 100644 web/src/features/suite/suite-version-ledger.tsx create mode 100644 web/src/features/suite/suite-workspace-header.tsx create mode 100644 web/src/pages/dashboard/my-suites.test.tsx create mode 100644 web/src/pages/dashboard/suite-management.test.tsx create mode 100644 web/src/pages/dashboard/suite-management.tsx create mode 100644 web/src/pages/dashboard/suite-publishing-task.test.tsx create mode 100644 web/src/pages/dashboard/suite-publishing-task.tsx create mode 100644 web/src/pages/suites.test.tsx create mode 100644 web/src/shared/hooks/use-suite-queries.test.ts diff --git a/Makefile b/Makefile index f6640d8d..42941a61 100644 --- a/Makefile +++ b/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 diff --git a/design-qa.md b/design-qa.md new file mode 100644 index 00000000..65a617d8 --- /dev/null +++ b/design-qa.md @@ -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 diff --git a/docs/skillhub/.vitepress/config.ts b/docs/skillhub/.vitepress/config.ts index 3cfef1d8..10b17307 100644 --- a/docs/skillhub/.vitepress/config.ts +++ b/docs/skillhub/.vitepress/config.ts @@ -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' }, diff --git a/docs/skillhub/en/guide/suite-bundle.md b/docs/skillhub/en/guide/suite-bundle.md new file mode 100644 index 00000000..feab2005 --- /dev/null +++ b/docs/skillhub/en/guide/suite-bundle.md @@ -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. diff --git a/docs/skillhub/guide/suite-bundle.md b/docs/skillhub/guide/suite-bundle.md new file mode 100644 index 00000000..7779873e --- /dev/null +++ b/docs/skillhub/guide/suite-bundle.md @@ -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 地址。 diff --git a/openspec/changes/add-suite-bundle-publishing/.openspec.yaml b/openspec/changes/add-suite-bundle-publishing/.openspec.yaml new file mode 100644 index 00000000..515eaae3 --- /dev/null +++ b/openspec/changes/add-suite-bundle-publishing/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-11 diff --git a/openspec/changes/add-suite-bundle-publishing/README.md b/openspec/changes/add-suite-bundle-publishing/README.md new file mode 100644 index 00000000..8527d8f3 --- /dev/null +++ b/openspec/changes/add-suite-bundle-publishing/README.md @@ -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。 diff --git a/openspec/changes/add-suite-bundle-publishing/design.md b/openspec/changes/add-suite-bundle-publishing/design.md new file mode 100644 index 00000000..a183c5a9 --- /dev/null +++ b/openspec/changes/add-suite-bundle-publishing/design.md @@ -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`)显式开启。 diff --git a/openspec/changes/add-suite-bundle-publishing/proposal.md b/openspec/changes/add-suite-bundle-publishing/proposal.md new file mode 100644 index 00000000..26f5f845 --- /dev/null +++ b/openspec/changes/add-suite-bundle-publishing/proposal.md @@ -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 固定版本说明。 diff --git a/openspec/changes/add-suite-bundle-publishing/specs/suite-bundle-publishing/spec.md b/openspec/changes/add-suite-bundle-publishing/specs/suite-bundle-publishing/spec.md new file mode 100644 index 00000000..af3e3596 --- /dev/null +++ b/openspec/changes/add-suite-bundle-publishing/specs/suite-bundle-publishing/spec.md @@ -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 入口 diff --git a/openspec/changes/add-suite-bundle-publishing/specs/suite-member-discovery/spec.md b/openspec/changes/add-suite-bundle-publishing/specs/suite-member-discovery/spec.md new file mode 100644 index 00000000..94a6c0ab --- /dev/null +++ b/openspec/changes/add-suite-bundle-publishing/specs/suite-member-discovery/spec.md @@ -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** 提供继续查看其他结果所需的信息 diff --git a/openspec/changes/add-suite-bundle-publishing/specs/suite-metadata/spec.md b/openspec/changes/add-suite-bundle-publishing/specs/suite-metadata/spec.md new file mode 100644 index 00000000..252dbf89 --- /dev/null +++ b/openspec/changes/add-suite-bundle-publishing/specs/suite-metadata/spec.md @@ -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 和经过权限过滤的成员状态仍可正常展示 diff --git a/openspec/changes/add-suite-bundle-publishing/tasks.md b/openspec/changes/add-suite-bundle-publishing/tasks.md new file mode 100644 index 00000000..007d0c7e --- /dev/null +++ b/openspec/changes/add-suite-bundle-publishing/tasks.md @@ -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,不得只保留范围缩写。 diff --git a/openspec/config.yaml b/openspec/config.yaml new file mode 100644 index 00000000..b4bbeb94 --- /dev/null +++ b/openspec/config.yaml @@ -0,0 +1 @@ +schema: spec-driven diff --git a/scripts/suite-bundle-smoke-test.sh b/scripts/suite-bundle-smoke-test.sh new file mode 100755 index 00000000..bd5c18d3 --- /dev/null +++ b/scripts/suite-bundle-smoke-test.sh @@ -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 ===" diff --git a/scripts/suite-smoke-test.sh b/scripts/suite-smoke-test.sh index aebd8d1f..a67e9625 100755 --- a/scripts/suite-smoke-test.sh +++ b/scripts/suite-smoke-test.sh @@ -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")" diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/DomainBeanConfig.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/DomainBeanConfig.java index 7b36265f..194a8160 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/DomainBeanConfig.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/DomainBeanConfig.java @@ -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) { diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/SkillSuiteBundleProperties.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/SkillSuiteBundleProperties.java new file mode 100644 index 00000000..c35e00a9 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/SkillSuiteBundleProperties.java @@ -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; + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/MySkillSuiteController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/MySkillSuiteController.java index 9903a2c5..1fcfb7fe 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/MySkillSuiteController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/MySkillSuiteController.java @@ -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 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 roles + ) { + return ok("response.success.read", appService.workspace( + userId, roles == null ? Map.of() : roles, q, state, page, size)); + } } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/ResourceDiscoveryController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/ResourceDiscoveryController.java index 9ae84305..280a4169 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/ResourceDiscoveryController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/ResourceDiscoveryController.java @@ -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 labels, @RequestAttribute(value = "userNsRoles", required = false) Map 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)); } } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/ReviewController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/ReviewController.java index bf0bbcb8..b4115e0b 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/ReviewController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/ReviewController.java @@ -143,6 +143,7 @@ public class ReviewController extends BaseApiController { @GetMapping("/my-progress") public ApiResponse 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) ); } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillController.java index 7b38d478..213f78d9 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillController.java @@ -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 namespaceRoles = userNsRoles != null ? userNsRoles : Map.of(); + Set platformRoles = principal == null || principal.platformRoles() == null + ? Set.of() : principal.platformRoles(); SkillQueryService.SkillDetailDTO detail = skillQueryService.getSkillDetail( namespace, slug, userId, namespaceRoles); + PageResponse 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> + 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 userNsRoles, + @AuthenticationPrincipal PlatformPrincipal principal) { + Map namespaceRoles = userNsRoles != null ? userNsRoles : Map.of(); + Set 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. diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillSuiteBundleController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillSuiteBundleController.java new file mode 100644 index 00000000..bfd3e8e8 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillSuiteBundleController.java @@ -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 preview( + @RequestPart("file") MultipartFile file, + @RequestAttribute("userId") String userId, + @RequestAttribute(value = "userNsRoles", required = false) Map 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 confirm( + @PathVariable String previewToken, + @RequestHeader("Idempotency-Key") String clientRequestId, + @Valid @RequestBody SkillSuiteBundleConfirmRequest request, + @RequestAttribute("userId") String userId, + @RequestAttribute(value = "userNsRoles", required = false) Map 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 getOperation( + @PathVariable String operationId, + @RequestAttribute("userId") String userId, + @RequestAttribute(value = "userNsRoles", required = false) Map 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> 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 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 cancelOperation( + @PathVariable String operationId, + @RequestAttribute("userId") String userId, + @RequestAttribute(value = "userNsRoles", required = false) Map 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 retryOperation( + @PathVariable String operationId, + @RequestAttribute("userId") String userId, + @RequestAttribute(value = "userNsRoles", required = false) Map roles, + @AuthenticationPrincipal PlatformPrincipal principal + ) { + return ok("response.success.updated", operationCommandService.retry( + operationId, userId, roles == null ? Map.of() : roles, platformRoles(principal))); + } + + private Set platformRoles(PlatformPrincipal principal) { + return principal == null || principal.platformRoles() == null + ? Set.of() + : principal.platformRoles(); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillSuiteLabelController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillSuiteLabelController.java new file mode 100644 index 00000000..f82ded76 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillSuiteLabelController.java @@ -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> listLabels( + @PathVariable String namespace, + @PathVariable String slug, + @RequestAttribute(value = "userId", required = false) String userId, + @RequestAttribute(value = "userNsRoles", required = false) Map 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 attachLabel( + @PathVariable String namespace, + @PathVariable String slug, + @PathVariable String labelSlug, + @RequestAttribute("userId") String userId, + @RequestAttribute(value = "userNsRoles", required = false) Map 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 detachLabel( + @PathVariable String namespace, + @PathVariable String slug, + @PathVariable String labelSlug, + @RequestAttribute("userId") String userId, + @RequestAttribute(value = "userNsRoles", required = false) Map 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 roles(Map roles) { + return roles == null ? Map.of() : roles; + } + + private Set platformRoles(PlatformPrincipal principal) { + return principal == null || principal.platformRoles() == null + ? Set.of() + : principal.platformRoles(); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/MySkillSuiteWorkspaceResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/MySkillSuiteWorkspaceResponse.java new file mode 100644 index 00000000..1f02bfb2 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/MySkillSuiteWorkspaceResponse.java @@ -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 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 + ) { + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/ResourceSummaryResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/ResourceSummaryResponse.java index 159b64c1..4b4431eb 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/ResourceSummaryResponse.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/ResourceSummaryResponse.java @@ -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 labels ) { } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillDetailResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillDetailResponse.java index 00cecfa9..a3825624 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillDetailResponse.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillDetailResponse.java @@ -29,5 +29,6 @@ public record SkillDetailResponse( SkillLifecycleVersionResponse ownerPreviewVersion, String ownerPreviewReviewComment, String resolutionMode, - List entryForSuites + List entryForSuites, + PageResponse memberOfSuites ) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillSuiteBundleConfirmRequest.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillSuiteBundleConfirmRequest.java new file mode 100644 index 00000000..3744afa4 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillSuiteBundleConfirmRequest.java @@ -0,0 +1,8 @@ +package com.iflytek.skillhub.dto; + +import jakarta.validation.constraints.NotBlank; + +public record SkillSuiteBundleConfirmRequest( + @NotBlank String warningDigest +) { +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillSuiteBundleOperationDetailResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillSuiteBundleOperationDetailResponse.java new file mode 100644 index 00000000..4cee51be --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillSuiteBundleOperationDetailResponse.java @@ -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 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 errors, + List warnings + ) { + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillSuiteBundleOperationPageResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillSuiteBundleOperationPageResponse.java new file mode 100644 index 00000000..98e2dfce --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillSuiteBundleOperationPageResponse.java @@ -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 items, + long total, + int page, + int size, + boolean hasChangingOperations +) { +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillSuiteBundleOperationResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillSuiteBundleOperationResponse.java new file mode 100644 index 00000000..2a4665aa --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillSuiteBundleOperationResponse.java @@ -0,0 +1,8 @@ +package com.iflytek.skillhub.dto; + +public record SkillSuiteBundleOperationResponse( + String operationId, + String status, + boolean replayed +) { +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillSuiteBundleOperationSummaryResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillSuiteBundleOperationSummaryResponse.java new file mode 100644 index 00000000..5d7d202f --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillSuiteBundleOperationSummaryResponse.java @@ -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 +) { +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillSuiteBundlePreviewResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillSuiteBundlePreviewResponse.java new file mode 100644 index 00000000..5f83175c --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillSuiteBundlePreviewResponse.java @@ -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 members, + List removedMembers, + List errors, + List 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 errors, + List warnings + ) { + } + + public record RemovedMember( + String coordinate, + Long skillId, + Long skillVersionId, + String version, + boolean entry + ) { + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillSuiteReferenceResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillSuiteReferenceResponse.java index feb95feb..ddfcacb6 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillSuiteReferenceResponse.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillSuiteReferenceResponse.java @@ -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 visibleSiblingMembers, + int restrictedMemberCount, + int omittedVisibleMemberCount ) { } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillSuiteResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillSuiteResponse.java index 619df458..da4405ec 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillSuiteResponse.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillSuiteResponse.java @@ -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, diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillSuiteSiblingMemberResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillSuiteSiblingMemberResponse.java new file mode 100644 index 00000000..6cebb4fa --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillSuiteSiblingMemberResponse.java @@ -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 +) { +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillSuiteVersionSummaryResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillSuiteVersionSummaryResponse.java index 2ba2f75d..de50aa06 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillSuiteVersionSummaryResponse.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillSuiteVersionSummaryResponse.java @@ -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 diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/exception/GlobalExceptionHandler.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/exception/GlobalExceptionHandler.java index 60a31773..70b30a41 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/exception/GlobalExceptionHandler.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/exception/GlobalExceptionHandler.java @@ -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 }) diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/listener/SkillSuiteBundleEventListener.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/listener/SkillSuiteBundleEventListener.java new file mode 100644 index 00000000..dfaf2ec5 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/listener/SkillSuiteBundleEventListener.java @@ -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); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/JpaReviewProgressQueryRepository.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/JpaReviewProgressQueryRepository.java index bcd2c423..c5ce1dc5 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/JpaReviewProgressQueryRepository.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/JpaReviewProgressQueryRepository.java @@ -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); diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/MySkillSuiteQueryRepository.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/MySkillSuiteQueryRepository.java index 168ed6a9..cbad9ab0 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/MySkillSuiteQueryRepository.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/MySkillSuiteQueryRepository.java @@ -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 memberNamespaceIds, Set 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 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; } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/ReviewProgressQueryRepository.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/ReviewProgressQueryRepository.java index 535cefbe..66ec7031 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/ReviewProgressQueryRepository.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/ReviewProgressQueryRepository.java @@ -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, diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/SkillSuiteBundleOperationQueryRepository.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/SkillSuiteBundleOperationQueryRepository.java new file mode 100644 index 00000000..ea1ad6f4 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/SkillSuiteBundleOperationQueryRepository.java @@ -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. + * + *

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.

+ */ +@Repository +public class SkillSuiteBundleOperationQueryRepository { + + private static final List 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 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 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 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); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/SkillSuiteLabelQueryRepository.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/SkillSuiteLabelQueryRepository.java new file mode 100644 index 00000000..7827ddc0 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/SkillSuiteLabelQueryRepository.java @@ -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> labelsBySuiteIds(List suiteIds) { + if (suiteIds == null || suiteIds.isEmpty()) { + return Map.of(); + } + List distinctSuiteIds = suiteIds.stream() + .filter(Objects::nonNull) + .distinct() + .toList(); + if (distinctSuiteIds.isEmpty()) { + return Map.of(); + } + List assignments = + suiteLabelService.listSuiteLabelsBySuiteIds(distinctSuiteIds); + if (assignments.isEmpty()) { + return Map.of(); + } + List labelIds = assignments.stream() + .map(SkillSuiteLabel::getLabelId) + .distinct() + .toList(); + Map definitionsById = labelDefinitionService.listByIds(labelIds).stream() + .collect(Collectors.toMap(LabelDefinition::getId, Function.identity())); + Map> 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> translationsByLabelId + ) { + return new SkillLabelDto( + definition.getSlug(), + definition.getType().name(), + labelLocalizationService.resolveDisplayName( + definition.getSlug(), + translationsByLabelId.getOrDefault(definition.getId(), List.of()))); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/SkillSuiteReferenceQueryRepository.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/SkillSuiteReferenceQueryRepository.java index 459bb996..7c37a576 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/SkillSuiteReferenceQueryRepository.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/SkillSuiteReferenceQueryRepository.java @@ -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. * - *

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.

+ *

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.

*/ @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 findVisibleEntryReferences( Long skillId, String userId, Map namespaceRoles, Set platformRoles + ) { + return findVisibleMemberships( + skillId, userId, namespaceRoles, platformRoles, 0, MAX_PAGE_SIZE).items().stream() + .filter(SkillSuiteReferenceResponse::currentSkillEntry) + .toList(); + } + + @Transactional(readOnly = true) + public PageResponse findVisibleMemberships( + Long skillId, + String userId, + Map namespaceRoles, + Set 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 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> membersBySuiteVersion = loadMembers( + suites.stream().map(SuiteRow::suiteVersionId).toList(), + skillId, userId, namespaceRoles, platformRoles); + List 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> loadMembers( + List suiteVersionIds, + Long currentSkillId, + String userId, + Map namespaceRoles, + Set platformRoles + ) { + MapSqlParameterSource parameters = viewerParameters( + currentSkillId, userId, namespaceRoles, platformRoles) + .addValue("suiteVersionIds", suiteVersionIds); + List 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> grouped = new LinkedHashMap<>(); + rows.forEach(row -> grouped.computeIfAbsent(row.suiteVersionId(), ignored -> new ArrayList<>()).add(row)); + return grouped; + } + + private SkillSuiteReferenceResponse toResponse(SuiteRow suite, List members) { + List visible = members.stream().filter(MemberRow::visible).toList(); + List 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 namespaceRoles, + Set platformRoles ) { List memberNamespaceIds = namespaceRoles.keySet().stream().toList(); List 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 nonEmpty(List 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 + ) { + } } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/GovernanceWorkflowAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/GovernanceWorkflowAppService.java index ededf424..c27f2a90 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/GovernanceWorkflowAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/GovernanceWorkflowAppService.java @@ -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 listMyReviewAttempts(Long reviewTaskId, String userId) { diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/ResourceDiscoveryAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/ResourceDiscoveryAppService.java index facea2d8..6e6ac413 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/ResourceDiscoveryAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/ResourceDiscoveryAppService.java @@ -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 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 memberNamespaceIds, + List 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> 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 normalizeLabelSlugs(List 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(); + } } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/ReviewPortalAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/ReviewPortalAppService.java index 074c51d8..dfaf2f8b 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/ReviewPortalAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/ReviewPortalAppService.java @@ -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, diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/ReviewSkillDetailAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/ReviewSkillDetailAppService.java index 8c9afd68..49a629ab 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/ReviewSkillDetailAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/ReviewSkillDetailAppService.java @@ -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 versions = snapshot.versions().stream() diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillSuiteAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillSuiteAppService.java index f035bbac..7dd55f63 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillSuiteAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillSuiteAppService.java @@ -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 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 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 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 searchCandidates( String suiteNamespace, SkillVisibility visibility, @@ -159,6 +182,18 @@ public class SkillSuiteAppService { skillId, userId, namespaceRoles, platformRoles); } + public PageResponse findVisibleMemberships( + Long skillId, + String userId, + Map namespaceRoles, + Set 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 namespaceRoles, Set platformRoles ) { - return queryService.listVersions(namespace, slug, userId, namespaceRoles, platformRoles).stream() + List versions = + queryService.listVersions(namespace, slug, userId, namespaceRoles, platformRoles); + Map 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 platformRoles ) { List selections = new ArrayList<>(request.members().size()); - List invalidMembers = new ArrayList<>(); + Set 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 namespaceRoles, diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillSuiteLabelAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillSuiteLabelAppService.java new file mode 100644 index 00000000..15b0b785 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillSuiteLabelAppService.java @@ -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 listLabels( + String namespaceSlug, + String suiteSlug, + String userId, + Map namespaceRoles, + Set platformRoles + ) { + SkillSuite suite = resolveSuite(namespaceSlug, suiteSlug); + Map safeNamespaceRoles = roles(namespaceRoles); + Set 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 namespaceRoles, + Set 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 namespaceRoles, + Set 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 namespaceRoles, + Set 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 roles(Map roles) { + return roles == null ? Map.of() : roles; + } + + private Set roles(Set roles) { + return roles == null ? Set.of() : roles; + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/bundle/SkillSuiteBundleActorContextService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/bundle/SkillSuiteBundleActorContextService.java new file mode 100644 index 00000000..3616494b --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/bundle/SkillSuiteBundleActorContextService.java @@ -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 namespaceRoles = namespaceMemberRepository.findByUserId(actorId).stream() + .collect(Collectors.toUnmodifiableMap( + member -> member.getNamespaceId(), member -> member.getRole())); + Set platformRoles = Set.copyOf(rbacService.getUserRoleCodes(actorId)); + return new ActorContext(namespaceRoles, platformRoles); + } + + public record ActorContext( + Map namespaceRoles, + Set platformRoles + ) { + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/bundle/SkillSuiteBundleArchiveService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/bundle/SkillSuiteBundleArchiveService.java new file mode 100644 index 00000000..d0d7a9be --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/bundle/SkillSuiteBundleArchiveService.java @@ -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 localEntries = new ArrayList<>(); + List 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 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 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 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 extractEntries( + Path archivePath, + Path tempDirectory, + String objectPrefix, + List localEntries, + List uploadedObjectKeys + ) throws IOException { + List 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 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 objectKeys + ) { + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/bundle/SkillSuiteBundleConfirmationAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/bundle/SkillSuiteBundleConfirmationAppService.java new file mode 100644 index 00000000..78ee6384 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/bundle/SkillSuiteBundleConfirmationAppService.java @@ -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 namespaceRoles, + Set 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 toMemberResults( + String operationId, + SkillSuiteBundleManifest manifest, + SkillSuiteBundlePreviewPlanner.PreviewPlan plan, + Instant now + ) { + Map manifestMembers = + manifest.spec().members().stream().collect(Collectors.toMap( + SkillSuiteBundleMember::coordinate, Function.identity())); + List 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) { + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/bundle/SkillSuiteBundleCoordinator.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/bundle/SkillSuiteBundleCoordinator.java new file mode 100644 index 00000000..f605fc0d --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/bundle/SkillSuiteBundleCoordinator.java @@ -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])); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/bundle/SkillSuiteBundleDraftCreationService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/bundle/SkillSuiteBundleDraftCreationService.java new file mode 100644 index 00000000..7b26712b --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/bundle/SkillSuiteBundleDraftCreationService.java @@ -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 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 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"); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/bundle/SkillSuiteBundleMemberExecutionService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/bundle/SkillSuiteBundleMemberExecutionService.java new file mode 100644 index 00000000..58c3c969 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/bundle/SkillSuiteBundleMemberExecutionService.java @@ -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 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 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 readEntries( + List files, Path localDirectory + ) throws IOException { + List 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 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 + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/bundle/SkillSuiteBundleMemberProgressService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/bundle/SkillSuiteBundleMemberProgressService.java new file mode 100644 index 00000000..ed5d952d --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/bundle/SkillSuiteBundleMemberProgressService.java @@ -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 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 namespaceRoles, + Set 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 + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/bundle/SkillSuiteBundleOperationCommandService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/bundle/SkillSuiteBundleOperationCommandService.java new file mode 100644 index 00000000..3902a64e --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/bundle/SkillSuiteBundleOperationCommandService.java @@ -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 namespaceRoles, + Set 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 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 namespaceRoles, + Set 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 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 members, + String actorId, + Map namespaceRoles, + Set 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 members) { + return members.stream().anyMatch(member -> member.getSkillVersionId() != null); + } + + private boolean planRemainsValid( + SkillSuiteBundlePreviewSession preview, + String actorId, + Map namespaceRoles, + Set 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"); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/bundle/SkillSuiteBundleOperationQueryService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/bundle/SkillSuiteBundleOperationQueryService.java new file mode 100644 index 00000000..f945fbd8 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/bundle/SkillSuiteBundleOperationQueryService.java @@ -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 namespaceRoles, + Set 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 skillIds = persistedMembers.stream() + .map(member -> member.getSkillId()) + .filter(Objects::nonNull) + .distinct() + .toList(); + Map skillsById = (skillIds.isEmpty() ? List.of() : skillRepository.findByIdIn(skillIds)) + .stream() + .collect(Collectors.toMap(Skill::getId, Function.identity())); + List namespaceSlugs = persistedMembers.stream() + .map(member -> member.getNamespaceSlug()) + .distinct() + .toList(); + Map namespaceIdsBySlug = (namespaceSlugs.isEmpty() + ? List.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 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 namespaceRoles, + Set 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"); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/bundle/SkillSuiteBundleOperationStateService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/bundle/SkillSuiteBundleOperationStateService.java new file mode 100644 index 00000000..3cb49fdc --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/bundle/SkillSuiteBundleOperationStateService.java @@ -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 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 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; + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/bundle/SkillSuiteBundlePackageAnalyzer.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/bundle/SkillSuiteBundlePackageAnalyzer.java new file mode 100644 index 00000000..1818be59 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/bundle/SkillSuiteBundlePackageAnalyzer.java @@ -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 stagedEntries) throws IOException { + List errors = new ArrayList<>(); + List indexed = indexEntries(stagedEntries, errors); + IndexedEntry manifestEntry = findManifest(indexed); + String outerPrefix = parentPrefix(manifestEntry.normalizedPath()); + List 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> 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 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 indexEntries( + List stagedEntries, List errors + ) { + List indexed = new ArrayList<>(); + Set 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 entries) { + List 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 removeOuterPrefix( + List entries, String prefix, List errors + ) { + if (prefix.isEmpty()) { + return entries; + } + List 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 indexedEntries + ) throws IOException { + List packageEntries = new ArrayList<>(); + List 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 memberErrors = new ArrayList<>(base.errors()); + List 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 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 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 packageMembers, + List 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 files, + String fingerprint + ) { + } + + public record StagedMemberFile( + String relativePath, + long size, + String contentType, + String sha256, + String objectKey + ) { + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/bundle/SkillSuiteBundlePreviewAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/bundle/SkillSuiteBundlePreviewAppService.java new file mode 100644 index 00000000..292384b4 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/bundle/SkillSuiteBundlePreviewAppService.java @@ -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> 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 namespaceRoles, + Set 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 manifestJson = objectMapper.convertValue( + staged.analysis().manifest(), JSON_OBJECT); + Map 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 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(); + } + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/bundle/SkillSuiteBundlePreviewPersistenceService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/bundle/SkillSuiteBundlePreviewPersistenceService.java new file mode 100644 index 00000000..7e7b2d01 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/bundle/SkillSuiteBundlePreviewPersistenceService.java @@ -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(); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/bundle/SkillSuiteBundlePreviewPlanner.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/bundle/SkillSuiteBundlePreviewPlanner.java new file mode 100644 index 00000000..450677a1 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/bundle/SkillSuiteBundlePreviewPlanner.java @@ -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 namespaceRoles, + Set platformRoles + ) { + SkillSuiteBundleManifest manifest = packageAnalysis.manifest(); + Map safeNamespaceRoles = namespaceRoles == null ? Map.of() : namespaceRoles; + Set safePlatformRoles = platformRoles == null ? Set.of() : platformRoles; + List errors = new ArrayList<>(packageAnalysis.errors()); + List warnings = new ArrayList<>(); + + Set namespaceSlugs = manifest.spec().members().stream() + .map(member -> member.coordinate().namespace()) + .collect(Collectors.toCollection(LinkedHashSet::new)); + namespaceSlugs.add(manifest.metadata().coordinate().namespace()); + Map 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 namespaceIds = namespaces.values().stream().map(Namespace::getId).distinct().toList(); + List skillSlugs = manifest.spec().members().stream() + .map(member -> member.coordinate().slug()).distinct().toList(); + List skills = namespaceIds.isEmpty() || skillSlugs.isEmpty() + ? List.of() + : skillRepository.findByNamespaceIdInAndSlugIn(namespaceIds, skillSlugs); + List skillIds = skills.stream().map(Skill::getId).distinct().toList(); + + List pendingVersions = skillIds.isEmpty() + ? List.of() + : skillVersionRepository.findBySkillIdInAndStatus(skillIds, SkillVersionStatus.PENDING_REVIEW); + List requestedVersions = requestedVersions(packageAnalysis); + List namedVersions = skillIds.isEmpty() || requestedVersions.isEmpty() + ? List.of() + : skillVersionRepository.findBySkillIdInAndVersionIn(skillIds, requestedVersions); + + List latestVersionIds = skills.stream() + .map(Skill::getLatestVersionId) + .filter(Objects::nonNull) + .distinct() + .toList(); + List latestVersions = latestVersionIds.isEmpty() + ? List.of() + : skillVersionRepository.findByIdIn(latestVersionIds); + Map 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 publishedVersions = List.copyOf(publishCandidatesById.values()); + + Map> publishedBySkill = groupVersions(publishedVersions); + Map> pendingBySkill = groupVersions(pendingVersions); + Map> namedBySkill = groupVersions(namedVersions); + List publishedVersionIds = publishedVersions.stream().map(SkillVersion::getId).distinct().toList(); + Map> filesByVersion = publishedVersionIds.isEmpty() + ? Map.of() + : skillFileRepository.findByVersionIdIn(publishedVersionIds).stream() + .collect(Collectors.groupingBy(SkillFile::getVersionId)); + Map fingerprints = publishedVersions.stream().collect(Collectors.toMap( + SkillVersion::getId, + version -> fingerprint(filesByVersion.getOrDefault(version.getId(), List.of())), + (left, right) -> left)); + + Map packageMembers = + packageAnalysis.packageMembers().stream().collect(Collectors.toMap( + SkillSuiteBundlePackageAnalyzer.MemberPackageAnalysis::coordinate, + Function.identity())); + Map baseline = baselineMembers(target); + List members = new ArrayList<>(); + Set 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 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 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 namespaceRoles, + Set platformRoles, + List 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 coordinateSkills, + Map> publishedBySkill, + Map> pendingBySkill, + Map> namedBySkill, + Map fingerprints, + String actorId, + Map namespaceRoles, + Set platformRoles + ) { + List errors = new ArrayList<>(); + List 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 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 coordinateSkills, + Map> namedBySkill, + Map fingerprints, + String actorId, + Map namespaceRoles, + Set platformRoles + ) { + String requestedVersion = member.referenceSource().version(); + List 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 namespaceRoles, + Set 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 skills, + String actorId, + NamespaceRole namespaceRole, + Set platformRoles, + List errors + ) { + boolean namespaceAdmin = namespaceRole == NamespaceRole.OWNER || namespaceRole == NamespaceRole.ADMIN; + boolean superAdmin = platformRoles.contains("SUPER_ADMIN"); + List 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 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 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 requestedVersions( + SkillSuiteBundlePackageAnalyzer.BundleAnalysis packageAnalysis + ) { + LinkedHashSet 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> groupVersions(List versions) { + return versions.stream().collect(Collectors.groupingBy(SkillVersion::getSkillId)); + } + + private String fingerprint(List 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 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 members, + List removedMembers, + List errors, + List 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 files, + List errors, + List warnings + ) { + private static MemberPlan invalidPackage( + SkillSuiteBundleCoordinate coordinate, List 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 files, + List errors, + List 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 + ) { + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/bundle/SkillSuiteBundlePreviewRevalidationService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/bundle/SkillSuiteBundlePreviewRevalidationService.java new file mode 100644 index 00000000..6cc9f380 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/bundle/SkillSuiteBundlePreviewRevalidationService.java @@ -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 namespaceRoles, + Set 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 manifestMembers = + manifest.spec().members().stream().collect(Collectors.toMap( + SkillSuiteBundleMember::coordinate, Function.identity())); + List 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 + ) { + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/bundle/SkillSuiteBundleResponseMapper.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/bundle/SkillSuiteBundleResponseMapper.java new file mode 100644 index 00000000..47a64a5d --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/bundle/SkillSuiteBundleResponseMapper.java @@ -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 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 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.of() + : outcome.packageAnalysis().packageMembers().stream().collect( + Collectors.toMap( + SkillSuiteBundlePackageAnalyzer.MemberPackageAnalysis::coordinate, + SkillSuiteBundlePackageAnalyzer.MemberPackageAnalysis::directory)); + List 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 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()); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/bundle/SkillSuiteBundleStagedCleanupService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/bundle/SkillSuiteBundleStagedCleanupService.java new file mode 100644 index 00000000..bfa47dc6 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/bundle/SkillSuiteBundleStagedCleanupService.java @@ -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 planJson) { + try { + SkillSuiteBundlePreviewPlanner.PreviewPlan plan = objectMapper.convertValue( + planJson, SkillSuiteBundlePreviewPlanner.PreviewPlan.class); + Set keys = new LinkedHashSet<>(); + keys.add(archiveObjectKey); + plan.members().stream() + .flatMap(member -> member.files().stream()) + .map(SkillSuiteBundlePackageAnalyzer.StagedMemberFile::objectKey) + .forEach(keys::add); + List 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 values) { + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/bundle/SkillSuiteBundleStagedEntry.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/bundle/SkillSuiteBundleStagedEntry.java new file mode 100644 index 00000000..ad183b46 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/bundle/SkillSuiteBundleStagedEntry.java @@ -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; + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/bundle/package-info.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/bundle/package-info.java new file mode 100644 index 00000000..284ade10 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/bundle/package-info.java @@ -0,0 +1,2 @@ +/** Suite Bundle upload analysis and orchestration application services. */ +package com.iflytek.skillhub.service.bundle; diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/task/SkillSuiteBundleRecoveryTask.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/task/SkillSuiteBundleRecoveryTask.java new file mode 100644 index 00000000..b33b9c8a --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/task/SkillSuiteBundleRecoveryTask.java @@ -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 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); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/task/SkillSuiteBundleStagedCleanupTask.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/task/SkillSuiteBundleStagedCleanupTask.java new file mode 100644 index 00000000..1a91ef69 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/task/SkillSuiteBundleStagedCleanupTask.java @@ -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 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); + } +} diff --git a/server/skillhub-app/src/main/resources/application.yml b/server/skillhub-app/src/main/resources/application.yml index 0d8575b3..ca11660a 100644 --- a/server/skillhub-app/src/main/resources/application.yml +++ b/server/skillhub-app/src/main/resources/application.yml @@ -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 diff --git a/server/skillhub-app/src/main/resources/db/migration/V54__skill_suite_bundle_operations.sql b/server/skillhub-app/src/main/resources/db/migration/V54__skill_suite_bundle_operations.sql new file mode 100644 index 00000000..5a49d298 --- /dev/null +++ b/server/skillhub-app/src/main/resources/db/migration/V54__skill_suite_bundle_operations.sql @@ -0,0 +1,97 @@ +-- Durable two-stage Suite Bundle workflow. Preview rows never reserve a target; only active +-- execution operations participate in the partial unique reservation index. +CREATE TABLE skill_suite_bundle_preview ( + token VARCHAR(64) PRIMARY KEY, + actor_id VARCHAR(128) NOT NULL, + mode VARCHAR(16) NOT NULL, + namespace_id BIGINT NOT NULL REFERENCES namespace(id), + target_suite_slug VARCHAR(128) NOT NULL, + target_suite_id BIGINT REFERENCES skill_suite(id) ON DELETE SET NULL, + base_suite_version_id BIGINT REFERENCES skill_suite_version(id) ON DELETE SET NULL, + target_version VARCHAR(64) NOT NULL, + archive_object_key VARCHAR(1024) NOT NULL, + archive_sha256 VARCHAR(64) NOT NULL, + manifest_json JSONB NOT NULL, + plan_json JSONB NOT NULL, + warning_digest VARCHAR(64) NOT NULL, + status VARCHAR(32) NOT NULL, + expires_at TIMESTAMPTZ NOT NULL, + confirmed_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + lock_version BIGINT NOT NULL DEFAULT 0 +); + +CREATE INDEX idx_suite_bundle_preview_actor_created + ON skill_suite_bundle_preview(actor_id, created_at DESC); +CREATE INDEX idx_suite_bundle_preview_expiry + ON skill_suite_bundle_preview(expires_at) + WHERE status = 'PREVIEW_READY'; + +CREATE TABLE skill_suite_bundle_operation ( + operation_id VARCHAR(64) PRIMARY KEY, + preview_token VARCHAR(64) NOT NULL UNIQUE REFERENCES skill_suite_bundle_preview(token), + client_request_id VARCHAR(64) NOT NULL, + actor_id VARCHAR(128) NOT NULL, + mode VARCHAR(16) NOT NULL, + namespace_id BIGINT NOT NULL REFERENCES namespace(id), + target_suite_slug VARCHAR(128) NOT NULL, + target_suite_id BIGINT REFERENCES skill_suite(id) ON DELETE SET NULL, + base_suite_version_id BIGINT REFERENCES skill_suite_version(id) ON DELETE SET NULL, + target_version VARCHAR(64) NOT NULL, + reservation_key VARCHAR(256) NOT NULL, + reservation_active BOOLEAN NOT NULL DEFAULT TRUE, + archive_object_key VARCHAR(1024) NOT NULL, + archive_sha256 VARCHAR(64) NOT NULL, + plan_json JSONB NOT NULL, + warning_digest VARCHAR(64) NOT NULL, + status VARCHAR(32) NOT NULL, + failure_code VARCHAR(128), + failure_detail TEXT, + result_suite_id BIGINT REFERENCES skill_suite(id) ON DELETE SET NULL, + result_suite_version_id BIGINT REFERENCES skill_suite_version(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + completed_at TIMESTAMPTZ, + lock_version BIGINT NOT NULL DEFAULT 0, + CONSTRAINT uk_suite_bundle_operation_actor_request UNIQUE (actor_id, client_request_id) +); + +CREATE UNIQUE INDEX uk_suite_bundle_operation_active_reservation + ON skill_suite_bundle_operation(reservation_key) + WHERE reservation_active = TRUE; +CREATE INDEX idx_suite_bundle_operation_actor_created + ON skill_suite_bundle_operation(actor_id, created_at DESC); +CREATE INDEX idx_suite_bundle_operation_recovery + ON skill_suite_bundle_operation(status, updated_at) + WHERE status IN ('RUNNING', 'WAITING_FOR_MEMBERS', 'BLOCKED_RETRYABLE'); + +CREATE TABLE skill_suite_bundle_member_result ( + id BIGSERIAL PRIMARY KEY, + operation_id VARCHAR(64) NOT NULL REFERENCES skill_suite_bundle_operation(operation_id) ON DELETE CASCADE, + position INT NOT NULL CHECK (position >= 0), + namespace_slug VARCHAR(128) NOT NULL, + skill_slug VARCHAR(128) NOT NULL, + source_type VARCHAR(32) NOT NULL, + package_path VARCHAR(1024), + requested_visibility VARCHAR(32), + requested_version VARCHAR(64), + relationship_change VARCHAR(32) NOT NULL, + publish_action VARCHAR(32) NOT NULL, + status VARCHAR(32) NOT NULL, + fingerprint VARCHAR(255), + skill_id BIGINT REFERENCES skill(id) ON DELETE SET NULL, + skill_version_id BIGINT REFERENCES skill_version(id) ON DELETE SET NULL, + errors JSONB NOT NULL DEFAULT '[]'::jsonb, + warnings JSONB NOT NULL DEFAULT '[]'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT uk_suite_bundle_member_position UNIQUE (operation_id, position), + CONSTRAINT uk_suite_bundle_member_skill UNIQUE (operation_id, namespace_slug, skill_slug), + CONSTRAINT ck_suite_bundle_member_source CHECK ( + (source_type = 'PACKAGE' AND package_path IS NOT NULL) + OR (source_type = 'REFERENCE' AND package_path IS NULL AND requested_version IS NOT NULL) + ) +); + +CREATE INDEX idx_suite_bundle_member_operation_status + ON skill_suite_bundle_member_result(operation_id, status); diff --git a/server/skillhub-app/src/main/resources/db/migration/V55__suite_bundle_staged_cleanup.sql b/server/skillhub-app/src/main/resources/db/migration/V55__suite_bundle_staged_cleanup.sql new file mode 100644 index 00000000..5ef234be --- /dev/null +++ b/server/skillhub-app/src/main/resources/db/migration/V55__suite_bundle_staged_cleanup.sql @@ -0,0 +1,18 @@ +ALTER TABLE skill_suite_bundle_preview + ADD COLUMN staged_objects_cleaned_at TIMESTAMPTZ, + ADD COLUMN staged_cleanup_failed_at TIMESTAMPTZ, + ADD COLUMN staged_cleanup_failure_code VARCHAR(128); + +ALTER TABLE skill_suite_bundle_operation + ADD COLUMN staged_objects_cleaned_at TIMESTAMPTZ, + ADD COLUMN staged_cleanup_failed_at TIMESTAMPTZ, + ADD COLUMN staged_cleanup_failure_code VARCHAR(128); + +CREATE INDEX idx_suite_bundle_preview_cleanup + ON skill_suite_bundle_preview(status, expires_at) + WHERE staged_objects_cleaned_at IS NULL AND status = 'EXPIRED'; + +CREATE INDEX idx_suite_bundle_operation_cleanup + ON skill_suite_bundle_operation(status, completed_at) + WHERE staged_objects_cleaned_at IS NULL + AND status IN ('REPREVIEW_REQUIRED', 'SUITE_DRAFT_CREATED', 'CANCELLED'); diff --git a/server/skillhub-app/src/main/resources/db/migration/V56__suite_member_reverse_lookup.sql b/server/skillhub-app/src/main/resources/db/migration/V56__suite_member_reverse_lookup.sql new file mode 100644 index 00000000..4cd32592 --- /dev/null +++ b/server/skillhub-app/src/main/resources/db/migration/V56__suite_member_reverse_lookup.sql @@ -0,0 +1,4 @@ +-- Reverse Skill-to-Suite discovery starts from any current snapshot member, not only the entry. +CREATE INDEX idx_skill_suite_member_skill + ON skill_suite_version_member(skill_id) + WHERE skill_id IS NOT NULL; diff --git a/server/skillhub-app/src/main/resources/db/migration/V57__skill_suite_labels.sql b/server/skillhub-app/src/main/resources/db/migration/V57__skill_suite_labels.sql new file mode 100644 index 00000000..a5204e4f --- /dev/null +++ b/server/skillhub-app/src/main/resources/db/migration/V57__skill_suite_labels.sql @@ -0,0 +1,11 @@ +CREATE TABLE skill_suite_label ( + id BIGSERIAL PRIMARY KEY, + suite_id BIGINT NOT NULL REFERENCES skill_suite(id) ON DELETE CASCADE, + label_id BIGINT NOT NULL REFERENCES label_definition(id) ON DELETE CASCADE, + created_by VARCHAR(128) REFERENCES user_account(id), + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE (suite_id, label_id) +); + +CREATE INDEX idx_skill_suite_label_suite_id ON skill_suite_label(suite_id); +CREATE INDEX idx_skill_suite_label_label_id ON skill_suite_label(label_id); diff --git a/server/skillhub-app/src/main/resources/db/migration/V58__index_active_bundle_operations_by_actor.sql b/server/skillhub-app/src/main/resources/db/migration/V58__index_active_bundle_operations_by_actor.sql new file mode 100644 index 00000000..c764fb17 --- /dev/null +++ b/server/skillhub-app/src/main/resources/db/migration/V58__index_active_bundle_operations_by_actor.sql @@ -0,0 +1,2 @@ +CREATE INDEX idx_suite_bundle_operation_actor_status_updated + ON skill_suite_bundle_operation(actor_id, status, updated_at DESC, operation_id DESC); diff --git a/server/skillhub-app/src/main/resources/db/migration/V59__index_bundle_operation_history_by_actor.sql b/server/skillhub-app/src/main/resources/db/migration/V59__index_bundle_operation_history_by_actor.sql new file mode 100644 index 00000000..c9096e12 --- /dev/null +++ b/server/skillhub-app/src/main/resources/db/migration/V59__index_bundle_operation_history_by_actor.sql @@ -0,0 +1,11 @@ +CREATE INDEX idx_suite_bundle_operation_actor_priority_updated + ON skill_suite_bundle_operation( + actor_id, + (CASE + WHEN status IN ('BLOCKED_RETRYABLE', 'REPREVIEW_REQUIRED') THEN 0 + WHEN status IN ('RUNNING', 'WAITING_FOR_MEMBERS') THEN 1 + ELSE 2 + END), + updated_at DESC, + operation_id DESC + ); diff --git a/server/skillhub-app/src/main/resources/messages.properties b/server/skillhub-app/src/main/resources/messages.properties index 6601d92c..43059acd 100644 --- a/server/skillhub-app/src/main/resources/messages.properties +++ b/server/skillhub-app/src/main/resources/messages.properties @@ -47,6 +47,7 @@ error.auth.sessionBootstrap.disabled=Session bootstrap is disabled error.auth.sessionBootstrap.providerUnsupported=Unsupported session bootstrap provider: {0} error.auth.sessionBootstrap.notAuthenticated=No authenticated external session found error.badRequest=Invalid request +error.suite.workspace.invalidFilter=Invalid suite status filter error.methodNotAllowed=HTTP method is not supported error.unsupportedMediaType=Unsupported media type error.notAcceptable=Requested response media type is not acceptable @@ -210,7 +211,7 @@ error.suite.members.limit=A Skill Suite cannot contain more than {0} Skills error.suite.members.duplicate=A Skill Suite cannot contain multiple versions of the same Skill error.suite.members.unavailable=One or more Suite members are unavailable: {0} error.suite.members.selectionMismatch=The selected Skill version does not match its coordinate -error.suite.members.invalid=Invalid Suite members: {0} +error.suite.members.invalid=These Suite members are unavailable. Check their published version and your access: {0} error.suite.entry.notMember=The Entry Skill must be one of the Suite members error.suite.entry.required=An Entry Skill is required error.suite.namespace.notWritable=The Suite namespace is not writable: {0} @@ -223,6 +224,11 @@ error.suite.review.notRejected=Skill Suite version {0} was not rejected error.suite.review.subjectMismatch=The review task does not target a Skill Suite version error.suite.publish.notPrivate=Only private Skill Suites can be published without review error.suite.publish.notDraft=Skill Suite version {0} is not a draft +error.suite.summary.required=Add a Suite summary before submitting or publishing this version +error.suite.overview.required=Add a Suite overview before submitting or publishing this version +label.suite.too_many=Skill Suite {0} cannot have more than {1} labels +label.suite.not_found=Label {1} is not attached to Skill Suite {0} +label.suite.no_permission=You do not have permission to manage this Skill Suite label error.suite.displayName.required=Skill Suite display name is required error.suite.version.required=Skill Suite version is required error.suite.version.invalid=Skill Suite version must use 1-64 portable characters: letters, numbers, dot, underscore, plus, or hyphen @@ -235,3 +241,17 @@ error.suite.version.renameNotAllowed=A Skill Suite version cannot be renamed; cr error.suite.delete.pendingReview=Skill Suite cannot be deleted while a review is pending error.suite.install.operationConflict=The idempotency key was already used for another Skill Suite version error.suite.install.idempotencyKey.invalid=The Suite install idempotency key is invalid +error.suite.bundle.manifest.invalid=Invalid Suite Bundle manifest: {0} +error.suite.bundle.preview.ownerMismatch=This Suite Bundle preview belongs to another user +error.suite.bundle.preview.expired=This Suite Bundle preview is unavailable or expired; create a new preview +error.suite.bundle.preview.warningMismatch=The Suite Bundle warnings changed; review a new preview before confirming +error.suite.bundle.preview.notFound=The Suite Bundle preview does not exist +error.suite.bundle.preview.stateChanged=Suite, permission, package, or reference state changed; create a new preview +error.suite.bundle.confirmation.disabled=Suite Bundle confirmation is not enabled on this deployment +error.suite.bundle.confirmation.idempotencyKey.invalid=The Suite Bundle confirmation idempotency key is invalid +error.suite.bundle.confirmation.operationConflict=The idempotency key or target is already used by another Suite Bundle operation +error.suite.bundle.operation.notFound=Suite Bundle operation not found +error.suite.bundle.operation.cancel.notAllowed=This Suite Bundle operation can no longer be cancelled +error.suite.bundle.operation.retry.notAllowed=Only a retryable blocked Suite Bundle operation can be retried +error.suite.bundle.member.stateChanged=The bound Skill or version state changed; create a new Suite Bundle preview +error.suite.bundle.actor.inactive=The Suite Bundle actor is no longer active diff --git a/server/skillhub-app/src/main/resources/messages_ru.properties b/server/skillhub-app/src/main/resources/messages_ru.properties index f2416492..e72ed9ea 100644 --- a/server/skillhub-app/src/main/resources/messages_ru.properties +++ b/server/skillhub-app/src/main/resources/messages_ru.properties @@ -45,6 +45,7 @@ error.auth.sessionBootstrap.disabled=Инициализация сессии о error.auth.sessionBootstrap.providerUnsupported=Неподдерживаемый провайдер инициализации сессии: {0} error.auth.sessionBootstrap.notAuthenticated=Внешняя аутентифицированная сессия не найдена error.badRequest=Некорректный запрос +error.suite.workspace.invalidFilter=Некорректный фильтр статуса набора error.methodNotAllowed=HTTP-метод не поддерживается error.unsupportedMediaType=Неподдерживаемый тип медиа error.notAcceptable=Запрошенный тип ответа не поддерживается @@ -188,3 +189,23 @@ error.skillReview.reason.tooLong=Причина модерации не долж error.pagination.invalid=Номер страницы не может быть отрицательным, а размер должен быть от 1 до {0} error.request.conflict=Данные изменились во время обработки запроса. Обновите страницу и повторите попытку. error.skillReview.notInteractable=Отзывы доступны только для опубликованных навыков +error.suite.members.invalid=Эти участники набора недоступны. Проверьте опубликованную версию и права доступа: {0} +error.suite.summary.required=Перед отправкой или публикацией этой версии добавьте краткое описание набора +error.suite.overview.required=Перед отправкой или публикацией этой версии добавьте обзор набора +label.suite.too_many=Набор навыков {0} не может иметь более {1} меток +label.suite.not_found=Метка {1} не связана с набором навыков {0} +label.suite.no_permission=У вас нет прав на управление метками этого набора навыков +error.suite.bundle.manifest.invalid=Недопустимый манифест пакета Skill Suite: {0} +error.suite.bundle.preview.ownerMismatch=Этот предварительный просмотр пакета Skill Suite принадлежит другому пользователю +error.suite.bundle.preview.expired=Предварительный просмотр пакета Skill Suite недоступен или истёк; создайте новый +error.suite.bundle.preview.warningMismatch=Предупреждения пакета Skill Suite изменились; создайте и проверьте новый предварительный просмотр +error.suite.bundle.preview.notFound=Предварительный просмотр пакета Skill Suite не существует +error.suite.bundle.preview.stateChanged=Состояние набора, разрешений, пакета или ссылки изменилось; создайте новый предварительный просмотр +error.suite.bundle.confirmation.disabled=Подтверждение пакета Skill Suite не включено в этом развёртывании +error.suite.bundle.confirmation.idempotencyKey.invalid=Недопустимый ключ идемпотентности подтверждения пакета Skill Suite +error.suite.bundle.confirmation.operationConflict=Ключ идемпотентности или цель уже используются другой операцией пакета Skill Suite +error.suite.bundle.operation.notFound=Операция пакета Skill Suite не найдена +error.suite.bundle.operation.cancel.notAllowed=Эту операцию пакета Skill Suite больше нельзя отменить +error.suite.bundle.operation.retry.notAllowed=Повторить можно только заблокированную операцию Skill Suite, допускающую повтор +error.suite.bundle.member.stateChanged=Состояние связанного Skill или версии изменилось; создайте новый предварительный просмотр пакета Skill Suite +error.suite.bundle.actor.inactive=Пользователь операции Skill Suite Bundle больше не активен diff --git a/server/skillhub-app/src/main/resources/messages_zh.properties b/server/skillhub-app/src/main/resources/messages_zh.properties index e43d42bf..1b867375 100644 --- a/server/skillhub-app/src/main/resources/messages_zh.properties +++ b/server/skillhub-app/src/main/resources/messages_zh.properties @@ -47,6 +47,7 @@ error.auth.sessionBootstrap.disabled=会话引导能力未启用 error.auth.sessionBootstrap.providerUnsupported=不支持的会话引导提供方:{0} error.auth.sessionBootstrap.notAuthenticated=未检测到已认证的外部会话 error.badRequest=请求参数不合法 +error.suite.workspace.invalidFilter=套件状态筛选不合法 error.methodNotAllowed=不支持的请求方法 error.unsupportedMediaType=不支持的请求内容类型 error.notAcceptable=无法返回客户端接受的内容类型 @@ -210,7 +211,7 @@ error.suite.members.limit=技能套件最多包含 {0} 个技能 error.suite.members.duplicate=技能套件不能包含同一技能的多个版本 error.suite.members.unavailable=一个或多个套件成员当前不可用:{0} error.suite.members.selectionMismatch=所选技能版本与提交的坐标不匹配 -error.suite.members.invalid=套件成员无效:{0} +error.suite.members.invalid=以下套件成员当前不可用,请检查是否为已发布版本以及你是否有访问权限:{0} error.suite.entry.notMember=入口技能必须是套件成员 error.suite.entry.required=必须选择入口技能 error.suite.namespace.notWritable=套件所在命名空间不可写:{0} @@ -223,6 +224,11 @@ error.suite.review.notRejected=技能套件版本 {0} 未被驳回 error.suite.review.subjectMismatch=该审核任务不属于技能套件版本 error.suite.publish.notPrivate=只有私有技能套件可以免审核发布 error.suite.publish.notDraft=技能套件版本 {0} 不是草稿 +error.suite.summary.required=提交或发布该版本前,请先填写套件摘要 +error.suite.overview.required=提交或发布该版本前,请先填写套件概述 +label.suite.too_many=技能套件 {0} 最多只能配置 {1} 个标签 +label.suite.not_found=技能套件 {0} 未关联标签 {1} +label.suite.no_permission=无权管理该技能套件的标签 error.suite.displayName.required=技能套件显示名称不能为空 error.suite.version.required=技能套件版本不能为空 error.suite.version.invalid=技能套件版本须为 1-64 位,且只能包含字母、数字、点、下划线、加号或连字符 @@ -235,3 +241,17 @@ error.suite.version.renameNotAllowed=不能修改技能套件版本号,请创 error.suite.delete.pendingReview=技能套件存在待审核版本,暂不能删除 error.suite.install.operationConflict=该幂等键已用于其他技能套件版本 error.suite.install.idempotencyKey.invalid=技能套件安装幂等键格式无效 +error.suite.bundle.manifest.invalid=技能套件 Bundle Manifest 无效:{0} +error.suite.bundle.preview.ownerMismatch=该技能套件 Bundle 预览属于其他用户 +error.suite.bundle.preview.expired=该技能套件 Bundle 预览不可用或已过期,请重新预览 +error.suite.bundle.preview.warningMismatch=技能套件 Bundle 的警告已变化,请重新预览并确认 +error.suite.bundle.preview.notFound=技能套件 Bundle 预览不存在 +error.suite.bundle.preview.stateChanged=套件、权限、成员包或引用状态已经变化,请重新预览 +error.suite.bundle.confirmation.disabled=当前部署尚未启用技能套件 Bundle 确认功能 +error.suite.bundle.confirmation.idempotencyKey.invalid=技能套件 Bundle 确认幂等键格式无效 +error.suite.bundle.confirmation.operationConflict=该幂等键或目标已被其他技能套件 Bundle 操作占用 +error.suite.bundle.operation.notFound=技能套件 Bundle 操作不存在 +error.suite.bundle.operation.cancel.notAllowed=该技能套件 Bundle 操作已不能取消 +error.suite.bundle.operation.retry.notAllowed=只有处于可重试阻塞状态的技能套件 Bundle 操作才能重试 +error.suite.bundle.member.stateChanged=绑定的技能或版本状态已经变化,请重新创建技能套件 Bundle 预览 +error.suite.bundle.actor.inactive=技能套件 Bundle 的操作者已不再处于可用状态 diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/ReviewPortalControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/ReviewPortalControllerTest.java index 432fdaaa..3e35e561 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/ReviewPortalControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/ReviewPortalControllerTest.java @@ -201,7 +201,8 @@ class ReviewPortalControllerTest { new SkillLifecycleVersionResponse(100L, "1.2.0", "PENDING_REVIEW"), null, "REVIEW_TASK", - List.of() + List.of(), + new com.iflytek.skillhub.dto.PageResponse<>(List.of(), 0, 0, 20) ), List.of(new SkillVersionResponse(100L, "1.2.0", "PENDING_REVIEW", null, 1, 10L, null, true, null)), List.of(new SkillFileResponse(1L, "README.md", 123L, "text/markdown", "sha")), @@ -299,7 +300,7 @@ class ReviewPortalControllerTest { Instant.parse("2026-08-31T11:00:00Z"), 2L ); - given(reviewProgressQueryRepository.findMyProgress("author-1", null, "", 0, 20)) + given(reviewProgressQueryRepository.findMyProgress("author-1", null, null, "", 0, 20)) .willReturn(new ReviewProgressPageResponse( List.of(item), 1, @@ -316,7 +317,7 @@ class ReviewPortalControllerTest { .andExpect(jsonPath("$.data.statusCounts.pending").value(0)) .andExpect(jsonPath("$.data.statusCounts.rejected").value(1)); - verify(reviewProgressQueryRepository).findMyProgress("author-1", null, "", 0, 20); + verify(reviewProgressQueryRepository).findMyProgress("author-1", null, null, "", 0, 20); } @Test diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillControllerTest.java index 04aec5ad..60a6a64f 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillControllerTest.java @@ -188,10 +188,13 @@ class SkillControllerTest { null, "OWNER_PREVIEW" )); - when(skillSuiteAppService.findVisibleEntryReferences( - eq(1L), eq((String) null), eq(Map.of()), anySet())) - .thenReturn(List.of(new SkillSuiteReferenceResponse( - 9L, "team", "demo-suite", "Demo Suite", "2.0.0", 3))); + SkillSuiteReferenceResponse suiteReference = new SkillSuiteReferenceResponse( + 9L, "team", "demo-suite", "Demo Suite", "2.0.0", 3, + true, List.of(), 0, 0); + when(skillSuiteAppService.findVisibleMemberships( + eq(1L), eq((String) null), eq(Map.of()), anySet(), eq(0), eq(20))) + .thenReturn(new com.iflytek.skillhub.dto.PageResponse<>( + List.of(suiteReference), 1, 0, 20)); mockMvc.perform(get("/api/web/skills/team/demo")) .andExpect(status().isOk()) @@ -204,6 +207,8 @@ class SkillControllerTest { .andExpect(jsonPath("$.data.entryForSuites[0].slug").value("demo-suite")) .andExpect(jsonPath("$.data.entryForSuites[0].version").value("2.0.0")) .andExpect(jsonPath("$.data.entryForSuites[0].memberCount").value(3)) + .andExpect(jsonPath("$.data.memberOfSuites.total").value(1)) + .andExpect(jsonPath("$.data.memberOfSuites.items[0].currentSkillEntry").value(true)) .andExpect(jsonPath("$.data.canInteract").value(false)) .andExpect(jsonPath("$.data.canReport").value(false)); } diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillSuiteLabelControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillSuiteLabelControllerTest.java new file mode 100644 index 00000000..c23bfbf9 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillSuiteLabelControllerTest.java @@ -0,0 +1,50 @@ +package com.iflytek.skillhub.controller; + +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; +import com.iflytek.skillhub.dto.SkillLabelDto; +import com.iflytek.skillhub.service.SkillSuiteLabelAppService; +import java.util.List; +import java.util.Map; +import java.util.Set; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +class SkillSuiteLabelControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockBean + private SkillSuiteLabelAppService skillSuiteLabelAppService; + + @MockBean + private NamespaceMemberRepository namespaceMemberRepository; + + @Test + void listSuiteLabelsShouldBeReadableThroughWebContract() throws Exception { + when(skillSuiteLabelAppService.listLabels( + eq("team"), eq("workflow"), isNull(), eq(Map.of()), eq(Set.of()))) + .thenReturn(List.of(new SkillLabelDto("healthcare", "RECOMMENDED", "医疗健康"))); + + mockMvc.perform(get("/api/web/suites/team/workflow/labels")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data[0].slug").value("healthcare")) + .andExpect(jsonPath("$.data[0].displayName").value("医疗健康")); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillSuiteBundleControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillSuiteBundleControllerTest.java new file mode 100644 index 00000000..d5765ea0 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillSuiteBundleControllerTest.java @@ -0,0 +1,230 @@ +package com.iflytek.skillhub.controller.portal; + +import com.iflytek.skillhub.TestRedisConfig; +import com.iflytek.skillhub.auth.device.DeviceAuthService; +import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; +import com.iflytek.skillhub.dto.SkillSuiteBundleOperationResponse; +import com.iflytek.skillhub.dto.SkillSuiteBundleOperationDetailResponse; +import com.iflytek.skillhub.dto.SkillSuiteBundleOperationPageResponse; +import com.iflytek.skillhub.dto.SkillSuiteBundleOperationSummaryResponse; +import com.iflytek.skillhub.dto.PageResponse; +import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleMode; +import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleOperationStatus; +import com.iflytek.skillhub.dto.SkillSuiteBundlePreviewResponse; +import com.iflytek.skillhub.service.bundle.SkillSuiteBundleConfirmationAppService; +import com.iflytek.skillhub.service.bundle.SkillSuiteBundlePreviewAppService; +import com.iflytek.skillhub.service.bundle.SkillSuiteBundleOperationQueryService; +import com.iflytek.skillhub.service.bundle.SkillSuiteBundleOperationCommandService; +import com.iflytek.skillhub.service.bundle.SkillSuiteBundleResponseMapper; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.Import; +import org.springframework.http.MediaType; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +@Import(TestRedisConfig.class) +class SkillSuiteBundleControllerTest { + + @Autowired private MockMvc mockMvc; + @MockBean private SkillSuiteBundlePreviewAppService previewService; + @MockBean private SkillSuiteBundleConfirmationAppService confirmationService; + @MockBean private SkillSuiteBundleOperationQueryService operationQueryService; + @MockBean private SkillSuiteBundleOperationCommandService operationCommandService; + @MockBean private SkillSuiteBundleResponseMapper responseMapper; + @MockBean private NamespaceMemberRepository namespaceMemberRepository; + @MockBean private DeviceAuthService deviceAuthService; + + @Test + void previewRequiresAuthentication() throws Exception { + mockMvc.perform(multipart("/api/v1/suite-bundles/preview") + .file(new MockMultipartFile( + "file", "bundle.zip", "application/zip", new byte[]{1})) + .with(csrf())) + .andExpect(status().isUnauthorized()); + verify(previewService, never()).preview(any(), any(), any(), any()); + } + + @Test + void authenticatedPreviewReturnsStructuredPlanWithoutStorageLocations() throws Exception { + SkillSuiteBundlePreviewAppService.PreviewOutcome outcome = + new SkillSuiteBundlePreviewAppService.PreviewOutcome( + "preview-1", Instant.parse("2026-09-11T09:00:00Z"), null, null); + SkillSuiteBundlePreviewResponse response = new SkillSuiteBundlePreviewResponse( + "preview-1", Instant.parse("2026-09-11T09:00:00Z"), true, null, + List.of(), List.of(), List.of(), List.of(), "warning-digest"); + when(previewService.preview(any(), eq("actor"), eq(Map.of()), eq(Set.of()))) + .thenReturn(outcome); + when(responseMapper.toResponse(outcome)).thenReturn(response); + + mockMvc.perform(multipart("/api/v1/suite-bundles/preview") + .file(new MockMultipartFile( + "file", "bundle.zip", "application/zip", new byte[]{1})) + .with(authentication(authToken("actor"))) + .with(csrf())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.previewToken").value("preview-1")) + .andExpect(jsonPath("$.data.confirmable").value(true)) + .andExpect(jsonPath("$.data.warningDigest").value("warning-digest")) + .andExpect(jsonPath("$.data.archiveObjectKey").doesNotExist()); + } + + @Test + void previewArchiveIsRequiredByTheHttpContract() throws Exception { + mockMvc.perform(multipart("/api/v1/suite-bundles/preview") + .with(authentication(authToken("actor"))) + .with(csrf())) + .andExpect(status().isBadRequest()); + verify(previewService, never()).preview(any(), any(), any(), any()); + } + + @Test + void confirmationRequiresWarningDigestAndPassesIdempotencyKey() throws Exception { + mockMvc.perform(post("/api/v1/suite-bundles/previews/preview-1/confirm") + .with(authentication(authToken("actor"))) + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content("{}")) + .andExpect(status().isBadRequest()); + verify(confirmationService, never()).confirm(any(), any(), any(), any(), any(), any()); + + mockMvc.perform(post("/api/v1/suite-bundles/previews/preview-1/confirm") + .with(authentication(authToken("actor"))) + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"warningDigest\":\"warning-digest\"}")) + .andExpect(status().isBadRequest()); + verify(confirmationService, never()).confirm(any(), any(), any(), any(), any(), any()); + + SkillSuiteBundleConfirmationAppService.ConfirmationOutcome outcome = + new SkillSuiteBundleConfirmationAppService.ConfirmationOutcome( + "operation-1", "RUNNING", false); + when(confirmationService.confirm( + "preview-1", "request-1", "warning-digest", "actor", Map.of(), Set.of())) + .thenReturn(outcome); + when(responseMapper.toResponse(outcome)).thenReturn( + new SkillSuiteBundleOperationResponse("operation-1", "RUNNING", false)); + + mockMvc.perform(post("/api/v1/suite-bundles/previews/preview-1/confirm") + .with(authentication(authToken("actor"))) + .with(csrf()) + .header("Idempotency-Key", "request-1") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"warningDigest\":\"warning-digest\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.operationId").value("operation-1")) + .andExpect(jsonPath("$.data.status").value("RUNNING")) + .andExpect(jsonPath("$.data.replayed").value(false)); + } + + @Test + void authenticatedCallerCanReadRedactedOperationStatus() throws Exception { + SkillSuiteBundleOperationDetailResponse response = new SkillSuiteBundleOperationDetailResponse( + "operation-1", SkillSuiteBundleOperationStatus.RUNNING, SkillSuiteBundleMode.UPDATE, + "@global/suite", 1L, 10L, "1.1.0", "1.0.0", null, null, null, + Instant.parse("2026-09-11T08:00:00Z"), Instant.parse("2026-09-11T08:00:00Z"), + null, List.of()); + when(operationQueryService.get("operation-1", "actor", Map.of(), Set.of())) + .thenReturn(response); + + mockMvc.perform(get("/api/v1/suite-bundles/operations/operation-1") + .with(authentication(authToken("actor")))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.operationId").value("operation-1")) + .andExpect(jsonPath("$.data.targetCoordinate").value("@global/suite")) + .andExpect(jsonPath("$.data.baseVersion").value("1.0.0")) + .andExpect(jsonPath("$.data.actorId").doesNotExist()) + .andExpect(jsonPath("$.data.archiveObjectKey").doesNotExist()) + .andExpect(jsonPath("$.data.plan").doesNotExist()); + } + + @Test + void authenticatedCallerCanListTheirActiveOperations() throws Exception { + when(operationQueryService.listActive("actor", 0, 12)).thenReturn(new PageResponse<>(List.of( + new SkillSuiteBundleOperationSummaryResponse( + "operation-1", SkillSuiteBundleMode.CREATE, "@global/suite", "1.0.0", + SkillSuiteBundleOperationStatus.WAITING_FOR_MEMBERS, null, + null, 2, 1, 1, Instant.parse("2026-09-11T08:00:00Z"))), 1, 0, 12)); + + mockMvc.perform(get("/api/v1/suite-bundles/operations/active") + .with(authentication(authToken("actor")))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.items[0].operationId").value("operation-1")) + .andExpect(jsonPath("$.data.items[0].targetCoordinate").value("@global/suite")) + .andExpect(jsonPath("$.data.items[0].completedMembers").value(1)) + .andExpect(jsonPath("$.data.total").value(1)); + } + + @Test + void authenticatedCallerCanListTheirOperationHistory() throws Exception { + when(operationQueryService.listMine("actor", 0, 12)).thenReturn(new SkillSuiteBundleOperationPageResponse(List.of( + new SkillSuiteBundleOperationSummaryResponse( + "operation-cancelled", SkillSuiteBundleMode.CREATE, "@global/suite", "1.0.0", + SkillSuiteBundleOperationStatus.CANCELLED, null, + null, 2, 1, 0, Instant.parse("2026-09-11T08:00:00Z"))), 1, 0, 12, true)); + + mockMvc.perform(get("/api/v1/suite-bundles/operations/mine") + .with(authentication(authToken("actor")))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.total").value(1)) + .andExpect(jsonPath("$.data.hasChangingOperations").value(true)) + .andExpect(jsonPath("$.data.items[0].operationId").value("operation-cancelled")) + .andExpect(jsonPath("$.data.items[0].status").value("CANCELLED")); + } + + @Test + void authenticatedCallerCanCancelAndRetryAnOperation() throws Exception { + when(operationCommandService.cancel("operation-1", "actor", Map.of(), Set.of())) + .thenReturn(new SkillSuiteBundleOperationResponse("operation-1", "CANCELLED", false)); + when(operationCommandService.retry("operation-2", "actor", Map.of(), Set.of())) + .thenReturn(new SkillSuiteBundleOperationResponse("operation-2", "RUNNING", false)); + + mockMvc.perform(post("/api/v1/suite-bundles/operations/operation-1/cancel") + .with(authentication(authToken("actor"))) + .with(csrf())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.status").value("CANCELLED")); + + mockMvc.perform(post("/api/v1/suite-bundles/operations/operation-2/retry") + .with(authentication(authToken("actor"))) + .with(csrf())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.operationId").value("operation-2")) + .andExpect(jsonPath("$.data.status").value("RUNNING")); + } + + private UsernamePasswordAuthenticationToken authToken(String userId) { + PlatformPrincipal principal = new PlatformPrincipal( + userId, userId, userId + "@example.test", null, "local", Set.of()); + return new UsernamePasswordAuthenticationToken( + principal, null, List.of(new SimpleGrantedAuthority("ROLE_USER"))); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/integration/SkillSuiteBundleOperationQueryRepositoryTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/integration/SkillSuiteBundleOperationQueryRepositoryTest.java new file mode 100644 index 00000000..040ea1d3 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/integration/SkillSuiteBundleOperationQueryRepositoryTest.java @@ -0,0 +1,269 @@ +package com.iflytek.skillhub.integration; + +import com.iflytek.skillhub.domain.namespace.Namespace; +import com.iflytek.skillhub.domain.skill.SkillVisibility; +import com.iflytek.skillhub.domain.suite.SkillSuite; +import com.iflytek.skillhub.domain.suite.SkillSuiteVersion; +import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleCoordinate; +import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleExecutionOperation; +import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleMemberResult; +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.SkillSuiteBundlePreviewSession; +import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundlePublishAction; +import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleRelationshipChange; +import com.iflytek.skillhub.domain.user.UserAccount; +import com.iflytek.skillhub.repository.SkillSuiteBundleOperationQueryRepository; +import com.iflytek.skillhub.repository.MySkillSuiteQueryRepository; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.test.context.TestPropertySource; +import org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +import java.time.Instant; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +@DataJpaTest +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +@ActiveProfiles("test") +@Import({SkillSuiteBundleOperationQueryRepository.class, MySkillSuiteQueryRepository.class}) +@Testcontainers +@TestPropertySource(properties = { + "spring.flyway.enabled=true", + "spring.jpa.hibernate.ddl-auto=validate", + "spring.jpa.show-sql=false", + "logging.level.org.hibernate.SQL=OFF" +}) +class SkillSuiteBundleOperationQueryRepositoryTest { + + private static final Instant NOW = Instant.parse("2026-09-14T08:00:00Z"); + + @Container + private static final PostgreSQLContainer POSTGRES = + new PostgreSQLContainer<>("postgres:16-alpine"); + + @DynamicPropertySource + static void configurePostgres(DynamicPropertyRegistry registry) { + registry.add("spring.datasource.url", POSTGRES::getJdbcUrl); + registry.add("spring.datasource.username", POSTGRES::getUsername); + registry.add("spring.datasource.password", POSTGRES::getPassword); + registry.add("spring.datasource.driver-class-name", () -> "org.postgresql.Driver"); + registry.add("spring.jpa.database-platform", () -> "org.hibernate.dialect.PostgreSQLDialect"); + } + + @Autowired + private TestEntityManager entityManager; + + @Autowired + private SkillSuiteBundleOperationQueryRepository repository; + + @Autowired + private MySkillSuiteQueryRepository workspaceRepository; + + @Test + void workspacePagesTemporaryCreationsAndCountsAttentionAcrossAllPagesWithoutMemberQueries() { + entityManager.persist(new UserAccount("actor", "Actor", null, null)); + Namespace namespace = entityManager.persistFlushFind(new Namespace("team-ai", "AI Team", "actor")); + for (int index = 0; index < 15; index++) { + var operation = createOperation("operation-" + Integer.toHexString(index), "actor", namespace, "temporary-" + index); + if (index < 3) operation.markBlockedRetryable("MEMBER_EXECUTION_FAILED", "retry", NOW.plusSeconds(index)); + else operation.cancel(NOW.plusSeconds(index)); + entityManager.persist(operation); + } + entityManager.flush(); + var statistics = entityManager.getEntityManager().getEntityManagerFactory() + .unwrap(org.hibernate.engine.spi.SessionFactoryImplementor.class).getStatistics(); + statistics.setStatisticsEnabled(true); + statistics.clear(); + var first = workspaceRepository.findWorkspace("actor", java.util.Set.of(namespace.getId()), java.util.Set.of(), "", "", 0, 12); + assertThat(statistics.getPrepareStatementCount()).isEqualTo(2); + assertThat(first.items()).hasSize(12); + assertThat(first.total()).isEqualTo(15); + assertThat(first.attentionCount()).isEqualTo(3); + assertThat(first.hasChangingOperations()).isFalse(); + assertThat(first.items().getFirst().state()).isEqualTo("ATTENTION"); + var second = workspaceRepository.findWorkspace("actor", java.util.Set.of(namespace.getId()), java.util.Set.of(), "", "", 1, 12); + assertThat(second.items()).hasSize(3); + assertThat(second.attentionCount()).isEqualTo(3); + assertThat(workspaceRepository.findWorkspace("actor", java.util.Set.of(namespace.getId()), java.util.Set.of(), "", "ATTENTION", 0, 12).items()).hasSize(3); + assertThat(workspaceRepository.findWorkspace("other", java.util.Set.of(namespace.getId()), java.util.Set.of(), "", "", 0, 12).total()).isZero(); + assertThat(workspaceRepository.findWorkspace("actor", java.util.Set.of(), java.util.Set.of(), "", "", 0, 12).total()).isZero(); + assertThat(workspaceRepository.findWorkspace("actor", java.util.Set.of(namespace.getId()), java.util.Set.of(), "%", "", 0, 12).total()).isZero(); + assertThat(workspaceRepository.findWorkspace("actor", java.util.Set.of(namespace.getId()), java.util.Set.of(), "team-ai/temporary-1", "", 0, 12).total()).isEqualTo(6); + } + + @Test + void workspaceMergesCreatedSuiteAndKeepsSuiteReviewWithoutABundleOperation() { + entityManager.persist(new UserAccount("actor", "Actor", null, null)); + Namespace namespace = entityManager.persistFlushFind(new Namespace("team-ai", "AI Team", "actor")); + var operation = createOperation("operation-1", "actor", namespace, "care-suite"); + operation.cancel(NOW.plusSeconds(1)); + entityManager.persist(operation); + SkillSuite suite = entityManager.persistFlushFind(new SkillSuite(namespace.getId(), "care-suite", "Care", "actor")); + SkillSuiteVersion version = new SkillSuiteVersion(suite.getId(), "1.0.0", SkillVisibility.PUBLIC, "actor"); + version.setDisplayName("Care"); + version.setStatus(com.iflytek.skillhub.domain.suite.SkillSuiteVersionStatus.PENDING_REVIEW); + entityManager.persistAndFlush(version); + var result = workspaceRepository.findWorkspace("actor", java.util.Set.of(namespace.getId()), java.util.Set.of(), "", "", 0, 12); + assertThat(result.items()).singleElement().satisfies(item -> { + assertThat(item.suiteId()).isEqualTo(suite.getId()); + assertThat(item.state()).isEqualTo("PENDING_REVIEW"); + assertThat(item.operationId()).isNull(); + }); + assertThat(workspaceRepository.findWorkspace("admin", java.util.Set.of(namespace.getId()), java.util.Set.of(namespace.getId()), "", "PENDING_REVIEW", 0, 12).total()).isEqualTo(1); + assertThat(workspaceRepository.findWorkspace("other", java.util.Set.of(namespace.getId()), java.util.Set.of(), "", "", 0, 12).total()).isZero(); + } + + @Test + void pagesOnlyTheActorsActiveOperationsAndAggregatesMemberStatusesInPostgres() { + entityManager.persist(new UserAccount("actor", "Actor", null, null)); + Namespace namespace = entityManager.persistFlushFind( + new Namespace("team-ai", "AI Team", "actor")); + SkillSuite suite = entityManager.persistFlushFind( + new SkillSuite(namespace.getId(), "care-suite", "Care Suite", "actor")); + SkillSuiteVersion baseVersion = entityManager.persistFlushFind( + new SkillSuiteVersion(suite.getId(), "1.0.0", SkillVisibility.PUBLIC, "actor")); + SkillSuiteBundlePreviewSession preview = entityManager.persistFlushFind( + new SkillSuiteBundlePreviewSession( + "preview-1", "actor", SkillSuiteBundleMode.UPDATE, namespace.getId(), + suite.getSlug(), suite.getId(), baseVersion.getId(), "1.1.0", + "staging/archive.zip", "a".repeat(64), Map.of(), Map.of(), + "warning-digest", NOW.plusSeconds(600), NOW)); + SkillSuiteBundleExecutionOperation operation = new SkillSuiteBundleExecutionOperation( + "operation-1", preview.getToken(), "request-1", "actor", SkillSuiteBundleMode.UPDATE, + namespace.getId(), suite.getSlug(), suite.getId(), baseVersion.getId(), "1.1.0", + "staging/archive.zip", "a".repeat(64), Map.of(), "warning-digest", NOW); + operation.markWaitingForMembers(NOW.plusSeconds(1)); + entityManager.persist(operation); + entityManager.persist(member(0, "first", false)); + entityManager.persist(member(1, "second", true)); + SkillSuiteBundleExecutionOperation newest = createOperation( + "operation-2", "actor", namespace, "newer-suite"); + newest.markWaitingForMembers(NOW.plusSeconds(2)); + entityManager.persist(newest); + SkillSuiteBundleExecutionOperation terminal = createOperation( + "operation-3", "actor", namespace, "finished-suite"); + terminal.cancel(NOW.plusSeconds(3)); + entityManager.persist(terminal); + SkillSuiteBundleExecutionOperation anotherActor = createOperation( + "operation-4", "another-actor", namespace, "other-suite"); + anotherActor.markWaitingForMembers(NOW.plusSeconds(4)); + entityManager.persist(anotherActor); + entityManager.flush(); + + var firstPage = repository.findActive("actor", 0, 1); + var secondPage = repository.findActive("actor", 1, 1); + + assertThat(firstPage.total()).isEqualTo(2); + assertThat(firstPage.items()).singleElement() + .extracting(summary -> summary.operationId()) + .isEqualTo("operation-2"); + assertThat(secondPage.items()).singleElement().satisfies(summary -> { + assertThat(summary.operationId()).isEqualTo("operation-1"); + assertThat(summary.targetCoordinate()).isEqualTo("@team-ai/care-suite"); + assertThat(summary.baseVersion()).isEqualTo("1.0.0"); + assertThat(summary.totalMembers()).isEqualTo(2); + assertThat(summary.completedMembers()).isZero(); + assertThat(summary.waitingMembers()).isEqualTo(1); + }); + assertThat(repository.findActive("another-actor", 0, 1).items()).singleElement() + .extracting(summary -> summary.operationId()) + .isEqualTo("operation-4"); + var history = repository.findMine("actor", 0, 10); + assertThat(history.total()).isEqualTo(3); + assertThat(history.hasChangingOperations()).isTrue(); + assertThat(history.items()).extracting(summary -> summary.operationId()) + .containsExactly("operation-2", "operation-1", "operation-3"); + assertThat(repository.findMine("another-actor", 0, 10).items()).singleElement() + .extracting(summary -> summary.operationId()) + .isEqualTo("operation-4"); + assertThat(entityManager.getEntityManager().createNativeQuery(""" + SELECT indexdef FROM pg_indexes + WHERE indexname = 'idx_suite_bundle_operation_actor_status_updated' + """).getSingleResult().toString()) + .contains("(actor_id, status, updated_at DESC, operation_id DESC)"); + assertThat(entityManager.getEntityManager().createNativeQuery(""" + SELECT indexdef FROM pg_indexes + WHERE indexname = 'idx_suite_bundle_operation_actor_priority_updated' + """).getSingleResult().toString()) + .contains("actor_id", "CASE", "updated_at DESC", "operation_id DESC"); + } + + @Test + void prioritizesActionableAndChangingTasksAheadOfNewerTerminalHistory() { + entityManager.persist(new UserAccount("actor", "Actor", null, null)); + Namespace namespace = entityManager.persistFlushFind( + new Namespace("team-ai", "AI Team", "actor")); + List terminalTokens = List.of("0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "a", "b"); + for (int index = 0; index < terminalTokens.size(); index++) { + String token = terminalTokens.get(index); + SkillSuiteBundleExecutionOperation terminal = createOperation( + "operation-" + token, "actor", namespace, "terminal-" + token); + terminal.cancel(NOW.plusSeconds(100L + index)); + entityManager.persist(terminal); + } + SkillSuiteBundleExecutionOperation running = createOperation( + "operation-c", "actor", namespace, "running-suite"); + entityManager.persist(running); + SkillSuiteBundleExecutionOperation blocked = createOperation( + "operation-d", "actor", namespace, "blocked-suite"); + blocked.markBlockedRetryable("MEMBER_FAILED", "retry", NOW.plusSeconds(1)); + entityManager.persist(blocked); + entityManager.flush(); + + var page = repository.findMine("actor", 0, 12); + + assertThat(page.total()).isEqualTo(14); + assertThat(page.hasChangingOperations()).isTrue(); + assertThat(page.items()).hasSize(12); + assertThat(page.items()).extracting(summary -> summary.operationId()) + .startsWith("operation-d", "operation-c"); + assertThat(page.items()).extracting(summary -> summary.status()) + .startsWith(SkillSuiteBundleOperationStatus.BLOCKED_RETRYABLE, + SkillSuiteBundleOperationStatus.RUNNING); + } + + private SkillSuiteBundleMemberResult member(int position, String slug, boolean waiting) { + SkillSuiteBundleMemberResult member = new SkillSuiteBundleMemberResult( + "operation-1", position, new SkillSuiteBundleCoordinate("team-ai", slug), + SkillSuiteBundleMemberSourceType.PACKAGE, "members/" + slug, + SkillVisibility.PUBLIC, "1.0.0", SkillSuiteBundleRelationshipChange.ADDED, + SkillSuiteBundlePublishAction.CREATE_VERSION, "fingerprint-" + slug, + null, null, List.of(), List.of(), NOW); + if (waiting) member.markWaiting(NOW.plusSeconds(1)); + return member; + } + + private SkillSuiteBundleExecutionOperation createOperation( + String operationId, + String actorId, + Namespace namespace, + String suiteSlug + ) { + String suffix = operationId.substring(operationId.lastIndexOf('-') + 1); + SkillSuiteBundlePreviewSession preview = entityManager.persistFlushFind( + new SkillSuiteBundlePreviewSession( + "preview-" + suffix, actorId, SkillSuiteBundleMode.CREATE, namespace.getId(), + suiteSlug, null, null, "1.0.0", "staging/" + suffix + ".zip", + suffix.repeat(64), Map.of(), Map.of(), "warning-" + suffix, + NOW.plusSeconds(600), NOW)); + return new SkillSuiteBundleExecutionOperation( + operationId, preview.getToken(), "request-" + suffix, actorId, + SkillSuiteBundleMode.CREATE, namespace.getId(), suiteSlug, null, null, "1.0.0", + "staging/" + suffix + ".zip", suffix.repeat(64), Map.of(), + "warning-" + suffix, NOW); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/integration/SkillSuiteLabelPersistenceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/integration/SkillSuiteLabelPersistenceTest.java new file mode 100644 index 00000000..81d12852 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/integration/SkillSuiteLabelPersistenceTest.java @@ -0,0 +1,117 @@ +package com.iflytek.skillhub.integration; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.iflytek.skillhub.domain.label.LabelDefinition; +import com.iflytek.skillhub.domain.label.LabelType; +import com.iflytek.skillhub.domain.label.SkillLabel; +import com.iflytek.skillhub.domain.label.SkillLabelRepository; +import com.iflytek.skillhub.domain.label.SkillSuiteLabel; +import com.iflytek.skillhub.domain.label.SkillSuiteLabelRepository; +import com.iflytek.skillhub.domain.namespace.Namespace; +import com.iflytek.skillhub.domain.skill.Skill; +import com.iflytek.skillhub.domain.skill.SkillTag; +import com.iflytek.skillhub.domain.skill.SkillTagRepository; +import com.iflytek.skillhub.domain.skill.SkillVersion; +import com.iflytek.skillhub.domain.skill.SkillVisibility; +import com.iflytek.skillhub.domain.suite.SkillSuite; +import com.iflytek.skillhub.domain.user.UserAccount; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +@DataJpaTest +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +@ActiveProfiles("test") +@Testcontainers +class SkillSuiteLabelPersistenceTest { + + @Container + private static final PostgreSQLContainer POSTGRES = + new PostgreSQLContainer<>("postgres:16-alpine"); + + @DynamicPropertySource + static void configurePostgres(DynamicPropertyRegistry registry) { + registry.add("spring.datasource.url", POSTGRES::getJdbcUrl); + registry.add("spring.datasource.username", POSTGRES::getUsername); + registry.add("spring.datasource.password", POSTGRES::getPassword); + registry.add("spring.datasource.driver-class-name", () -> "org.postgresql.Driver"); + registry.add("spring.jpa.database-platform", () -> "org.hibernate.dialect.PostgreSQLDialect"); + registry.add("spring.flyway.enabled", () -> true); + registry.add("spring.jpa.hibernate.ddl-auto", () -> "validate"); + } + + @Autowired private TestEntityManager entityManager; + @Autowired private SkillSuiteLabelRepository suiteLabelRepository; + @Autowired private SkillLabelRepository skillLabelRepository; + @Autowired private SkillTagRepository skillTagRepository; + + @Test + void deletingSuiteRemovesOnlySuiteLabelsAndKeepsMemberSkillMetadata() { + Fixture fixture = persistFixture("suite-delete"); + + entityManager.remove(entityManager.find(SkillSuite.class, fixture.suite().getId())); + entityManager.flush(); + entityManager.clear(); + + assertThat(suiteLabelRepository.findBySuiteId(fixture.suite().getId())).isEmpty(); + assertThat(skillLabelRepository.findBySkillId(fixture.skill().getId())) + .extracting(SkillLabel::getLabelId) + .containsExactly(fixture.label().getId()); + assertThat(skillTagRepository.findBySkillId(fixture.skill().getId())) + .extracting(SkillTag::getTagName) + .containsExactly("member-tag"); + assertThat(entityManager.find(Skill.class, fixture.skill().getId())).isNotNull(); + assertThat(entityManager.find(LabelDefinition.class, fixture.label().getId())).isNotNull(); + } + + @Test + void deletingSuiteLabelAssociationDoesNotPropagateToMemberSkill() { + Fixture fixture = persistFixture("association-delete"); + SkillSuiteLabel association = suiteLabelRepository + .findBySuiteIdAndLabelId(fixture.suite().getId(), fixture.label().getId()) + .orElseThrow(); + + suiteLabelRepository.delete(association); + entityManager.flush(); + entityManager.clear(); + + assertThat(suiteLabelRepository.findBySuiteId(fixture.suite().getId())).isEmpty(); + assertThat(skillLabelRepository.findBySkillId(fixture.skill().getId())).hasSize(1); + assertThat(skillTagRepository.findBySkillId(fixture.skill().getId())).hasSize(1); + } + + private Fixture persistFixture(String suffix) { + String userId = "suite-label-" + suffix; + entityManager.persist(new UserAccount(userId, "Suite Label Owner", null, null)); + Namespace namespace = entityManager.persistFlushFind( + new Namespace("suite-label-" + suffix, "Suite Label Namespace", userId)); + Skill skill = entityManager.persistFlushFind( + new Skill(namespace.getId(), "member", userId, SkillVisibility.PUBLIC)); + SkillVersion version = entityManager.persistFlushFind( + new SkillVersion(skill.getId(), "1.0.0", userId)); + SkillSuite suite = entityManager.persistFlushFind( + new SkillSuite(namespace.getId(), "suite", "Suite", userId)); + LabelDefinition label = entityManager.persistFlushFind( + new LabelDefinition("label-" + suffix, LabelType.RECOMMENDED, true, 0, userId)); + + entityManager.persist(new SkillLabel(skill.getId(), label.getId(), userId)); + entityManager.persist(new SkillTag(skill.getId(), "member-tag", version.getId(), userId)); + entityManager.persist(new SkillSuiteLabel(suite.getId(), label.getId(), userId)); + entityManager.flush(); + entityManager.clear(); + + return new Fixture(skill, suite, label); + } + + private record Fixture(Skill skill, SkillSuite suite, LabelDefinition label) { + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/integration/SuiteDiscoveryIntegrationTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/integration/SuiteDiscoveryIntegrationTest.java index b203bcb6..a2a769b8 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/integration/SuiteDiscoveryIntegrationTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/integration/SuiteDiscoveryIntegrationTest.java @@ -3,6 +3,10 @@ package com.iflytek.skillhub.integration; import static org.assertj.core.api.Assertions.assertThat; import com.iflytek.skillhub.domain.namespace.Namespace; +import com.iflytek.skillhub.domain.label.LabelDefinition; +import com.iflytek.skillhub.domain.label.LabelType; +import com.iflytek.skillhub.domain.label.SkillLabel; +import com.iflytek.skillhub.domain.label.SkillSuiteLabel; import com.iflytek.skillhub.domain.namespace.NamespaceRole; import com.iflytek.skillhub.domain.skill.Skill; import com.iflytek.skillhub.domain.skill.SkillVersion; @@ -14,9 +18,11 @@ import com.iflytek.skillhub.domain.suite.SkillSuiteVersion; import com.iflytek.skillhub.domain.suite.SkillSuiteVersionMember; import com.iflytek.skillhub.domain.suite.SkillSuiteVersionMemberRepository; import com.iflytek.skillhub.domain.suite.SkillSuiteVersionStatus; +import com.iflytek.skillhub.domain.suite.SkillSuiteStatus; import com.iflytek.skillhub.domain.user.UserAccount; import com.iflytek.skillhub.search.postgres.PostgresResourceDiscoveryQueryService; import com.iflytek.skillhub.service.ResourceDiscoveryAppService; +import com.iflytek.skillhub.repository.SkillSuiteLabelQueryRepository; import com.iflytek.skillhub.repository.MySkillSuiteQueryRepository; import com.iflytek.skillhub.repository.SkillSuiteReferenceQueryRepository; import java.time.Instant; @@ -29,6 +35,7 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager; +import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.context.annotation.Import; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.context.DynamicPropertyRegistry; @@ -46,7 +53,9 @@ import org.testcontainers.junit.jupiter.Testcontainers; @Testcontainers @TestPropertySource(properties = { "spring.flyway.enabled=true", - "spring.jpa.hibernate.ddl-auto=validate" + "spring.jpa.hibernate.ddl-auto=validate", + "spring.jpa.show-sql=false", + "logging.level.org.hibernate.SQL=OFF" }) class SuiteDiscoveryIntegrationTest { @@ -69,6 +78,9 @@ class SuiteDiscoveryIntegrationTest { @Autowired private ResourceDiscoveryAppService appService; + @MockBean + private SkillSuiteLabelQueryRepository suiteLabelProjectionService; + @Autowired private MySkillSuiteQueryRepository mySuiteRepository; @@ -80,6 +92,8 @@ class SuiteDiscoveryIntegrationTest { @BeforeEach void seedReferencedUsers() { + org.mockito.Mockito.when(suiteLabelProjectionService.labelsBySuiteIds(org.mockito.ArgumentMatchers.any())) + .thenReturn(Map.of()); entityManager.persist(new UserAccount("owner", "Owner", null, null)); entityManager.persist(new UserAccount("author", "Author", null, null)); entityManager.persist(new UserAccount("other-author", "Other Author", null, null)); @@ -121,6 +135,13 @@ class SuiteDiscoveryIntegrationTest { true)); suite.setLatestVersionId(suiteVersion.getId()); entityManager.persistAndFlush(suite); + LabelDefinition suiteLabel = entityManager.persistFlushFind( + new LabelDefinition("suite-label", LabelType.RECOMMENDED, true, 0, "owner")); + LabelDefinition memberLabel = entityManager.persistFlushFind( + new LabelDefinition("member-only", LabelType.RECOMMENDED, true, 1, "owner")); + entityManager.persist(new SkillSuiteLabel(suite.getId(), suiteLabel.getId(), "owner")); + entityManager.persist(new SkillLabel(skill.getId(), memberLabel.getId(), "owner")); + entityManager.flush(); entityManager.clear(); var result = appService.search("starter", "team-ai", "", "relevance", 0, 20, Set.of()); @@ -141,6 +162,13 @@ class SuiteDiscoveryIntegrationTest { assertThat(item.displayName()).isEqualTo("Published snapshot name"); assertThat(item.summary()).isEqualTo("Published snapshot summary"); }); + assertThat(appService.search( + null, null, "SUITE", "newest", 0, 20, Set.of(), List.of("suite-label")).items()) + .extracting(item -> item.slug()) + .containsExactly("starter"); + assertThat(appService.search( + null, null, "SUITE", "newest", 0, 20, Set.of(), List.of("member-only")).items()) + .isEmpty(); assertThat(suiteReferenceRepository.findVisibleEntryReferences( skill.getId(), null, Map.of(), Set.of())) .singleElement() @@ -188,6 +216,157 @@ class SuiteDiscoveryIntegrationTest { skill.getId(), "author", Map.of(namespace.getId(), NamespaceRole.MEMBER), Set.of())) .singleElement() .satisfies(reference -> assertThat(reference.slug()).isEqualTo("private-suite")); + + suite = entityManager.find(SkillSuite.class, suite.getId()); + suite.setHidden(true); + entityManager.persistAndFlush(suite); + entityManager.clear(); + assertThat(suiteReferenceRepository.findVisibleMemberships( + skill.getId(), "author", Map.of(namespace.getId(), NamespaceRole.MEMBER), + Set.of(), 0, 20).items()).isEmpty(); + + suite = entityManager.find(SkillSuite.class, suite.getId()); + suite.setHidden(false); + suite.setStatus(SkillSuiteStatus.ARCHIVED); + entityManager.persistAndFlush(suite); + entityManager.clear(); + assertThat(suiteReferenceRepository.findVisibleMemberships( + skill.getId(), "author", Map.of(namespace.getId(), NamespaceRole.MEMBER), + Set.of(), 0, 20).items()).isEmpty(); + } + + @Test + void findsOrdinaryMembershipAndProtectsPrivateSiblingMetadata() { + Namespace namespace = entityManager.persistFlushFind( + new Namespace("member-team", "Member Team", "owner")); + PublishedSkill current = publishedSkill( + namespace, "ordinary", "owner", SkillVisibility.PUBLIC, "Ordinary"); + PublishedSkill entry = publishedSkill( + namespace, "entry", "owner", SkillVisibility.PUBLIC, "Entry"); + PublishedSkill restricted = publishedSkill( + namespace, "private-helper", "other-author", SkillVisibility.PRIVATE, + "Private Helper"); + + SkillSuite suite = entityManager.persistFlushFind( + new SkillSuite(namespace.getId(), "member-pack", "Member Pack", "author")); + SkillSuiteVersion suiteVersion = new SkillSuiteVersion( + suite.getId(), "2.0.0", "Member Pack", "Current members", + SkillVisibility.PUBLIC, "author"); + suiteVersion.setStatus(SkillSuiteVersionStatus.PUBLISHED); + suiteVersion = entityManager.persistFlushFind(suiteVersion); + persistMember(suiteVersion, entry, 0, true); + persistMember(suiteVersion, current, 1, false); + persistMember(suiteVersion, restricted, 2, false); + for (int index = 0; index < 9; index++) { + persistMember(suiteVersion, publishedSkill( + namespace, "helper-" + index, "owner", SkillVisibility.PUBLIC, + "Helper " + index), index + 3, false); + } + suite.setLatestVersionId(suiteVersion.getId()); + entityManager.persistAndFlush(suite); + entityManager.clear(); + + var page = suiteReferenceRepository.findVisibleMemberships( + current.skill().getId(), null, Map.of(), Set.of(), 0, 20); + + assertThat(page.total()).isEqualTo(1); + assertThat(page.items()).singleElement().satisfies(reference -> { + assertThat(reference.slug()).isEqualTo("member-pack"); + assertThat(reference.currentSkillEntry()).isFalse(); + assertThat(reference.memberCount()).isEqualTo(12); + assertThat(reference.visibleSiblingMembers()).hasSize(8); + assertThat(reference.visibleSiblingMembers().getFirst()).satisfies(member -> { + assertThat(member.slug()).isEqualTo("entry"); + assertThat(member.entry()).isTrue(); + assertThat(member.available()).isTrue(); + }); + assertThat(reference.restrictedMemberCount()).isEqualTo(1); + assertThat(reference.omittedVisibleMemberCount()).isEqualTo(2); + }); + + entityManager.getEntityManager().createNativeQuery( + "UPDATE skill SET latest_version_id = NULL WHERE id = :id") + .setParameter("id", restricted.skill().getId()) + .executeUpdate(); + entityManager.getEntityManager().createNativeQuery("DELETE FROM skill_version WHERE id = :id") + .setParameter("id", restricted.version().getId()) + .executeUpdate(); + entityManager.getEntityManager().createNativeQuery("DELETE FROM skill WHERE id = :id") + .setParameter("id", restricted.skill().getId()) + .executeUpdate(); + entityManager.flush(); + entityManager.clear(); + + assertThat(suiteReferenceRepository.findVisibleMemberships( + current.skill().getId(), null, Map.of(), Set.of(), 0, 20).items()) + .singleElement() + .satisfies(reference -> { + assertThat(reference.restrictedMemberCount()).isEqualTo(1); + assertThat(reference.visibleSiblingMembers()) + .noneMatch(member -> member.slug().equals("private-helper")); + }); + } + + @Test + void ignoresHistoricalMembershipOutsideLatestSuiteSnapshot() { + Namespace namespace = entityManager.persistFlushFind( + new Namespace("history-team", "History Team", "owner")); + PublishedSkill removed = publishedSkill( + namespace, "removed", "owner", SkillVisibility.PUBLIC, "Removed"); + PublishedSkill replacement = publishedSkill( + namespace, "replacement", "owner", SkillVisibility.PUBLIC, "Replacement"); + SkillSuite suite = entityManager.persistFlushFind( + new SkillSuite(namespace.getId(), "evolving-pack", "Evolving Pack", "owner")); + + SkillSuiteVersion oldVersion = new SkillSuiteVersion( + suite.getId(), "1.0.0", SkillVisibility.PUBLIC, "owner"); + oldVersion.setStatus(SkillSuiteVersionStatus.PUBLISHED); + oldVersion = entityManager.persistFlushFind(oldVersion); + persistMember(oldVersion, removed, 0, true); + + SkillSuiteVersion latestVersion = new SkillSuiteVersion( + suite.getId(), "2.0.0", SkillVisibility.PUBLIC, "owner"); + latestVersion.setStatus(SkillSuiteVersionStatus.PUBLISHED); + latestVersion = entityManager.persistFlushFind(latestVersion); + persistMember(latestVersion, replacement, 0, true); + suite.setLatestVersionId(latestVersion.getId()); + entityManager.persistAndFlush(suite); + entityManager.clear(); + + assertThat(suiteReferenceRepository.findVisibleMemberships( + removed.skill().getId(), null, Map.of(), Set.of(), 0, 20).items()).isEmpty(); + } + + @Test + void boundsMembershipPagesAndExposesTotalForContinuation() { + Namespace namespace = entityManager.persistFlushFind( + new Namespace("many-suite-team", "Many Suite Team", "owner")); + PublishedSkill current = publishedSkill( + namespace, "popular-member", "owner", SkillVisibility.PUBLIC, "Popular Member"); + for (int index = 0; index < 21; index++) { + SkillSuite suite = new SkillSuite( + namespace.getId(), "pack-" + index, "Pack " + index, "owner"); + entityManager.persist(suite); + SkillSuiteVersion version = new SkillSuiteVersion( + suite.getId(), "1.0.0", SkillVisibility.PUBLIC, "owner"); + version.setStatus(SkillSuiteVersionStatus.PUBLISHED); + entityManager.persist(version); + persistMember(version, current, 0, true); + suite.setLatestVersionId(version.getId()); + } + entityManager.flush(); + entityManager.clear(); + + var first = suiteReferenceRepository.findVisibleMemberships( + current.skill().getId(), null, Map.of(), Set.of(), 0, 100); + var second = suiteReferenceRepository.findVisibleMemberships( + current.skill().getId(), null, Map.of(), Set.of(), 1, 100); + + assertThat(first.size()).isEqualTo(20); + assertThat(first.total()).isEqualTo(21); + assertThat(first.items()).hasSize(20); + assertThat(second.total()).isEqualTo(21); + assertThat(second.items()).hasSize(1); } @Test @@ -212,6 +391,32 @@ class SuiteDiscoveryIntegrationTest { .satisfies(item -> assertThat(item.slug()).isEqualTo("internal")); } + @Test + void exposesNamespaceOnlySuiteMembershipOnlyToNamespaceMembers() { + Namespace namespace = entityManager.persistFlushFind( + new Namespace("membership-team", "Membership Team", "owner")); + PublishedSkill current = publishedSkill( + namespace, "shared-member", "owner", SkillVisibility.PUBLIC, "Shared Member"); + SkillSuite suite = entityManager.persistFlushFind(new SkillSuite( + namespace.getId(), "internal-pack", "Internal Pack", "owner")); + SkillSuiteVersion version = new SkillSuiteVersion( + suite.getId(), "1.0.0", SkillVisibility.NAMESPACE_ONLY, "owner"); + version.setStatus(SkillSuiteVersionStatus.PUBLISHED); + version = entityManager.persistFlushFind(version); + persistMember(version, current, 0, true); + suite.setLatestVersionId(version.getId()); + entityManager.persistAndFlush(suite); + entityManager.clear(); + + assertThat(suiteReferenceRepository.findVisibleMemberships( + current.skill().getId(), null, Map.of(), Set.of(), 0, 20).items()).isEmpty(); + assertThat(suiteReferenceRepository.findVisibleMemberships( + current.skill().getId(), "author", + Map.of(namespace.getId(), NamespaceRole.MEMBER), Set.of(), 0, 20).items()) + .singleElement() + .satisfies(reference -> assertThat(reference.slug()).isEqualTo("internal-pack")); + } + @Test void dashboardReturnsTheLatestVersionTheCallerCanManage() { Namespace namespace = entityManager.persistFlushFind( @@ -335,4 +540,42 @@ class SuiteDiscoveryIntegrationTest { assertThat(member.getSkillVersionSnapshot()).isEqualTo("2.0.0"); }); } + + private PublishedSkill publishedSkill( + Namespace namespace, + String slug, + String ownerId, + SkillVisibility visibility, + String displayName + ) { + Skill skill = new Skill(namespace.getId(), slug, ownerId, visibility); + skill.setDisplayName(displayName); + entityManager.persist(skill); + SkillVersion version = new SkillVersion(skill.getId(), "1.0.0", ownerId); + version.setStatus(SkillVersionStatus.PUBLISHED); + version.setDownloadReady(true); + entityManager.persist(version); + skill.setLatestVersionId(version.getId()); + return new PublishedSkill(skill, version, namespace.getSlug()); + } + + private void persistMember( + SkillSuiteVersion suiteVersion, + PublishedSkill publishedSkill, + int position, + boolean entry + ) { + entityManager.persist(new SkillSuiteVersionMember( + suiteVersion.getId(), + new SkillSuiteMemberSelection( + publishedSkill.skill().getId(), publishedSkill.version().getId(), + publishedSkill.namespaceSlug(), + publishedSkill.skill().getSlug(), publishedSkill.version().getVersion(), + "sha256:" + Integer.toHexString(position).repeat(64).substring(0, 64)), + position, + entry)); + } + + private record PublishedSkill(Skill skill, SkillVersion version, String namespaceSlug) { + } } diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/listener/SkillSuiteBundleEventListenerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/listener/SkillSuiteBundleEventListenerTest.java new file mode 100644 index 00000000..fb2162ec --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/listener/SkillSuiteBundleEventListenerTest.java @@ -0,0 +1,39 @@ +package com.iflytek.skillhub.listener; + +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.SkillSuiteBundleMemberResult; +import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleMemberResultRepository; +import com.iflytek.skillhub.service.bundle.SkillSuiteBundleCoordinator; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class SkillSuiteBundleEventListenerTest { + + @Test + void directAndLifecycleEventsWakeEachBoundOperationOnce() { + SkillSuiteBundleMemberResultRepository repository = mock(SkillSuiteBundleMemberResultRepository.class); + SkillSuiteBundleCoordinator coordinator = mock(SkillSuiteBundleCoordinator.class); + SkillSuiteBundleEventListener listener = new SkillSuiteBundleEventListener(repository, coordinator); + SkillSuiteBundleMemberResult first = mock(SkillSuiteBundleMemberResult.class); + SkillSuiteBundleMemberResult duplicate = mock(SkillSuiteBundleMemberResult.class); + when(first.getOperationId()).thenReturn("operation-a"); + when(duplicate.getOperationId()).thenReturn("operation-a"); + when(repository.findBySkillVersionId(9L)).thenReturn(List.of(first, duplicate)); + when(repository.findBySkillVersionId(10L)).thenReturn(List.of(first)); + + listener.onAdvanceRequested(new SkillSuiteBundleAdvanceRequestedEvent("operation-direct")); + listener.onSkillPublished(new SkillPublishedEvent(1L, 9L, "actor")); + listener.onSkillVersionYanked(new SkillVersionYankedEvent(1L, 10L, "actor", true)); + + verify(coordinator).advance("operation-direct"); + verify(coordinator, org.mockito.Mockito.times(2)).advance("operation-a"); + verify(repository).findBySkillVersionId(10L); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/repository/JpaReviewProgressQueryRepositoryTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/repository/JpaReviewProgressQueryRepositoryTest.java index e590f8bd..7b7f8b47 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/repository/JpaReviewProgressQueryRepositoryTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/repository/JpaReviewProgressQueryRepositoryTest.java @@ -4,6 +4,7 @@ import static org.assertj.core.api.Assertions.assertThat; import com.iflytek.skillhub.domain.namespace.Namespace; import com.iflytek.skillhub.domain.review.ReviewTask; +import com.iflytek.skillhub.domain.review.ReviewSubjectType; import com.iflytek.skillhub.domain.review.ReviewTaskStatus; import com.iflytek.skillhub.domain.suite.SkillSuite; import com.iflytek.skillhub.domain.suite.SkillSuiteVersion; @@ -103,7 +104,7 @@ class JpaReviewProgressQueryRepositoryTest { entityManager.flush(); entityManager.clear(); - var firstPage = repository.findMyProgress("author-1", null, "", 0, 1); + var firstPage = repository.findMyProgress("author-1", null, null, "", 0, 1); assertThat(firstPage.items()).hasSize(1); assertThat(firstPage.total()).isEqualTo(3); @@ -116,23 +117,23 @@ class JpaReviewProgressQueryRepositoryTest { assertThat(firstPage.statusCounts().approved()).isEqualTo(1); assertThat(firstPage.statusCounts().rejected()).isEqualTo(1); - var emptyPage = repository.findMyProgress("author-1", null, "", 8, 1); + var emptyPage = repository.findMyProgress("author-1", null, null, "", 8, 1); assertThat(emptyPage.items()).isEmpty(); assertThat(emptyPage.total()).isEqualTo(3); var maximumPage = repository.findMyProgress( - "author-1", null, "", Integer.MAX_VALUE, 100); + "author-1", null, null, "", Integer.MAX_VALUE, 100); assertThat(maximumPage.items()).isEmpty(); assertThat(maximumPage.total()).isEqualTo(3); var searchedAndFiltered = repository.findMyProgress( - "author-1", ReviewTaskStatus.APPROVED, "BETA", 0, 20); + "author-1", null, ReviewTaskStatus.APPROVED, "BETA", 0, 20); assertThat(searchedAndFiltered.items()).singleElement() .satisfies(item -> assertThat(item.skillSlug()).isEqualTo("beta-skill")); assertThat(searchedAndFiltered.total()).isEqualTo(1); assertThat(searchedAndFiltered.statusCounts().approved()).isEqualTo(1); - var searchMiss = repository.findMyProgress("author-1", null, "missing", 0, 20); + var searchMiss = repository.findMyProgress("author-1", null, null, "missing", 0, 20); assertThat(searchMiss.items()).isEmpty(); assertThat(searchMiss.total()).isZero(); assertThat(searchMiss.statusCounts().pending()).isZero(); @@ -145,6 +146,8 @@ class JpaReviewProgressQueryRepositoryTest { persistUsers("owner", "author-1"); Namespace namespace = entityManager.persistFlushFind( new Namespace("team-suite-review", "Suite Review Team", "owner")); + Skill skill = entityManager.persistFlushFind( + new Skill(namespace.getId(), "starter-skill", "author-1", SkillVisibility.PUBLIC)); SkillSuite suite = entityManager.persistFlushFind( new SkillSuite(namespace.getId(), "starter-pack", "Starter Pack", "author-1")); SkillSuiteVersion suiteVersion = entityManager.persistFlushFind( @@ -152,12 +155,20 @@ class JpaReviewProgressQueryRepositoryTest { ReviewTask task = ReviewTask.forSuiteVersion( suiteVersion.getId(), suite.getId(), namespace.getId(), suiteVersion.getVersion(), "author-1"); entityManager.persist(task); + persistAttempt( + skill, + namespace, + "author-1", + "1.0.0", + ReviewTaskStatus.APPROVED, + Instant.parse("2026-08-30T10:00:00Z")); entityManager.flush(); entityManager.clear(); - var progress = repository.findMyProgress("author-1", null, "STARTER", 0, 20); + var progress = repository.findMyProgress("author-1", null, null, "STARTER", 0, 20); - assertThat(progress.items()).singleElement().satisfies(item -> { + assertThat(progress.items()).hasSize(2); + assertThat(progress.items()).anySatisfy(item -> { assertThat(item.skillId()).isNull(); assertThat(item.skillSlug()).isNull(); assertThat(item.subjectType()).isEqualTo("SUITE_VERSION"); @@ -166,6 +177,23 @@ class JpaReviewProgressQueryRepositoryTest { assertThat(item.subjectSlug()).isEqualTo("starter-pack"); }); assertThat(progress.statusCounts().pending()).isEqualTo(1); + assertThat(progress.statusCounts().approved()).isEqualTo(1); + + var suitesOnly = repository.findMyProgress( + "author-1", ReviewSubjectType.SUITE_VERSION, null, "STARTER", 0, 20); + assertThat(suitesOnly.items()).singleElement() + .satisfies(item -> assertThat(item.subjectType()).isEqualTo("SUITE_VERSION")); + assertThat(suitesOnly.total()).isEqualTo(1); + assertThat(suitesOnly.statusCounts().pending()).isEqualTo(1); + assertThat(suitesOnly.statusCounts().approved()).isZero(); + + var skillsOnly = repository.findMyProgress( + "author-1", ReviewSubjectType.SKILL_VERSION, null, "STARTER", 0, 20); + assertThat(skillsOnly.items()).singleElement() + .satisfies(item -> assertThat(item.subjectType()).isEqualTo("SKILL_VERSION")); + assertThat(skillsOnly.total()).isEqualTo(1); + assertThat(skillsOnly.statusCounts().pending()).isZero(); + assertThat(skillsOnly.statusCounts().approved()).isEqualTo(1); } @Test diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/repository/SkillSuiteBundlePersistenceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/repository/SkillSuiteBundlePersistenceTest.java new file mode 100644 index 00000000..45c1a467 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/repository/SkillSuiteBundlePersistenceTest.java @@ -0,0 +1,544 @@ +package com.iflytek.skillhub.repository; + +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.Namespace; +import com.iflytek.skillhub.domain.shared.exception.DomainConflictException; +import com.iflytek.skillhub.domain.skill.SkillVisibility; +import com.iflytek.skillhub.domain.suite.SkillSuite; +import com.iflytek.skillhub.domain.suite.SkillSuiteVersion; +import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleCoordinate; +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.SkillSuiteBundleMemberSourceType; +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 com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundlePreviewStatus; +import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundlePublishAction; +import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleRelationshipChange; +import com.iflytek.skillhub.domain.user.UserAccount; +import com.iflytek.skillhub.service.bundle.SkillSuiteBundleConfirmationAppService; +import com.iflytek.skillhub.service.bundle.SkillSuiteBundlePreviewPlanner; +import com.iflytek.skillhub.service.bundle.SkillSuiteBundlePreviewRevalidationService; +import com.iflytek.skillhub.storage.ObjectStorageService; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionTemplate; +import org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +@DataJpaTest +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +@ActiveProfiles("test") +@Testcontainers +class SkillSuiteBundlePersistenceTest { + + private static final TypeReference> JSON_OBJECT = new TypeReference<>() { }; + + @Container + private static final PostgreSQLContainer POSTGRES = + new PostgreSQLContainer<>("postgres:16-alpine"); + + @DynamicPropertySource + static void configurePostgres(DynamicPropertyRegistry registry) { + registry.add("spring.datasource.url", POSTGRES::getJdbcUrl); + registry.add("spring.datasource.username", POSTGRES::getUsername); + registry.add("spring.datasource.password", POSTGRES::getPassword); + registry.add("spring.datasource.driver-class-name", () -> "org.postgresql.Driver"); + registry.add("spring.jpa.database-platform", () -> "org.hibernate.dialect.PostgreSQLDialect"); + registry.add("spring.flyway.enabled", () -> true); + registry.add("spring.jpa.hibernate.ddl-auto", () -> "validate"); + } + + @Autowired private jakarta.persistence.EntityManager entityManager; + @Autowired private SkillSuiteBundlePreviewSessionRepository previewRepository; + @Autowired private SkillSuiteBundleExecutionOperationRepository operationRepository; + @Autowired private SkillSuiteBundleMemberResultRepository memberRepository; + @Autowired private PlatformTransactionManager transactionManager; + + @Test + void previewsForTheSameCreateTargetCanCoexistWithoutReservingIt() { + Namespace namespace = persistNamespace("bundle-preview"); + previewRepository.save(preview("preview-a", "actor-a", namespace.getId(), "target", null, null)); + previewRepository.save(preview("preview-b", "actor-b", namespace.getId(), "target", null, null)); + entityManager.flush(); + entityManager.clear(); + + assertThat(previewRepository.findById("preview-a")).isPresent(); + assertThat(previewRepository.findById("preview-b")).isPresent(); + } + + @Test + @Transactional(propagation = Propagation.NOT_SUPPORTED) + void activeCreateReservationIsUniqueAndAReleasedReservationCanBeReacquired() { + TransactionTemplate transactions = new TransactionTemplate(transactionManager); + Long namespaceId = transactions.execute(status -> persistNamespace("bundle-create-lock").getId()); + transactions.executeWithoutResult(status -> { + previewRepository.save(preview("create-preview-a", "create-actor-a", namespaceId, "target", null, null)); + operationRepository.save(operation( + "create-op-a", "create-preview-a", "create-request-a", "create-actor-a", + SkillSuiteBundleMode.CREATE, namespaceId, "target", null, null)); + }); + + assertThatThrownBy(() -> transactions.executeWithoutResult(status -> { + previewRepository.save(preview("create-preview-b", "create-actor-b", namespaceId, "target", null, null)); + operationRepository.save(operation( + "create-op-b", "create-preview-b", "create-request-b", "create-actor-b", + SkillSuiteBundleMode.CREATE, namespaceId, "target", null, null)); + })).isInstanceOf(DataIntegrityViolationException.class); + + transactions.executeWithoutResult(status -> { + SkillSuiteBundleExecutionOperation first = operationRepository.findById("create-op-a").orElseThrow(); + first.transition(SkillSuiteBundleOperationStatus.CANCELLED, now().plusSeconds(5)); + operationRepository.save(first); + }); + transactions.executeWithoutResult(status -> { + previewRepository.save(preview("create-preview-c", "create-actor-c", namespaceId, "target", null, null)); + operationRepository.save(operation( + "create-op-c", "create-preview-c", "create-request-c", "create-actor-c", + SkillSuiteBundleMode.CREATE, namespaceId, "target", null, null)); + }); + + assertThat(operationRepository.findById("create-op-c")).isPresent(); + } + + @Test + @Transactional(propagation = Propagation.NOT_SUPPORTED) + void concurrentCreateConfirmationsAcquireExactlyOneReservation() throws Exception { + TransactionTemplate transactions = new TransactionTemplate(transactionManager); + Long namespaceId = transactions.execute(status -> persistNamespace("bundle-concurrent-lock").getId()); + CountDownLatch ready = new CountDownLatch(2); + CountDownLatch start = new CountDownLatch(1); + + try (var executor = Executors.newFixedThreadPool(2)) { + var first = executor.submit(() -> attemptReservation( + transactions, ready, start, namespaceId, null, null, "concurrent-a")); + var second = executor.submit(() -> attemptReservation( + transactions, ready, start, namespaceId, null, null, "concurrent-b")); + assertThat(ready.await(10, TimeUnit.SECONDS)).isTrue(); + start.countDown(); + + assertThat(List.of(first.get(10, TimeUnit.SECONDS), second.get(10, TimeUnit.SECONDS))) + .containsExactlyInAnyOrder(true, false); + } finally { + start.countDown(); + } + } + + @Test + @Transactional(propagation = Propagation.NOT_SUPPORTED) + void concurrentUpdateConfirmationsAcquireExactlyOneReservation() throws Exception { + TransactionTemplate transactions = new TransactionTemplate(transactionManager); + SuiteFixture fixture = transactions.execute(status -> persistSuite("bundle-concurrent-update-lock")); + CountDownLatch ready = new CountDownLatch(2); + CountDownLatch start = new CountDownLatch(1); + + try (var executor = Executors.newFixedThreadPool(2)) { + var first = executor.submit(() -> attemptReservation( + transactions, ready, start, fixture.namespaceId(), fixture.suiteId(), + fixture.baseVersionId(), "concurrent-update-a")); + var second = executor.submit(() -> attemptReservation( + transactions, ready, start, fixture.namespaceId(), fixture.suiteId(), + fixture.baseVersionId(), "concurrent-update-b")); + assertThat(ready.await(10, TimeUnit.SECONDS)).isTrue(); + start.countDown(); + + assertThat(List.of(first.get(10, TimeUnit.SECONDS), second.get(10, TimeUnit.SECONDS))) + .containsExactlyInAnyOrder(true, false); + } finally { + start.countDown(); + } + } + + @Test + @Transactional(propagation = Propagation.NOT_SUPPORTED) + void confirmationServiceAtomicallyReservesTargetAndReplaysAfterResponseLoss() throws Exception { + TransactionTemplate transactions = new TransactionTemplate(transactionManager); + Long namespaceId = transactions.execute(status -> persistNamespace("bundle-confirm-service").getId()); + ObjectMapper objectMapper = new ObjectMapper(); + SkillSuiteBundlePreviewPlanner.PreviewPlan plan = confirmationPlan(namespaceId); + var manifest = new com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleManifestParser().parse(""" + apiVersion: skillhub.iflytek.com/v1alpha1 + kind: SkillSuiteBundle + metadata: + namespace: bundle-confirm-service + slug: target + spec: + mode: CREATE + version: 1.1.0 + displayName: Target + summary: Summary + overview: Overview + visibility: PUBLIC + entry: "@bundle-confirm-service/member" + members: + - skill: "@bundle-confirm-service/member" + package: + path: skills/member + visibility: PUBLIC + """); + transactions.executeWithoutResult(status -> { + previewRepository.save(confirmablePreview( + "confirm-service-a", "actor-a", namespaceId, manifest, plan, objectMapper)); + previewRepository.save(confirmablePreview( + "confirm-service-b", "actor-b", namespaceId, manifest, plan, objectMapper)); + }); + + SkillSuiteBundlePreviewPlanner planner = mock(SkillSuiteBundlePreviewPlanner.class); + when(planner.plan(any(), any(), any(), any())).thenReturn(plan); + ObjectStorageService storage = mock(ObjectStorageService.class); + when(storage.exists(any())).thenReturn(true); + SkillSuiteBundleProperties properties = new SkillSuiteBundleProperties(); + properties.setConfirmationEnabled(true); + SkillSuiteBundlePreviewRevalidationService revalidation = + new SkillSuiteBundlePreviewRevalidationService(planner, storage, objectMapper); + SkillSuiteBundleConfirmationAppService confirmation = new SkillSuiteBundleConfirmationAppService( + previewRepository, operationRepository, memberRepository, revalidation, properties, + mock(org.springframework.context.ApplicationEventPublisher.class), + java.time.Clock.fixed(now(), java.time.ZoneOffset.UTC)); + + CountDownLatch ready = new CountDownLatch(2); + CountDownLatch start = new CountDownLatch(1); + try (var executor = Executors.newFixedThreadPool(2)) { + var first = executor.submit(() -> attemptConfirmation( + transactions, confirmation, ready, start, + "confirm-service-a", "request-a", "actor-a")); + var second = executor.submit(() -> attemptConfirmation( + transactions, confirmation, ready, start, + "confirm-service-b", "request-b", "actor-b")); + assertThat(ready.await(10, TimeUnit.SECONDS)).isTrue(); + start.countDown(); + assertThat(List.of(first.get(10, TimeUnit.SECONDS), second.get(10, TimeUnit.SECONDS))) + .containsExactlyInAnyOrder(true, false); + } finally { + start.countDown(); + } + + SkillSuiteBundleExecutionOperation winner = transactions.execute(status -> operationRepository + .findByPreviewToken("confirm-service-a") + .or(() -> operationRepository.findByPreviewToken("confirm-service-b")) + .orElseThrow()); + SkillSuiteBundleConfirmationAppService.ConfirmationOutcome replay = transactions.execute(status -> + confirmation.confirm( + winner.getPreviewToken(), winner.getClientRequestId(), winner.getWarningDigest(), + winner.getActorId(), Map.of(), java.util.Set.of())); + assertThat(replay.operationId()).isEqualTo(winner.getOperationId()); + assertThat(replay.replayed()).isTrue(); + } + + @Test + void updateReservationRejectsMissingSuiteIdentity() { + assertThatThrownBy(() -> SkillSuiteBundleExecutionOperation.reservationKey( + SkillSuiteBundleMode.UPDATE, 1L, "target", null, "1.1.0")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("UPDATE reservation requires suite and target version"); + } + + @Test + @Transactional(propagation = Propagation.NOT_SUPPORTED) + void updateReservationIsScopedToSuiteAndTargetVersion() { + TransactionTemplate transactions = new TransactionTemplate(transactionManager); + SuiteFixture fixture = transactions.execute(status -> persistSuite("bundle-update-lock")); + transactions.executeWithoutResult(status -> { + previewRepository.save(preview( + "update-preview-a", "update-actor-a", fixture.namespaceId(), "target", + fixture.suiteId(), fixture.baseVersionId())); + operationRepository.save(operation( + "update-op-a", "update-preview-a", "update-request-a", "update-actor-a", + SkillSuiteBundleMode.UPDATE, fixture.namespaceId(), "target", + fixture.suiteId(), fixture.baseVersionId())); + }); + + assertThatThrownBy(() -> transactions.executeWithoutResult(status -> { + previewRepository.save(preview( + "update-preview-b", "update-actor-b", fixture.namespaceId(), "target", + fixture.suiteId(), fixture.baseVersionId())); + operationRepository.save(operation( + "update-op-b", "update-preview-b", "update-request-b", "update-actor-b", + SkillSuiteBundleMode.UPDATE, fixture.namespaceId(), "target", + fixture.suiteId(), fixture.baseVersionId())); + })).isInstanceOf(DataIntegrityViolationException.class); + } + + @Test + void deletingTheBaseSuiteVersionKeepsTheUpdateOperationAndClearsItsReference() { + SuiteFixture fixture = persistSuite("bundle-deleted-base-version"); + previewRepository.save(preview( + "deleted-base-preview", "actor", fixture.namespaceId(), "target", + fixture.suiteId(), fixture.baseVersionId())); + operationRepository.save(operation( + "deleted-base-operation", "deleted-base-preview", "deleted-base-request", "actor", + SkillSuiteBundleMode.UPDATE, fixture.namespaceId(), "target", + fixture.suiteId(), fixture.baseVersionId())); + entityManager.flush(); + + entityManager.createNativeQuery("DELETE FROM skill_suite_version WHERE id = :id") + .setParameter("id", fixture.baseVersionId()) + .executeUpdate(); + entityManager.flush(); + entityManager.clear(); + + assertThat(previewRepository.findById("deleted-base-preview")) + .get() + .extracting(SkillSuiteBundlePreviewSession::getBaseSuiteVersionId) + .isNull(); + assertThat(operationRepository.findById("deleted-base-operation")) + .get() + .extracting(SkillSuiteBundleExecutionOperation::getBaseSuiteVersionId) + .isNull(); + } + + @Test + void operationAndMemberPlanRemainReadableAfterPersistenceContextIsCleared() { + Namespace namespace = persistNamespace("bundle-recovery"); + previewRepository.save(preview("recovery-preview", "actor", namespace.getId(), "target", null, null)); + operationRepository.save(operation( + "recovery-op", "recovery-preview", "request", "actor", + SkillSuiteBundleMode.CREATE, namespace.getId(), "target", null, null)); + memberRepository.saveAll(List.of(new SkillSuiteBundleMemberResult( + "recovery-op", 0, new SkillSuiteBundleCoordinate("global", "member"), + SkillSuiteBundleMemberSourceType.PACKAGE, "members/member", SkillVisibility.PUBLIC, + "1.0.0", SkillSuiteBundleRelationshipChange.ADDED, + SkillSuiteBundlePublishAction.CREATE_SKILL, "sha256:member", null, null, + List.of(), List.of("review warning"), now()))); + entityManager.flush(); + entityManager.clear(); + + SkillSuiteBundleExecutionOperation operation = operationRepository.findById("recovery-op").orElseThrow(); + assertThat(operation.getPlan()).containsEntry("memberCount", 1); + assertThat(operation.isReservationActive()).isTrue(); + assertThat(memberRepository.findByOperationIdOrderByPosition("recovery-op")) + .singleElement() + .satisfies(member -> { + assertThat(member.getSkillSlug()).isEqualTo("member"); + assertThat(member.getWarnings()).containsExactly("review warning"); + }); + } + + @Test + void cleanupQueriesReturnOnlyExpiredPreviewsAndUncleanedTerminalOperations() { + Namespace namespace = persistNamespace("bundle-cleanup-query"); + SkillSuiteBundlePreviewSession expired = preview( + "cleanup-expired", "actor", namespace.getId(), "expired", null, null); + expired.markExpired(); + previewRepository.save(expired); + SkillSuiteBundlePreviewSession activePreview = preview( + "cleanup-active-preview", "actor", namespace.getId(), "active", null, null); + activePreview.markConfirmed(now()); + previewRepository.save(activePreview); + operationRepository.save(operation( + "cleanup-active", "cleanup-active-preview", "cleanup-active-request", "actor", + SkillSuiteBundleMode.CREATE, namespace.getId(), "active", null, null)); + SkillSuiteBundlePreviewSession terminalPreview = preview( + "cleanup-terminal-preview", "actor", namespace.getId(), "terminal", null, null); + terminalPreview.markConfirmed(now()); + previewRepository.save(terminalPreview); + SkillSuiteBundleExecutionOperation terminal = operation( + "cleanup-terminal", "cleanup-terminal-preview", "cleanup-request", "actor", + SkillSuiteBundleMode.CREATE, namespace.getId(), "terminal", null, null); + terminal.cancel(now().plusSeconds(1)); + operationRepository.save(terminal); + entityManager.flush(); + entityManager.clear(); + + assertThat(previewRepository + .findTop100ByStatusAndStagedObjectsCleanedAtIsNullOrderByExpiresAtAsc( + SkillSuiteBundlePreviewStatus.EXPIRED)) + .extracting(SkillSuiteBundlePreviewSession::getToken) + .containsExactly("cleanup-expired"); + assertThat(operationRepository + .findTop100ByStatusInAndStagedObjectsCleanedAtIsNullOrderByCompletedAtAsc(Set.of( + SkillSuiteBundleOperationStatus.REPREVIEW_REQUIRED, + SkillSuiteBundleOperationStatus.SUITE_DRAFT_CREATED, + SkillSuiteBundleOperationStatus.CANCELLED))) + .extracting(SkillSuiteBundleExecutionOperation::getOperationId) + .contains("cleanup-terminal") + .doesNotContain("cleanup-active"); + } + + @Test + @Transactional(propagation = Propagation.NOT_SUPPORTED) + void rollingBackConfirmationLeavesNoOperationOrSuiteLifecycleRows() { + TransactionTemplate transactions = new TransactionTemplate(transactionManager); + Long namespaceId = transactions.execute(status -> persistNamespace("bundle-rollback").getId()); + + assertThatThrownBy(() -> transactions.executeWithoutResult(status -> { + previewRepository.save(preview( + "rollback-preview", "rollback-actor", namespaceId, "target", null, null)); + operationRepository.save(operation( + "rollback-op", "rollback-preview", "rollback-request", "rollback-actor", + SkillSuiteBundleMode.CREATE, namespaceId, "target", null, null)); + entityManager.flush(); + throw new IllegalStateException("force rollback"); + })).isInstanceOf(IllegalStateException.class); + + assertThat(operationRepository.findById("rollback-op")).isEmpty(); + assertThat(previewRepository.findById("rollback-preview")).isEmpty(); + Long suiteCount = transactions.execute(status -> entityManager.createQuery( + "SELECT COUNT(suite) FROM SkillSuite suite WHERE suite.namespaceId = :namespaceId", Long.class) + .setParameter("namespaceId", namespaceId) + .getSingleResult()); + assertThat(suiteCount).isZero(); + } + + private boolean attemptReservation( + TransactionTemplate transactions, CountDownLatch ready, CountDownLatch start, + Long namespaceId, Long suiteId, Long baseVersionId, String suffix + ) { + ready.countDown(); + try { + if (!start.await(10, TimeUnit.SECONDS)) { + throw new AssertionError("Timed out waiting to start concurrent confirmation"); + } + transactions.executeWithoutResult(status -> { + String previewToken = "preview-" + suffix; + SkillSuiteBundleMode mode = suiteId == null + ? SkillSuiteBundleMode.CREATE + : SkillSuiteBundleMode.UPDATE; + previewRepository.save(preview( + previewToken, "actor-" + suffix, namespaceId, "target", suiteId, baseVersionId)); + operationRepository.save(operation( + "operation-" + suffix, previewToken, "request-" + suffix, "actor-" + suffix, + mode, namespaceId, "target", suiteId, baseVersionId)); + }); + return true; + } catch (DataIntegrityViolationException exception) { + return false; + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + throw new AssertionError("Interrupted while waiting to confirm", exception); + } + } + + private boolean attemptConfirmation( + TransactionTemplate transactions, + SkillSuiteBundleConfirmationAppService confirmation, + CountDownLatch ready, + CountDownLatch start, + String previewToken, + String requestId, + String actorId + ) { + ready.countDown(); + try { + if (!start.await(10, TimeUnit.SECONDS)) { + throw new AssertionError("Timed out waiting to start concurrent confirmation"); + } + transactions.execute(status -> confirmation.confirm( + previewToken, requestId, "warning-digest", actorId, Map.of(), java.util.Set.of())); + return true; + } catch (DomainConflictException exception) { + return false; + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + throw new AssertionError("Interrupted while waiting to confirm", exception); + } + } + + private SkillSuiteBundlePreviewSession confirmablePreview( + String token, + String actor, + Long namespaceId, + com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleManifest manifest, + SkillSuiteBundlePreviewPlanner.PreviewPlan plan, + ObjectMapper objectMapper + ) { + return new SkillSuiteBundlePreviewSession( + token, actor, SkillSuiteBundleMode.CREATE, namespaceId, "target", null, null, "1.1.0", + "temporary/" + token + ".zip", "a".repeat(64), + objectMapper.convertValue(manifest, JSON_OBJECT), objectMapper.convertValue(plan, JSON_OBJECT), + "warning-digest", now().plus(30, ChronoUnit.MINUTES), now()); + } + + private SkillSuiteBundlePreviewPlanner.PreviewPlan confirmationPlan(Long namespaceId) { + SkillSuiteBundlePreviewPlanner.MemberPlan member = new SkillSuiteBundlePreviewPlanner.MemberPlan( + new SkillSuiteBundleCoordinate("bundle-confirm-service", "member"), + SkillSuiteBundleMemberSourceType.PACKAGE, + SkillSuiteBundleRelationshipChange.ADDED, SkillSuiteBundlePublishAction.CREATE_SKILL, + null, null, SkillVisibility.PUBLIC, "1.0.0", "sha256:member", + List.of(), List.of(), List.of()); + return new SkillSuiteBundlePreviewPlanner.PreviewPlan( + SkillSuiteBundleMode.CREATE, + new SkillSuiteBundleCoordinate("bundle-confirm-service", "target"), namespaceId, + null, null, "1.1.0", "Target", "Summary", "Overview", SkillVisibility.PUBLIC, + List.of(member), List.of(), List.of(), List.of(), "warning-digest"); + } + + private Namespace persistNamespace(String slug) { + String ownerId = "owner-" + slug; + entityManager.persist(new UserAccount(ownerId, ownerId, null, null)); + Namespace namespace = new Namespace(slug, slug, ownerId); + entityManager.persist(namespace); + entityManager.flush(); + return namespace; + } + + private SuiteFixture persistSuite(String namespaceSlug) { + Namespace namespace = persistNamespace(namespaceSlug); + String ownerId = "owner-" + namespaceSlug; + SkillSuite suite = new SkillSuite(namespace.getId(), "target", "Target", ownerId); + entityManager.persist(suite); + SkillSuiteVersion version = new SkillSuiteVersion( + suite.getId(), "1.0.0", "Target", "Summary", SkillVisibility.PUBLIC, ownerId); + entityManager.persist(version); + entityManager.flush(); + return new SuiteFixture(namespace.getId(), suite.getId(), version.getId()); + } + + private SkillSuiteBundlePreviewSession preview( + String token, String actor, Long namespaceId, String slug, Long suiteId, Long baseVersionId + ) { + SkillSuiteBundleMode mode = suiteId == null ? SkillSuiteBundleMode.CREATE : SkillSuiteBundleMode.UPDATE; + return new SkillSuiteBundlePreviewSession( + token, actor, mode, namespaceId, slug, suiteId, baseVersionId, "1.1.0", + "temporary/" + token + ".zip", "a".repeat(64), Map.of("kind", "SkillSuiteBundle"), + Map.of("memberCount", 1), "b".repeat(64), now().plus(30, ChronoUnit.MINUTES), now()); + } + + private SkillSuiteBundleExecutionOperation operation( + String operationId, String previewToken, String requestId, String actor, + SkillSuiteBundleMode mode, Long namespaceId, String slug, Long suiteId, Long baseVersionId + ) { + return new SkillSuiteBundleExecutionOperation( + operationId, previewToken, requestId, actor, mode, namespaceId, slug, suiteId, + baseVersionId, "1.1.0", "temporary/" + previewToken + ".zip", "a".repeat(64), + Map.of("memberCount", 1), "b".repeat(64), now()); + } + + private Instant now() { + return Instant.parse("2026-09-11T04:00:00Z"); + } + + private record SuiteFixture(Long namespaceId, Long suiteId, Long baseVersionId) { + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/ResourceDiscoveryAppServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/ResourceDiscoveryAppServiceTest.java new file mode 100644 index 00000000..eb67aa40 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/ResourceDiscoveryAppServiceTest.java @@ -0,0 +1,56 @@ +package com.iflytek.skillhub.service; + +import com.iflytek.skillhub.repository.SkillSuiteLabelQueryRepository; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.iflytek.skillhub.dto.SkillLabelDto; +import com.iflytek.skillhub.search.ResourceDiscoveryQueryService; +import com.iflytek.skillhub.search.ResourceDiscoveryQueryService.ResourceHit; +import com.iflytek.skillhub.search.ResourceDiscoveryQueryService.ResourcePage; +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.Set; +import org.junit.jupiter.api.Test; + +class ResourceDiscoveryAppServiceTest { + + private final ResourceDiscoveryQueryService queryService = mock(ResourceDiscoveryQueryService.class); + private final SkillSuiteLabelQueryRepository projectionService = + mock(SkillSuiteLabelQueryRepository.class); + private final ResourceDiscoveryAppService service = + new ResourceDiscoveryAppService(queryService, projectionService); + + @Test + void attachesLabelsToSuitesWithOneBatchAndNeverToSkills() { + Instant now = Instant.parse("2026-09-11T00:00:00Z"); + ResourceHit suite = new ResourceHit( + "SUITE", 1L, "global", "suite", "Suite", "Summary", "1.0.0", + "PUBLIC", 1, true, now); + ResourceHit skill = new ResourceHit( + "SKILL", 2L, "global", "skill", "Skill", "Summary", "1.0.0", + "PUBLIC", 2, true, now); + when(queryService.search(anyQuery())).thenReturn(new ResourcePage(List.of(suite, skill), 2, 0, 20)); + SkillLabelDto label = new SkillLabelDto("automation", "RECOMMENDED", "Automation"); + when(projectionService.labelsBySuiteIds(List.of(1L))).thenReturn(Map.of(1L, List.of(label))); + + var response = service.search( + null, null, "SUITE", "newest", 0, 20, Set.of(), List.of("Automation")); + + assertThat(response.items()).filteredOn(item -> item.resourceType().equals("SUITE")) + .singleElement().extracting(item -> item.labels()).isEqualTo(List.of(label)); + assertThat(response.items()).filteredOn(item -> item.resourceType().equals("SKILL")) + .singleElement().extracting(item -> item.labels()).isEqualTo(List.of()); + verify(projectionService).labelsBySuiteIds(List.of(1L)); + verify(queryService).search(new ResourceDiscoveryQueryService.ResourceQuery( + null, null, "SUITE", "newest", 0, 20, Set.of(), List.of("automation"))); + } + + private ResourceDiscoveryQueryService.ResourceQuery anyQuery() { + return org.mockito.ArgumentMatchers.any(ResourceDiscoveryQueryService.ResourceQuery.class); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillSuiteAppServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillSuiteAppServiceTest.java index 71693fa3..c4372c7c 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillSuiteAppServiceTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillSuiteAppServiceTest.java @@ -25,6 +25,9 @@ import com.iflytek.skillhub.domain.suite.SkillSuiteMemberState; import com.iflytek.skillhub.domain.suite.SkillSuiteQueryService; import com.iflytek.skillhub.domain.suite.SkillSuiteVersion; import com.iflytek.skillhub.domain.suite.SkillSuiteVersionMember; +import com.iflytek.skillhub.domain.suite.SkillSuiteVersionStatus; +import com.iflytek.skillhub.domain.user.UserAccount; +import com.iflytek.skillhub.domain.user.UserAccountRepository; import com.iflytek.skillhub.repository.SkillSuiteCandidateQueryRepository; import com.iflytek.skillhub.repository.MySkillSuiteQueryRepository; import com.iflytek.skillhub.repository.SkillSuiteReferenceQueryRepository; @@ -38,8 +41,10 @@ import org.mockito.ArgumentCaptor; import org.mockito.junit.jupiter.MockitoExtension; import java.lang.reflect.Field; +import java.time.Instant; import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.Set; import static org.assertj.core.api.Assertions.assertThat; @@ -54,6 +59,21 @@ import static org.mockito.Mockito.verify; @ExtendWith(MockitoExtension.class) class SkillSuiteAppServiceTest { + @Test + void workspaceBoundsPaginationAndSeparatesMemberAndAdminPermissions() { + service.workspace("actor", Map.of(1L, NamespaceRole.MEMBER, 2L, NamespaceRole.ADMIN), + "care", "ATTENTION", -1, 200); + verify(mySkillSuiteQueryRepository).findWorkspace("actor", Set.of(1L, 2L), Set.of(2L), + "care", "ATTENTION", 0, 100); + } + + @Test + void workspaceRejectsUnknownStateBeforeQuerying() { + assertThatThrownBy(() -> service.workspace("actor", Map.of(), "", "UNKNOWN", 0, 12)) + .isInstanceOf(DomainBadRequestException.class); + org.mockito.Mockito.verifyNoInteractions(mySkillSuiteQueryRepository); + } + @Mock private NamespaceRepository namespaceRepository; @Mock private SkillQueryService skillQueryService; @Mock private SkillSuiteDraftService draftService; @@ -66,6 +86,7 @@ class SkillSuiteAppServiceTest { @Mock private SkillSuiteCandidateQueryRepository candidateQueryRepository; @Mock private MySkillSuiteQueryRepository mySkillSuiteQueryRepository; @Mock private SkillSuiteReferenceQueryRepository referenceQueryRepository; + @Mock private UserAccountRepository userAccountRepository; @Mock private HttpServletRequest request; private SkillSuiteAppService service; private Namespace namespace; @@ -80,7 +101,8 @@ class SkillSuiteAppServiceTest { namespaceRepository, skillQueryService, draftService, lifecycleService, queryService, installMetricsService, installOperationRepository, auditLogService, requestIdAccessor, - candidateQueryRepository, mySkillSuiteQueryRepository, referenceQueryRepository); + candidateQueryRepository, mySkillSuiteQueryRepository, referenceQueryRepository, + userAccountRepository); namespace = new Namespace("global", "Global", "admin"); setField(namespace, "id", 1L); suite = new SkillSuite(1L, "starter", "Starter", "user-1"); @@ -88,6 +110,7 @@ class SkillSuiteAppServiceTest { version = new SkillSuiteVersion(7L, "1.0.0", SkillVisibility.PUBLIC, "user-1"); setField(version, "id", 70L); version.setOverview("## Install in order"); + version.setChangelog("Initial Suite workflow"); firstMember = member(11L, 101L, "first", "1.0.0", "sha256:first", 0); secondMember = member(12L, 102L, "second", "2.0.0", "sha256:second", 1); } @@ -120,6 +143,30 @@ class SkillSuiteAppServiceTest { any(), any(), any(), any(), any(), any(), any(), any()); } + @Test + void createInstallPlan_keepsHistoricalEmptyDisplayMetadataInstallable() { + version.setOverview(null); + SkillSuiteQueryService.Detail detail = detail(true); + given(queryService.getDetail("global", "starter", null, "user-1", Map.of(), Set.of())) + .willReturn(detail); + given(skillQueryService.resolveVersionById(101L, "user-1", Map.of(), Set.of())) + .willReturn(resolved(11L, 101L, "first", "1.0.0", "sha256:first")); + given(skillQueryService.resolveVersionById(102L, "user-1", Map.of(), Set.of())) + .willReturn(resolved(12L, 102L, "second", "2.0.0", "sha256:second")); + given(installOperationRepository.insertIfAbsent( + any(), org.mockito.ArgumentMatchers.eq("historical-1"), + org.mockito.ArgumentMatchers.eq("user:user-1"), + org.mockito.ArgumentMatchers.eq(7L), org.mockito.ArgumentMatchers.eq(70L))).willReturn(1); + + var result = service.createInstallPlan( + "global", "starter", null, "user-1", Map.of(), Set.of(), "historical-1", request); + + assertThat(version.getSummary()).isNull(); + assertThat(version.getOverview()).isNull(); + assertThat(result.members()).hasSize(2); + verify(installMetricsService).recordIssuedPlan(7L); + } + @Test void createInstallPlan_preservesSuperAdminAccessWhenResolvingPrivateMembers() { Set platformRoles = Set.of("SUPER_ADMIN"); @@ -287,6 +334,8 @@ class SkillSuiteAppServiceTest { org.mockito.ArgumentMatchers.eq(suite), org.mockito.ArgumentMatchers.eq(version), org.mockito.ArgumentMatchers.eq(namespace), any())) .willReturn(Set.of(SkillSuiteAllowedAction.EDIT, SkillSuiteAllowedAction.CREATE_VERSION)); + given(userAccountRepository.findById("user-1")) + .willReturn(Optional.of(new UserAccount("user-1", "Suite Owner", null, null))); var result = service.getDetail( "global", "starter", null, "user-1", Map.of(1L, NamespaceRole.MEMBER), Set.of()); @@ -296,10 +345,37 @@ class SkillSuiteAppServiceTest { assertThat(result.suiteStatus()).isEqualTo("ACTIVE"); assertThat(result.hidden()).isFalse(); assertThat(result.overview()).isEqualTo("## Install in order"); + assertThat(result.changelog()).isEqualTo("Initial Suite workflow"); + assertThat(result.createdBy()).isEqualTo("user-1"); + assertThat(result.createdByName()).isEqualTo("Suite Owner"); assertThat(result.members()).extracting(member -> member.displayName()) .containsExactly("First Skill", "Second Skill"); } + @Test + void listVersions_resolvesCreatorNamesInOneBatch() { + var createdAt = Instant.parse("2026-09-15T10:00:00Z"); + given(queryService.listVersions("global", "starter", "user-1", Map.of(), Set.of())) + .willReturn(List.of( + new SkillSuiteQueryService.VersionSummary( + 70L, "1.0.0", SkillSuiteVersionStatus.PUBLISHED, + SkillVisibility.PUBLIC, "Initial", "user-1", createdAt, null, createdAt), + new SkillSuiteQueryService.VersionSummary( + 71L, "1.1.0", SkillSuiteVersionStatus.DRAFT, + SkillVisibility.PUBLIC, "Next", "user-2", null, null, createdAt))); + given(userAccountRepository.findByIdIn(List.of("user-1", "user-2"))) + .willReturn(List.of( + new UserAccount("user-1", "Suite Owner", null, null), + new UserAccount("user-2", "Second Owner", null, null))); + + var result = service.listVersions("global", "starter", "user-1", Map.of(), Set.of()); + + assertThat(result).extracting(item -> item.createdByName()) + .containsExactly("Suite Owner", "Second Owner"); + verify(userAccountRepository).findByIdIn(List.of("user-1", "user-2")); + verify(userAccountRepository, never()).findById(any()); + } + @Test void getDetail_hidesLiveMemberMetadataWhenTheViewerCannotReadThatSkill() { SkillSuiteMemberState restricted = new SkillSuiteMemberState( @@ -341,13 +417,13 @@ class SkillSuiteAppServiceTest { .isInstanceOfSatisfying(DomainBadRequestException.class, exception -> assertThat(exception.messageArgs()[0].toString()) .contains("@global/selected@1.0.0") - .contains("error.suite.members.selectionMismatch")); + .doesNotContain("error.suite.members.selectionMismatch")); verify(draftService, never()).create(any(), any()); } @Test - void create_reportsEveryInvalidMemberCoordinateAndReason() { + void create_reportsEveryInvalidMemberCoordinateWithoutInternalReasonCodes() { SkillSuiteMemberRequest first = new SkillSuiteMemberRequest( 101L, "global", "missing", "1.0.0"); SkillSuiteMemberRequest second = new SkillSuiteMemberRequest( @@ -367,8 +443,30 @@ class SkillSuiteAppServiceTest { assertThat(exception.messageCode()).isEqualTo("error.suite.members.invalid"); assertThat((String) exception.messageArgs()[0]) - .contains("@global/missing@1.0.0 (error.skill.version.notFound)") - .contains("@private-team/restricted@2.0.0 (error.skill.access.denied)"); + .contains("@global/missing@1.0.0") + .contains("@private-team/restricted@2.0.0") + .doesNotContain("error.skill.version.notFound") + .doesNotContain("error.skill.access.denied"); + verify(draftService, never()).create(any(), any()); + } + + @Test + void create_reportsADuplicateInvalidMemberOnlyOnce() { + SkillSuiteMemberRequest member = new SkillSuiteMemberRequest( + 101L, "global", "unavailable", "1.0.0"); + SkillSuiteCreateRequest createRequest = new SkillSuiteCreateRequest( + "global", "starter", "Starter", null, null, "1.0.0", + SkillVisibility.PRIVATE, null, member, List.of(member, member)); + given(namespaceRepository.findBySlug("global")).willReturn(java.util.Optional.of(namespace)); + given(skillQueryService.resolveVersionById(101L, "user-1", Map.of(), Set.of())) + .willThrow(new DomainBadRequestException("error.skill.version.notDownloadable", "1.0.0")); + + DomainBadRequestException exception = catchThrowableOfType( + () -> service.create(createRequest, "user-1", Map.of(), Set.of(), request), + DomainBadRequestException.class); + + assertThat(exception.messageCode()).isEqualTo("error.suite.members.invalid"); + assertThat(exception.messageArgs()[0]).isEqualTo("@global/unavailable@1.0.0"); verify(draftService, never()).create(any(), any()); } diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillSuiteLabelAppServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillSuiteLabelAppServiceTest.java new file mode 100644 index 00000000..17307efb --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillSuiteLabelAppServiceTest.java @@ -0,0 +1,104 @@ +package com.iflytek.skillhub.service; + +import com.iflytek.skillhub.repository.SkillSuiteLabelQueryRepository; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +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.suite.SkillSuite; +import com.iflytek.skillhub.domain.suite.SkillSuiteQueryService; +import com.iflytek.skillhub.domain.suite.SkillSuiteRepository; +import com.iflytek.skillhub.dto.SkillLabelDto; +import com.iflytek.skillhub.observability.RequestIdAccessor; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +class SkillSuiteLabelAppServiceTest { + + private final NamespaceRepository namespaceRepository = mock(NamespaceRepository.class); + private final SkillSuiteRepository suiteRepository = mock(SkillSuiteRepository.class); + private final SkillSuiteQueryService suiteQueryService = mock(SkillSuiteQueryService.class); + private final SkillSuiteLabelService suiteLabelService = mock(SkillSuiteLabelService.class); + private final SkillSuiteLabelQueryRepository projectionService = + mock(SkillSuiteLabelQueryRepository.class); + private final AuditLogService auditLogService = mock(AuditLogService.class); + private final RequestIdAccessor requestIdAccessor = new RequestIdAccessor(); + private final SkillSuiteLabelAppService service = new SkillSuiteLabelAppService( + namespaceRepository, suiteRepository, suiteQueryService, suiteLabelService, + projectionService, auditLogService, requestIdAccessor); + + private Namespace namespace; + private SkillSuite suite; + + @BeforeEach + void setUp() { + namespace = new Namespace("global", "Global", "owner"); + suite = new SkillSuite(1L, "starter", "Starter", "author"); + ReflectionTestUtils.setField(namespace, "id", 1L); + ReflectionTestUtils.setField(suite, "id", 10L); + when(namespaceRepository.findBySlug("global")).thenReturn(Optional.of(namespace)); + when(suiteRepository.findByNamespaceIdAndSlug(1L, "starter")).thenReturn(Optional.of(suite)); + } + + @Test + void publicReaderMustPassExistingSuiteVisibilityCheck() { + SkillLabelDto label = new SkillLabelDto("automation", "RECOMMENDED", "Automation"); + when(projectionService.labelsBySuiteIds(List.of(10L))).thenReturn(Map.of(10L, List.of(label))); + + assertThat(service.listLabels("global", "starter", null, Map.of(), Set.of())) + .containsExactly(label); + + verify(suiteQueryService).getDetail("global", "starter", null, null, Map.of(), Set.of()); + } + + @Test + void suiteManagerCanReadDraftContainerLabelsWithoutPublishedVersion() { + when(projectionService.labelsBySuiteIds(List.of(10L))).thenReturn(Map.of()); + + assertThat(service.listLabels( + "global", "starter", "owner", Map.of(1L, NamespaceRole.OWNER), Set.of())) + .isEmpty(); + + verify(suiteQueryService, never()).getDetail(any(), any(), any(), any(), any(), any()); + } + + @Test + void mutationRecordsSuiteScopedAuditWithRequestCorrelation() { + SkillSuiteLabel assignment = new SkillSuiteLabel(10L, 20L, "author"); + SkillLabelDto label = new SkillLabelDto("automation", "RECOMMENDED", "Automation"); + when(suiteLabelService.attachLabel( + 10L, "automation", "author", Map.of(1L, NamespaceRole.MEMBER), Set.of())) + .thenReturn(assignment); + when(projectionService.labelsBySuiteIds(List.of(10L))) + .thenReturn(Map.of(10L, List.of(label))); + + try (RequestIdAccessor.Scope ignored = requestIdAccessor.open("req-suite-label")) { + assertThat(service.attachLabel( + "global", "starter", "automation", "author", + Map.of(1L, NamespaceRole.MEMBER), Set.of(), + new AuditRequestContext("127.0.0.1", "test-agent"))) + .isEqualTo(label); + } + + verify(auditLogService).record( + eq("author"), eq("SKILL_SUITE_LABEL_ATTACH"), eq("SKILL_SUITE"), eq(10L), + eq("req-suite-label"), eq("127.0.0.1"), eq("test-agent"), + eq("{\"labelSlug\":\"automation\"}")); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillSuiteLabelProjectionServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillSuiteLabelProjectionServiceTest.java new file mode 100644 index 00000000..ba0da7e8 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillSuiteLabelProjectionServiceTest.java @@ -0,0 +1,71 @@ +package com.iflytek.skillhub.service; + +import com.iflytek.skillhub.repository.SkillSuiteLabelQueryRepository; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.iflytek.skillhub.domain.label.LabelDefinition; +import com.iflytek.skillhub.domain.label.LabelDefinitionService; +import com.iflytek.skillhub.domain.label.LabelType; +import com.iflytek.skillhub.domain.label.SkillSuiteLabel; +import com.iflytek.skillhub.domain.label.SkillSuiteLabelService; +import com.iflytek.skillhub.dto.SkillLabelDto; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +class SkillSuiteLabelProjectionServiceTest { + + private final SkillSuiteLabelService suiteLabelService = mock(SkillSuiteLabelService.class); + private final LabelDefinitionService labelDefinitionService = mock(LabelDefinitionService.class); + private final SkillSuiteLabelQueryRepository service = new SkillSuiteLabelQueryRepository( + suiteLabelService, labelDefinitionService, new LabelLocalizationService()); + + @Test + void projectsWholeSuitePageWithThreeBoundedLookups() { + LabelDefinition automation = definition(10L, "automation", LabelType.RECOMMENDED); + LabelDefinition verified = definition(11L, "verified", LabelType.PRIVILEGED); + when(suiteLabelService.listSuiteLabelsBySuiteIds(List.of(1L, 2L))).thenReturn(List.of( + new SkillSuiteLabel(1L, 10L, "owner"), + new SkillSuiteLabel(1L, 11L, "admin"), + new SkillSuiteLabel(2L, 10L, "owner"))); + when(labelDefinitionService.listByIds(anyList())).thenReturn(List.of(automation, verified)); + when(labelDefinitionService.listTranslationsByLabelIds(anyList())).thenReturn(Map.of()); + + Map> result = service.labelsBySuiteIds(List.of(1L, 2L, 1L)); + + assertThat(result.get(1L)).extracting(SkillLabelDto::slug) + .containsExactly("verified", "automation"); + assertThat(result.get(2L)).extracting(SkillLabelDto::slug) + .containsExactly("automation"); + verify(suiteLabelService).listSuiteLabelsBySuiteIds(List.of(1L, 2L)); + verify(labelDefinitionService).listByIds(anyList()); + verify(labelDefinitionService).listTranslationsByLabelIds(anyList()); + } + + @Test + void skipsMissingDefinitionsAndDoesNotQueryForEmptyInput() { + when(suiteLabelService.listSuiteLabelsBySuiteIds(List.of(1L))) + .thenReturn(List.of(new SkillSuiteLabel(1L, 99L, "owner"))); + when(labelDefinitionService.listByIds(anyList())).thenReturn(List.of()); + when(labelDefinitionService.listTranslationsByLabelIds(anyList())).thenReturn(Map.of()); + + assertThat(service.labelsBySuiteIds(List.of(1L))).isEmpty(); + assertThat(service.labelsBySuiteIds(List.of())).isEmpty(); + assertThat(service.labelsBySuiteIds(null)).isEmpty(); + verify(suiteLabelService, times(1)).listSuiteLabelsBySuiteIds(any()); + } + + private LabelDefinition definition(Long id, String slug, LabelType type) { + LabelDefinition definition = new LabelDefinition(slug, type, true, 0, "admin"); + ReflectionTestUtils.setField(definition, "id", id); + return definition; + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/bundle/SkillSuiteBundleArchiveServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/bundle/SkillSuiteBundleArchiveServiceTest.java new file mode 100644 index 00000000..0d2c4473 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/bundle/SkillSuiteBundleArchiveServiceTest.java @@ -0,0 +1,320 @@ +package com.iflytek.skillhub.service.bundle; + +import com.iflytek.skillhub.config.SkillPublishProperties; +import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; +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 com.iflytek.skillhub.storage.ObjectMetadata; +import com.iflytek.skillhub.storage.ObjectStorageService; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockMultipartFile; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.time.Duration; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class SkillSuiteBundleArchiveServiceTest { + + @Test + void stagesValidArchiveAndKeepsOnlyObjectLocationsInThePlan() throws Exception { + InMemoryObjectStorage storage = new InMemoryObjectStorage(); + SkillSuiteBundleArchiveService service = service(storage, 1024 * 1024); + byte[] archive = zip(List.of( + file("outer/SUITE.yaml", manifest()), + file("outer/skills/member/SKILL.md", skillMd()), + file("outer/skills/member/notes.txt", "notes"), + file("__MACOSX/._notes.txt", "ignored") + )); + + SkillSuiteBundleArchiveService.StagedBundleAnalysis result = service.stageAndAnalyze( + new MockMultipartFile("file", "bundle.zip", "application/zip", archive)); + + assertThat(result.analysis().confirmable()).isTrue(); + assertThat(result.archiveSha256()).hasSize(64); + assertThat(result.objectKeys()).hasSize(3); + assertThat(storage.objects).containsOnlyKeys(result.objectKeys().toArray(String[]::new)); + assertThat(result.analysis().packageMembers()).singleElement().satisfies(member -> { + assertThat(member.fingerprint()).startsWith("sha256:"); + assertThat(member.files()).extracting( + SkillSuiteBundlePackageAnalyzer.StagedMemberFile::relativePath) + .containsExactly("SKILL.md", "notes.txt"); + assertThat(member.files()).allSatisfy(stagedFile -> + assertThat(storage.objects).containsKey(stagedFile.objectKey())); + }); + assertThat(storage.putCounts.values()).allMatch(count -> count == 1); + } + + @Test + void zipOrderAndCompressionMetadataDoNotChangeMemberFingerprint() throws Exception { + List forward = List.of( + file("SUITE.yaml", manifest()), + file("skills/member/SKILL.md", skillMd()), + file("skills/member/notes.txt", "notes") + ); + List reverse = List.of(forward.get(2), forward.get(1), forward.get(0)); + + SkillSuiteBundleArchiveService.StagedBundleAnalysis first = service( + new InMemoryObjectStorage(), 1024 * 1024).stageAndAnalyze( + new MockMultipartFile("file", "first.zip", "application/zip", zip(forward))); + SkillSuiteBundleArchiveService.StagedBundleAnalysis second = service( + new InMemoryObjectStorage(), 1024 * 1024).stageAndAnalyze( + new MockMultipartFile("file", "second.zip", "application/zip", zip(reverse))); + + assertThat(first.analysis().packageMembers().getFirst().fingerprint()) + .isEqualTo(second.analysis().packageMembers().getFirst().fingerprint()); + } + + @Test + void zipAndNormalizedDirectoryTreesProduceTheSameMemberFingerprint() throws Exception { + List files = List.of( + file("SUITE.yaml", manifest()), + file("skills/member/SKILL.md", skillMd()), + file("skills/member/notes.txt", "notes") + ); + SkillSuiteBundleArchiveService.StagedBundleAnalysis zipped = service( + new InMemoryObjectStorage(), 1024 * 1024).stageAndAnalyze( + new MockMultipartFile("file", "bundle.zip", "application/zip", zip(files))); + + SkillMetadataParser metadataParser = new SkillMetadataParser(); + SkillSuiteBundlePackageAnalyzer directoryAnalyzer = new SkillSuiteBundlePackageAnalyzer( + new SkillSuiteBundleManifestParser(), metadataParser, + new SkillPackageValidator(metadataParser)); + List directoryEntries = files.stream() + .map(this::stagedEntry) + .toList(); + SkillSuiteBundlePackageAnalyzer.BundleAnalysis directory = directoryAnalyzer.analyze(directoryEntries); + + assertThat(directory.packageMembers().getFirst().fingerprint()) + .isEqualTo(zipped.analysis().packageMembers().getFirst().fingerprint()); + } + + @Test + void analyzesOneHundredPackagedMembersWithBoundedResultDescriptors() throws Exception { + StringBuilder members = new StringBuilder(); + List files = new java.util.ArrayList<>(); + for (int index = 0; index < 100; index++) { + String slug = "member-" + index; + members.append(" - skill: \"@global/").append(slug).append("\"\n") + .append(" package:\n") + .append(" path: skills/").append(slug).append("\n") + .append(" visibility: PUBLIC\n"); + files.add(file("skills/" + slug + "/SKILL.md", skillMd(slug))); + } + files.add(0, file("SUITE.yaml", manifest(members.toString(), "@global/member-0"))); + + SkillSuiteBundleArchiveService.StagedBundleAnalysis result = service( + new InMemoryObjectStorage(), 1024 * 1024).stageAndAnalyze( + new MockMultipartFile("file", "maximum.zip", "application/zip", zip(files))); + + assertThat(result.analysis().confirmable()).isTrue(); + assertThat(result.analysis().packageMembers()).hasSize(100); + assertThat(result.analysis().packageMembers()) + .allSatisfy(member -> assertThat(member.files()).hasSize(1)); + } + + @Test + void rejectsExpandedArchiveLimitAndCleansUploadedObjects() throws Exception { + InMemoryObjectStorage storage = new InMemoryObjectStorage(); + SkillSuiteBundleArchiveService service = service(storage, 2_000); + byte[] archive = zip(List.of( + file("SUITE.yaml", manifest()), + file("skills/member/SKILL.md", skillMd()), + file("skills/member/large.txt", "x".repeat(3_000)) + )); + + assertThatThrownBy(() -> service.stageAndAnalyze( + new MockMultipartFile("file", "large.zip", "application/zip", archive))) + .isInstanceOf(DomainBadRequestException.class); + assertThat(storage.objects).isEmpty(); + } + + @Test + void rejectsUnixSymbolicLinksBeforeUploadingAnything() throws Exception { + InMemoryObjectStorage storage = new InMemoryObjectStorage(); + byte[] archive = markFirstEntryAsUnixSymlink(zip(List.of(file("link", "target")))); + + assertThatThrownBy(() -> service(storage, 1024 * 1024).stageAndAnalyze( + new MockMultipartFile("file", "link.zip", "application/zip", archive))) + .isInstanceOf(DomainBadRequestException.class); + assertThat(storage.objects).isEmpty(); + } + + @Test + void invalidBundleAnalysisDeletesTemporaryObjects() throws Exception { + InMemoryObjectStorage storage = new InMemoryObjectStorage(); + byte[] archive = zip(List.of( + file("SUITE.yaml", manifest()), + file("skills/member/SKILL.md", skillMd()), + file("README.md", "not declared") + )); + + SkillSuiteBundleArchiveService.StagedBundleAnalysis result = service( + storage, 1024 * 1024).stageAndAnalyze( + new MockMultipartFile("file", "invalid.zip", "application/zip", archive)); + + assertThat(result.analysis().confirmable()).isFalse(); + assertThat(result.archiveObjectKey()).isNull(); + assertThat(result.objectKeys()).isEmpty(); + assertThat(storage.objects).isEmpty(); + } + + private SkillSuiteBundleArchiveService service(InMemoryObjectStorage storage, long maxPackageSize) { + SkillMetadataParser metadataParser = new SkillMetadataParser(); + SkillSuiteBundlePackageAnalyzer analyzer = new SkillSuiteBundlePackageAnalyzer( + new SkillSuiteBundleManifestParser(), metadataParser, + new SkillPackageValidator(metadataParser)); + SkillPublishProperties properties = new SkillPublishProperties(); + properties.setMaxPackageSize(maxPackageSize); + properties.setMaxSingleFileSize(maxPackageSize); + return new SkillSuiteBundleArchiveService(analyzer, storage, properties); + } + + private byte[] zip(List files) throws IOException { + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + try (ZipOutputStream zip = new ZipOutputStream(bytes)) { + for (ArchiveFile file : files) { + zip.putNextEntry(new ZipEntry(file.path())); + zip.write(file.content()); + zip.closeEntry(); + } + } + return bytes.toByteArray(); + } + + private byte[] markFirstEntryAsUnixSymlink(byte[] archive) { + ByteBuffer bytes = ByteBuffer.wrap(archive).order(ByteOrder.LITTLE_ENDIAN); + for (int index = 0; index <= archive.length - 46; index++) { + if (bytes.getInt(index) == 0x02014b50) { + archive[index + 5] = 3; + bytes.putInt(index + 38, 0120777 << 16); + return archive; + } + } + throw new AssertionError("ZIP central directory not found"); + } + + private ArchiveFile file(String path, String content) { + return new ArchiveFile(path, content.getBytes(java.nio.charset.StandardCharsets.UTF_8)); + } + + private SkillSuiteBundleStagedEntry stagedEntry(ArchiveFile file) { + try { + String sha = java.util.HexFormat.of().formatHex( + java.security.MessageDigest.getInstance("SHA-256").digest(file.content())); + return new SkillSuiteBundleStagedEntry( + file.path(), file.content().length, "text/plain", sha, + "directory/" + file.path(), () -> new ByteArrayInputStream(file.content())); + } catch (java.security.NoSuchAlgorithmException exception) { + throw new IllegalStateException(exception); + } + } + + private String manifest() { + return manifest(""" + - skill: "@global/member" + package: + path: skills/member + visibility: PUBLIC + """, "@global/member"); + } + + private String manifest(String members, String entry) { + return """ + apiVersion: skillhub.iflytek.com/v1alpha1 + kind: SkillSuiteBundle + metadata: + namespace: global + slug: archive-test + spec: + mode: CREATE + version: 1.0.0 + displayName: Archive Test + summary: Archive summary + overview: Archive overview + visibility: PUBLIC + entry: "%s" + members: + %s + """.formatted(entry, members); + } + + private String skillMd() { + return skillMd("member"); + } + + private String skillMd(String name) { + return """ + --- + name: %s + description: Member skill + version: 1.0.0 + --- + Instructions. + """.formatted(name); + } + + private record ArchiveFile(String path, byte[] content) { + } + + private static final class InMemoryObjectStorage implements ObjectStorageService { + private final Map objects = new LinkedHashMap<>(); + private final Map putCounts = new LinkedHashMap<>(); + + @Override + public void putObject(String key, InputStream data, long size, String contentType) { + try { + byte[] bytes = data.readAllBytes(); + if (bytes.length != size) { + throw new AssertionError("size mismatch"); + } + objects.put(key, bytes); + putCounts.merge(key, 1, Integer::sum); + } catch (IOException exception) { + throw new IllegalStateException(exception); + } + } + + @Override + public InputStream getObject(String key) { + return new ByteArrayInputStream(objects.get(key)); + } + + @Override + public void deleteObject(String key) { + objects.remove(key); + } + + @Override + public void deleteObjects(List keys) { + keys.forEach(objects::remove); + } + + @Override + public boolean exists(String key) { + return objects.containsKey(key); + } + + @Override + public ObjectMetadata getMetadata(String key) { + throw new UnsupportedOperationException(); + } + + @Override + public String generatePresignedUrl(String key, Duration expiry, String downloadFilename) { + throw new UnsupportedOperationException(); + } + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/bundle/SkillSuiteBundleConfirmationAppServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/bundle/SkillSuiteBundleConfirmationAppServiceTest.java new file mode 100644 index 00000000..1167c26a --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/bundle/SkillSuiteBundleConfirmationAppServiceTest.java @@ -0,0 +1,232 @@ +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.shared.exception.DomainBadRequestException; +import com.iflytek.skillhub.domain.shared.exception.DomainConflictException; +import com.iflytek.skillhub.domain.skill.SkillVisibility; +import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleCoordinate; +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.SkillSuiteBundleManifestParser; +import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleMemberResult; +import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleMemberResultRepository; +import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleMemberSourceType; +import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleMode; +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.domain.suite.bundle.SkillSuiteBundlePublishAction; +import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleRelationshipChange; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.dao.DataIntegrityViolationException; + +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class SkillSuiteBundleConfirmationAppServiceTest { + + private static final Instant NOW = Instant.parse("2026-09-11T08:00:00Z"); + private static final TypeReference> JSON_OBJECT = new TypeReference<>() { }; + + private final ObjectMapper objectMapper = new ObjectMapper(); + private SkillSuiteBundlePreviewSessionRepository previewRepository; + private SkillSuiteBundleExecutionOperationRepository operationRepository; + private SkillSuiteBundleMemberResultRepository memberRepository; + private SkillSuiteBundlePreviewRevalidationService revalidationService; + private SkillSuiteBundleProperties properties; + private SkillSuiteBundleConfirmationAppService service; + + @BeforeEach + void setUp() { + previewRepository = mock(SkillSuiteBundlePreviewSessionRepository.class); + operationRepository = mock(SkillSuiteBundleExecutionOperationRepository.class); + memberRepository = mock(SkillSuiteBundleMemberResultRepository.class); + revalidationService = mock(SkillSuiteBundlePreviewRevalidationService.class); + properties = new SkillSuiteBundleProperties(); + properties.setConfirmationEnabled(true); + service = new SkillSuiteBundleConfirmationAppService( + previewRepository, operationRepository, memberRepository, revalidationService, properties, + mock(org.springframework.context.ApplicationEventPublisher.class), + Clock.fixed(NOW, ZoneOffset.UTC)); + } + + @Test + void confirmsExactLivePlanAndCreatesReservationBeforeMemberResults() { + SkillSuiteBundleManifest manifest = manifest(); + SkillSuiteBundlePreviewPlanner.PreviewPlan plan = plan(); + SkillSuiteBundlePreviewSession preview = preview(manifest, plan); + when(operationRepository.findByActorIdAndClientRequestId("actor", "request-1")) + .thenReturn(Optional.empty()); + when(previewRepository.findByIdForUpdate("preview-1")).thenReturn(Optional.of(preview)); + when(revalidationService.requireUnchanged(any(), any(), any(), any())) + .thenReturn(new SkillSuiteBundlePreviewRevalidationService.ValidatedPreview(manifest, plan)); + + SkillSuiteBundleConfirmationAppService.ConfirmationOutcome outcome = service.confirm( + "preview-1", "request-1", "warning-digest", "actor", Map.of(), Set.of()); + + assertThat(outcome.status()).isEqualTo("RUNNING"); + assertThat(outcome.replayed()).isFalse(); + assertThat(preview.getStatus()).isEqualTo(SkillSuiteBundlePreviewStatus.CONFIRMED); + verify(operationRepository).flush(); + verify(memberRepository).flush(); + verify(previewRepository).flush(); + + ArgumentCaptor> members = ArgumentCaptor.forClass(List.class); + verify(memberRepository).saveAll(members.capture()); + assertThat(members.getValue()).singleElement().satisfies(member -> { + assertThat(member.getNamespaceSlug()).isEqualTo("global"); + assertThat(member.getSkillSlug()).isEqualTo("member"); + assertThat(member.getPackagePath()).isEqualTo("skills/member"); + assertThat(member.getPublishAction()).isEqualTo(SkillSuiteBundlePublishAction.CREATE_SKILL); + }); + } + + @Test + void repeatsSameActorRequestWithoutLockingOrCreatingAnotherOperation() { + SkillSuiteBundleExecutionOperation existing = operation("preview-1", "request-1"); + when(operationRepository.findByActorIdAndClientRequestId("actor", "request-1")) + .thenReturn(Optional.of(existing)); + + SkillSuiteBundleConfirmationAppService.ConfirmationOutcome outcome = service.confirm( + "preview-1", "request-1", "warning-digest", "actor", Map.of(), Set.of()); + + assertThat(outcome.operationId()).isEqualTo("operation-1"); + assertThat(outcome.replayed()).isTrue(); + verify(previewRepository, never()).findByIdForUpdate(any()); + verify(operationRepository, never()).save(any()); + } + + @Test + void rejectsChangedLivePlanBeforeAcquiringReservation() { + SkillSuiteBundlePreviewPlanner.PreviewPlan original = plan(); + when(operationRepository.findByActorIdAndClientRequestId(any(), any())).thenReturn(Optional.empty()); + when(previewRepository.findByIdForUpdate("preview-1")) + .thenReturn(Optional.of(preview(manifest(), original))); + when(revalidationService.requireUnchanged(any(), any(), any(), any())) + .thenThrow(new DomainBadRequestException("error.suite.bundle.preview.stateChanged")); + + assertThatThrownBy(() -> service.confirm( + "preview-1", "request-1", "warning-digest", "actor", Map.of(), Set.of())) + .isInstanceOf(DomainBadRequestException.class) + .extracting("messageCode") + .isEqualTo("error.suite.bundle.preview.stateChanged"); + verify(operationRepository, never()).save(any()); + verify(memberRepository, never()).saveAll(any()); + } + + @Test + void mapsReservationRaceToConflictAndTransactionDoesNotReachMembers() { + SkillSuiteBundlePreviewPlanner.PreviewPlan plan = plan(); + when(operationRepository.findByActorIdAndClientRequestId(any(), any())).thenReturn(Optional.empty()); + when(previewRepository.findByIdForUpdate("preview-1")) + .thenReturn(Optional.of(preview(manifest(), plan))); + when(revalidationService.requireUnchanged(any(), any(), any(), any())) + .thenReturn(new SkillSuiteBundlePreviewRevalidationService.ValidatedPreview(manifest(), plan)); + doThrow(new DataIntegrityViolationException("reservation collision")) + .when(operationRepository).flush(); + + assertThatThrownBy(() -> service.confirm( + "preview-1", "request-1", "warning-digest", "actor", Map.of(), Set.of())) + .isInstanceOf(DomainConflictException.class) + .extracting("messageCode") + .isEqualTo("error.suite.bundle.confirmation.operationConflict"); + verify(memberRepository, never()).saveAll(any()); + } + + @Test + void featureFlagAndIdempotencyKeyAreValidatedBeforeDatabaseWrites() { + properties.setConfirmationEnabled(false); + assertThatThrownBy(() -> service.confirm( + "preview-1", "request-1", "warning-digest", "actor", Map.of(), Set.of())) + .isInstanceOf(DomainBadRequestException.class) + .extracting("messageCode") + .isEqualTo("error.suite.bundle.confirmation.disabled"); + + properties.setConfirmationEnabled(true); + assertThatThrownBy(() -> service.confirm( + "preview-1", " ", "warning-digest", "actor", Map.of(), Set.of())) + .isInstanceOf(DomainBadRequestException.class) + .extracting("messageCode") + .isEqualTo("error.suite.bundle.confirmation.idempotencyKey.invalid"); + assertThatThrownBy(() -> service.confirm( + "preview-1", "not valid!", "warning-digest", "actor", Map.of(), Set.of())) + .isInstanceOf(DomainBadRequestException.class) + .extracting("messageCode") + .isEqualTo("error.suite.bundle.confirmation.idempotencyKey.invalid"); + verify(operationRepository, never()).save(any()); + } + + private SkillSuiteBundlePreviewSession preview( + SkillSuiteBundleManifest manifest, + SkillSuiteBundlePreviewPlanner.PreviewPlan plan + ) { + return new SkillSuiteBundlePreviewSession( + "preview-1", "actor", SkillSuiteBundleMode.CREATE, 1L, "suite", null, null, + "1.0.0", "archive.zip", "a".repeat(64), + objectMapper.convertValue(manifest, JSON_OBJECT), objectMapper.convertValue(plan, JSON_OBJECT), + "warning-digest", NOW.plusSeconds(300), NOW.minusSeconds(60)); + } + + private SkillSuiteBundleExecutionOperation operation(String previewToken, String requestId) { + return new SkillSuiteBundleExecutionOperation( + "operation-1", previewToken, requestId, "actor", SkillSuiteBundleMode.CREATE, + 1L, "suite", null, null, "1.0.0", "archive.zip", "a".repeat(64), + Map.of("plan", "value"), "warning-digest", NOW); + } + + private SkillSuiteBundlePreviewPlanner.PreviewPlan plan() { + SkillSuiteBundlePreviewPlanner.MemberPlan member = new SkillSuiteBundlePreviewPlanner.MemberPlan( + new SkillSuiteBundleCoordinate("global", "member"), SkillSuiteBundleMemberSourceType.PACKAGE, + SkillSuiteBundleRelationshipChange.ADDED, SkillSuiteBundlePublishAction.CREATE_SKILL, + null, null, SkillVisibility.PUBLIC, "1.0.0", "sha256:member", + List.of(new SkillSuiteBundlePackageAnalyzer.StagedMemberFile( + "SKILL.md", 100, "text/markdown", "b".repeat(64), "staged/member/SKILL.md")), + List.of(), List.of()); + return new SkillSuiteBundlePreviewPlanner.PreviewPlan( + SkillSuiteBundleMode.CREATE, new SkillSuiteBundleCoordinate("global", "suite"), + 1L, null, null, "1.0.0", "Suite", "Summary", "Overview", + SkillVisibility.PUBLIC, List.of(member), List.of(), List.of(), List.of(), "warning-digest"); + } + + private SkillSuiteBundleManifest manifest() { + return new SkillSuiteBundleManifestParser().parse(""" + apiVersion: skillhub.iflytek.com/v1alpha1 + kind: SkillSuiteBundle + metadata: + namespace: global + slug: suite + spec: + mode: CREATE + version: 1.0.0 + displayName: Suite + summary: Summary + overview: Overview + visibility: PUBLIC + entry: "@global/member" + members: + - skill: "@global/member" + package: + path: skills/member + visibility: PUBLIC + """); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/bundle/SkillSuiteBundleCoordinatorTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/bundle/SkillSuiteBundleCoordinatorTest.java new file mode 100644 index 00000000..a738b042 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/bundle/SkillSuiteBundleCoordinatorTest.java @@ -0,0 +1,112 @@ +package com.iflytek.skillhub.service.bundle; + +import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; +import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class SkillSuiteBundleCoordinatorTest { + + private SkillSuiteBundleMemberExecutionService executionService; + private SkillSuiteBundleMemberProgressService progressService; + private SkillSuiteBundleDraftCreationService draftCreationService; + private SkillSuiteBundleOperationStateService stateService; + private SkillSuiteBundleCoordinator coordinator; + + @BeforeEach + void setUp() { + executionService = mock(SkillSuiteBundleMemberExecutionService.class); + progressService = mock(SkillSuiteBundleMemberProgressService.class); + draftCreationService = mock(SkillSuiteBundleDraftCreationService.class); + stateService = mock(SkillSuiteBundleOperationStateService.class); + coordinator = new SkillSuiteBundleCoordinator( + executionService, progressService, draftCreationService, stateService); + } + + @Test + void createsDraftOnlyAfterAllMemberWorkConverges() { + when(executionService.executeNext("operation")) + .thenReturn(SkillSuiteBundleMemberExecutionService.ExecutionOutcome.PROGRESSED) + .thenReturn(SkillSuiteBundleMemberExecutionService.ExecutionOutcome.NONE); + when(progressService.reconcile("operation")) + .thenReturn(SkillSuiteBundleMemberProgressService.ProgressOutcome.READY_FOR_DRAFT); + + coordinator.advance("operation"); + + verify(executionService, org.mockito.Mockito.times(2)).executeNext("operation"); + verify(draftCreationService).create("operation"); + verify(stateService, never()).markBlockedRetryable( + org.mockito.ArgumentMatchers.anyString(), org.mockito.ArgumentMatchers.anyString(), + org.mockito.ArgumentMatchers.any()); + } + + @Test + void domainStateDriftRequiresFreshPreviewAndDoesNotCreateDraft() { + when(executionService.executeNext("operation")) + .thenThrow(new DomainBadRequestException("error.suite.bundle.member.stateChanged")); + + coordinator.advance("operation"); + + verify(stateService).markRepreviewRequired("operation", "BUNDLE_PLAN_CHANGED"); + verify(draftCreationService, never()).create("operation"); + } + + @Test + void infrastructureFailureIsRetryableAndDoesNotCreateDraft() { + doThrow(new IllegalStateException("storage unavailable")) + .when(executionService).executeNext("operation"); + + coordinator.advance("operation"); + + verify(stateService).markBlockedRetryable( + "operation", "MEMBER_EXECUTION_FAILED", "IllegalStateException"); + verify(draftCreationService, never()).create("operation"); + } + + @Test + void frozenNamespaceKeepsTheOperationRetryable() { + when(executionService.executeNext("operation")) + .thenThrow(new DomainBadRequestException("error.namespace.frozen", "global")); + + coordinator.advance("operation"); + + verify(stateService).markBlockedRetryable( + "operation", "AUTHORIZATION_OR_NAMESPACE_BLOCKED", "error.namespace.frozen"); + verify(stateService, never()).markRepreviewRequired( + org.mockito.ArgumentMatchers.anyString(), org.mockito.ArgumentMatchers.anyString()); + } + + @Test + void revokedPermissionKeepsTheOperationRetryable() { + when(executionService.executeNext("operation")) + .thenThrow(new DomainForbiddenException("error.skill.lifecycle.noPermission")); + + coordinator.advance("operation"); + + verify(stateService).markBlockedRetryable( + "operation", "AUTHORIZATION_OR_NAMESPACE_BLOCKED", "error.skill.lifecycle.noPermission"); + verify(stateService, never()).markRepreviewRequired( + org.mockito.ArgumentMatchers.anyString(), org.mockito.ArgumentMatchers.anyString()); + } + + @Test + void removedNamespaceMemberKeepsTheOperationRetryable() { + when(executionService.executeNext("operation")) + .thenThrow(new DomainBadRequestException( + "error.skill.publish.publisher.notMember", "global")); + + coordinator.advance("operation"); + + verify(stateService).markBlockedRetryable( + "operation", "AUTHORIZATION_OR_NAMESPACE_BLOCKED", + "error.skill.publish.publisher.notMember"); + verify(stateService, never()).markRepreviewRequired( + org.mockito.ArgumentMatchers.anyString(), org.mockito.ArgumentMatchers.anyString()); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/bundle/SkillSuiteBundleDraftCreationServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/bundle/SkillSuiteBundleDraftCreationServiceTest.java new file mode 100644 index 00000000..dbbea763 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/bundle/SkillSuiteBundleDraftCreationServiceTest.java @@ -0,0 +1,147 @@ +package com.iflytek.skillhub.service.bundle; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.iflytek.skillhub.domain.skill.SkillVisibility; +import com.iflytek.skillhub.domain.suite.SkillSuite; +import com.iflytek.skillhub.domain.suite.SkillSuiteDraftService; +import com.iflytek.skillhub.domain.suite.SkillSuiteVersion; +import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleCoordinate; +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.SkillSuiteBundleMember; +import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleMemberResult; +import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleMemberResultRepository; +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.SkillSuiteBundlePreviewSession; +import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundlePreviewSessionRepository; +import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundlePublishAction; +import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleRelationshipChange; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class SkillSuiteBundleDraftCreationServiceTest { + + private static final Instant NOW = Instant.parse("2026-09-11T08:00:00Z"); + private SkillSuiteBundleExecutionOperationRepository operationRepository; + private SkillSuiteBundleMemberResultRepository memberRepository; + private SkillSuiteBundlePreviewSessionRepository previewRepository; + private SkillSuiteDraftService draftService; + private ObjectMapper objectMapper; + private SkillSuiteBundleDraftCreationService service; + + @BeforeEach + void setUp() { + operationRepository = mock(SkillSuiteBundleExecutionOperationRepository.class); + memberRepository = mock(SkillSuiteBundleMemberResultRepository.class); + previewRepository = mock(SkillSuiteBundlePreviewSessionRepository.class); + SkillSuiteBundleActorContextService actorContextService = mock(SkillSuiteBundleActorContextService.class); + draftService = mock(SkillSuiteDraftService.class); + objectMapper = mock(ObjectMapper.class); + when(actorContextService.requireCurrent("actor")).thenReturn( + new SkillSuiteBundleActorContextService.ActorContext(Map.of(), Set.of("SUPER_ADMIN"))); + service = new SkillSuiteBundleDraftCreationService( + operationRepository, memberRepository, previewRepository, actorContextService, + draftService, objectMapper, Clock.fixed(NOW, ZoneOffset.UTC)); + } + + @Test + void doesNotCreatePartialSuiteWhileAnyMemberIsUnfinished() { + when(operationRepository.findByIdForUpdate("operation")).thenReturn(Optional.of(operation())); + when(memberRepository.findByOperationIdOrderByPositionForUpdate("operation")) + .thenReturn(List.of(member(false))); + + assertThat(service.create("operation")).isFalse(); + + verify(draftService, never()).create(any(), any()); + verify(draftService, never()).createVersion(any(), any(), any()); + } + + @Test + void atomicallyRecordsTheDraftCreatedFromExactCompletedMemberIds() { + SkillSuiteBundleExecutionOperation operation = operation(); + SkillSuiteBundleMemberResult member = member(true); + SkillSuiteBundlePreviewSession preview = mock(SkillSuiteBundlePreviewSession.class); + Map manifestJson = Map.of("manifest", "value"); + when(preview.getManifest()).thenReturn(manifestJson); + when(operationRepository.findByIdForUpdate("operation")).thenReturn(Optional.of(operation)); + when(memberRepository.findByOperationIdOrderByPositionForUpdate("operation")) + .thenReturn(List.of(member)); + when(previewRepository.findById("preview")).thenReturn(Optional.of(preview)); + when(objectMapper.convertValue(manifestJson, SkillSuiteBundleManifest.class)).thenReturn(manifest()); + when(objectMapper.convertValue(operation.getPlan(), SkillSuiteBundlePreviewPlanner.PreviewPlan.class)) + .thenReturn(plan()); + SkillSuite suite = mock(SkillSuite.class); + SkillSuiteVersion version = mock(SkillSuiteVersion.class); + when(suite.getId()).thenReturn(21L); + when(version.getId()).thenReturn(22L); + when(draftService.create(any(), any())).thenReturn( + new SkillSuiteDraftService.CreatedDraft(suite, version, List.of())); + + assertThat(service.create("operation")).isTrue(); + + assertThat(operation.getStatus()).isEqualTo(SkillSuiteBundleOperationStatus.SUITE_DRAFT_CREATED); + assertThat(operation.getResultSuiteId()).isEqualTo(21L); + assertThat(operation.getResultSuiteVersionId()).isEqualTo(22L); + assertThat(operation.isReservationActive()).isFalse(); + verify(operationRepository).flush(); + } + + private SkillSuiteBundleExecutionOperation operation() { + Map planJson = Map.of("plan", "value"); + return new SkillSuiteBundleExecutionOperation( + "operation", "preview", "request", "actor", SkillSuiteBundleMode.CREATE, + 1L, "suite", null, null, "1.0.0", "archive", "a".repeat(64), + planJson, "digest", NOW.minusSeconds(1)); + } + + private SkillSuiteBundleMemberResult member(boolean completed) { + SkillSuiteBundleMemberResult member = new SkillSuiteBundleMemberResult( + "operation", 0, new SkillSuiteBundleCoordinate("global", "member"), + SkillSuiteBundleMemberSourceType.REFERENCE, null, SkillVisibility.PUBLIC, "1.0.0", + SkillSuiteBundleRelationshipChange.ADDED, SkillSuiteBundlePublishAction.REFERENCE_VERSION, + "sha256:fingerprint", 11L, 12L, List.of(), List.of(), NOW.minusSeconds(1)); + if (completed) { + member.start(NOW.minusMillis(500)); + member.markCompleted(NOW.minusMillis(400)); + } + return member; + } + + private SkillSuiteBundleManifest manifest() { + SkillSuiteBundleCoordinate suite = new SkillSuiteBundleCoordinate("global", "suite"); + SkillSuiteBundleCoordinate entry = new SkillSuiteBundleCoordinate("global", "member"); + return new SkillSuiteBundleManifest( + SkillSuiteBundleManifest.API_VERSION, SkillSuiteBundleManifest.KIND, + new SkillSuiteBundleManifest.Metadata(suite), + new SkillSuiteBundleManifest.Spec( + SkillSuiteBundleMode.CREATE, null, "1.0.0", "Suite", "Summary", + "Overview", SkillVisibility.PUBLIC, "Initial", entry, + List.of(new SkillSuiteBundleMember( + entry, null, new SkillSuiteBundleMember.ReferenceSource("1.0.0"))))); + } + + private SkillSuiteBundlePreviewPlanner.PreviewPlan plan() { + return new SkillSuiteBundlePreviewPlanner.PreviewPlan( + SkillSuiteBundleMode.CREATE, new SkillSuiteBundleCoordinate("global", "suite"), + 1L, null, null, "1.0.0", "Suite", "Summary", "Overview", + SkillVisibility.PUBLIC, List.of(), List.of(), List.of(), List.of(), "digest"); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/bundle/SkillSuiteBundleMemberExecutionServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/bundle/SkillSuiteBundleMemberExecutionServiceTest.java new file mode 100644 index 00000000..0d476a2f --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/bundle/SkillSuiteBundleMemberExecutionServiceTest.java @@ -0,0 +1,205 @@ +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.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.VisibilityChecker; +import com.iflytek.skillhub.domain.skill.service.SkillPublishService; +import com.iflytek.skillhub.domain.skill.service.SkillReviewSubmitService; +import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleCoordinate; +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.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 com.iflytek.skillhub.storage.ObjectStorageService; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayInputStream; +import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.atomic.AtomicReference; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class SkillSuiteBundleMemberExecutionServiceTest { + + @Test + void exactPublishedReferenceCompletesWithoutStorageOrPublishSideEffects() { + Instant now = Instant.parse("2026-09-11T08:00:00Z"); + SkillSuiteBundleExecutionOperationRepository operationRepository = + mock(SkillSuiteBundleExecutionOperationRepository.class); + SkillSuiteBundleMemberResultRepository memberRepository = mock(SkillSuiteBundleMemberResultRepository.class); + SkillSuiteBundleActorContextService actorContextService = mock(SkillSuiteBundleActorContextService.class); + NamespaceRepository namespaceRepository = mock(NamespaceRepository.class); + SkillRepository skillRepository = mock(SkillRepository.class); + SkillVersionRepository versionRepository = mock(SkillVersionRepository.class); + VisibilityChecker visibilityChecker = mock(VisibilityChecker.class); + SkillPublishService publishService = mock(SkillPublishService.class); + SkillReviewSubmitService reviewSubmitService = mock(SkillReviewSubmitService.class); + ObjectStorageService storage = mock(ObjectStorageService.class); + ObjectMapper objectMapper = mock(ObjectMapper.class); + SkillSuiteBundleExecutionOperation operation = new SkillSuiteBundleExecutionOperation( + "operation", "preview", "request", "actor", SkillSuiteBundleMode.CREATE, + 1L, "suite", null, null, "1.0.0", "archive", "a".repeat(64), + Map.of("plan", "value"), "digest", now.minusSeconds(1)); + SkillSuiteBundleMemberResult member = new SkillSuiteBundleMemberResult( + "operation", 0, new SkillSuiteBundleCoordinate("global", "member"), + SkillSuiteBundleMemberSourceType.REFERENCE, null, SkillVisibility.PUBLIC, "1.0.0", + SkillSuiteBundleRelationshipChange.ADDED, SkillSuiteBundlePublishAction.REFERENCE_VERSION, + "sha256:fingerprint", 11L, 12L, List.of(), List.of(), now.minusSeconds(1)); + SkillSuiteBundlePreviewPlanner.MemberPlan memberPlan = new SkillSuiteBundlePreviewPlanner.MemberPlan( + new SkillSuiteBundleCoordinate("global", "member"), SkillSuiteBundleMemberSourceType.REFERENCE, + SkillSuiteBundleRelationshipChange.ADDED, SkillSuiteBundlePublishAction.REFERENCE_VERSION, + 11L, 12L, SkillVisibility.PUBLIC, "1.0.0", "sha256:fingerprint", + List.of(), List.of(), List.of()); + SkillSuiteBundlePreviewPlanner.PreviewPlan plan = new SkillSuiteBundlePreviewPlanner.PreviewPlan( + SkillSuiteBundleMode.CREATE, new SkillSuiteBundleCoordinate("global", "suite"), + 1L, null, null, "1.0.0", "Suite", "Summary", "Overview", + SkillVisibility.PUBLIC, List.of(memberPlan), List.of(), List.of(), List.of(), "digest"); + Skill skill = mock(Skill.class); + SkillVersion version = mock(SkillVersion.class); + Namespace namespace = mock(Namespace.class); + when(operationRepository.findByIdForUpdate("operation")).thenReturn(Optional.of(operation)); + when(memberRepository.findByOperationIdOrderByPositionForUpdate("operation")).thenReturn(List.of(member)); + when(actorContextService.requireCurrent("actor")).thenReturn( + new SkillSuiteBundleActorContextService.ActorContext(Map.of(), Set.of())); + when(objectMapper.convertValue(operation.getPlan(), SkillSuiteBundlePreviewPlanner.PreviewPlan.class)) + .thenReturn(plan); + when(skillRepository.findById(11L)).thenReturn(Optional.of(skill)); + when(versionRepository.findById(12L)).thenReturn(Optional.of(version)); + when(namespaceRepository.findBySlug("global")).thenReturn(Optional.of(namespace)); + when(namespace.getId()).thenReturn(1L); + when(namespace.getStatus()).thenReturn(NamespaceStatus.ACTIVE); + when(skill.getId()).thenReturn(11L); + when(skill.getNamespaceId()).thenReturn(1L); + when(skill.getSlug()).thenReturn("member"); + when(skill.getStatus()).thenReturn(SkillStatus.ACTIVE); + when(skill.getVisibility()).thenReturn(SkillVisibility.PUBLIC); + when(version.getSkillId()).thenReturn(11L); + when(version.getVersion()).thenReturn("1.0.0"); + when(version.getStatus()).thenReturn(SkillVersionStatus.PUBLISHED); + when(version.isDownloadReady()).thenReturn(true); + when(visibilityChecker.canAccess(skill, "actor", Map.of(), Set.of())).thenReturn(true); + SkillSuiteBundleMemberExecutionService service = new SkillSuiteBundleMemberExecutionService( + operationRepository, memberRepository, actorContextService, namespaceRepository, + skillRepository, versionRepository, visibilityChecker, publishService, + reviewSubmitService, storage, objectMapper, Clock.fixed(now, ZoneOffset.UTC)); + + assertThat(service.executeNext("operation")) + .isEqualTo(SkillSuiteBundleMemberExecutionService.ExecutionOutcome.PROGRESSED); + + assertThat(member.getStatus()).isEqualTo(SkillSuiteBundleMemberResultStatus.COMPLETED); + verify(publishService, never()).publishBundleMemberFromEntries( + org.mockito.ArgumentMatchers.anyString(), org.mockito.ArgumentMatchers.any(), + org.mockito.ArgumentMatchers.anyString(), org.mockito.ArgumentMatchers.anyString(), + anyList(), org.mockito.ArgumentMatchers.anyMap(), org.mockito.ArgumentMatchers.anyString(), + org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.anyMap(), + org.mockito.ArgumentMatchers.anySet(), org.mockito.ArgumentMatchers.anyBoolean()); + verify(storage, never()).getObject(org.mockito.ArgumentMatchers.anyString()); + } + + @Test + void packagedMemberDownloadsEachObjectOnceAndRemovesLocalSnapshot() throws Exception { + Instant now = Instant.parse("2026-09-11T08:00:00Z"); + byte[] content = "---\nname: member\ndescription: test\nversion: 1.0.0\n---\n" + .getBytes(StandardCharsets.UTF_8); + var file = new SkillSuiteBundlePackageAnalyzer.StagedMemberFile( + "SKILL.md", content.length, "text/markdown", "a".repeat(64), "staged/key"); + SkillSuiteBundleExecutionOperationRepository operationRepository = + mock(SkillSuiteBundleExecutionOperationRepository.class); + SkillSuiteBundleMemberResultRepository memberRepository = mock(SkillSuiteBundleMemberResultRepository.class); + SkillSuiteBundleActorContextService actorContextService = mock(SkillSuiteBundleActorContextService.class); + NamespaceRepository namespaceRepository = mock(NamespaceRepository.class); + SkillRepository skillRepository = mock(SkillRepository.class); + SkillVersionRepository versionRepository = mock(SkillVersionRepository.class); + VisibilityChecker visibilityChecker = mock(VisibilityChecker.class); + SkillPublishService publishService = mock(SkillPublishService.class); + SkillReviewSubmitService reviewSubmitService = mock(SkillReviewSubmitService.class); + ObjectStorageService storage = mock(ObjectStorageService.class); + ObjectMapper objectMapper = mock(ObjectMapper.class); + SkillSuiteBundleExecutionOperation operation = new SkillSuiteBundleExecutionOperation( + "operation", "preview", "request", "actor", SkillSuiteBundleMode.CREATE, + 1L, "suite", null, null, "1.0.0", "archive", "a".repeat(64), + Map.of("plan", "value"), "digest", now.minusSeconds(1)); + SkillSuiteBundleCoordinate coordinate = new SkillSuiteBundleCoordinate("global", "member"); + SkillSuiteBundleMemberResult member = new SkillSuiteBundleMemberResult( + "operation", 0, coordinate, SkillSuiteBundleMemberSourceType.PACKAGE, "members/member", + SkillVisibility.PUBLIC, "1.0.0", SkillSuiteBundleRelationshipChange.ADDED, + SkillSuiteBundlePublishAction.CREATE_SKILL, "sha256:fingerprint", + null, null, List.of(), List.of(), now.minusSeconds(1)); + SkillSuiteBundlePreviewPlanner.MemberPlan memberPlan = new SkillSuiteBundlePreviewPlanner.MemberPlan( + coordinate, SkillSuiteBundleMemberSourceType.PACKAGE, + SkillSuiteBundleRelationshipChange.ADDED, SkillSuiteBundlePublishAction.CREATE_SKILL, + null, null, SkillVisibility.PUBLIC, "1.0.0", "sha256:fingerprint", + List.of(file), List.of(), List.of()); + SkillSuiteBundlePreviewPlanner.PreviewPlan plan = new SkillSuiteBundlePreviewPlanner.PreviewPlan( + SkillSuiteBundleMode.CREATE, new SkillSuiteBundleCoordinate("global", "suite"), + 1L, null, null, "1.0.0", "Suite", "Summary", "Overview", + SkillVisibility.PUBLIC, List.of(memberPlan), List.of(), List.of(), List.of(), "digest"); + SkillVersion publishedVersion = mock(SkillVersion.class); + AtomicReference> capturedEntries = + new AtomicReference<>(); + + when(operationRepository.findByIdForUpdate("operation")).thenReturn(Optional.of(operation)); + when(memberRepository.findByOperationIdOrderByPositionForUpdate("operation")).thenReturn(List.of(member)); + when(actorContextService.requireCurrent("actor")).thenReturn( + new SkillSuiteBundleActorContextService.ActorContext(Map.of(), Set.of())); + when(objectMapper.convertValue(operation.getPlan(), SkillSuiteBundlePreviewPlanner.PreviewPlan.class)) + .thenReturn(plan); + when(storage.getObject("staged/key")).thenReturn(new ByteArrayInputStream(content)); + when(publishedVersion.getId()).thenReturn(12L); + when(publishedVersion.getStatus()).thenReturn(SkillVersionStatus.PUBLISHED); + when(publishService.publishBundleMemberFromEntries( + org.mockito.ArgumentMatchers.anyString(), org.mockito.ArgumentMatchers.nullable(Long.class), + org.mockito.ArgumentMatchers.anyString(), org.mockito.ArgumentMatchers.anyString(), + org.mockito.ArgumentMatchers.anyList(), org.mockito.ArgumentMatchers.anyMap(), + org.mockito.ArgumentMatchers.anyString(), org.mockito.ArgumentMatchers.any(), + org.mockito.ArgumentMatchers.anyMap(), org.mockito.ArgumentMatchers.anySet(), + org.mockito.ArgumentMatchers.anyBoolean())).thenAnswer(invocation -> { + List entries = invocation.getArgument(4); + capturedEntries.set(entries); + assertThat(entries.getFirst().content()).isEqualTo(content); + assertThat(entries.getFirst().content()).isEqualTo(content); + return new SkillPublishService.PublishResult(11L, "member", publishedVersion); + }); + SkillSuiteBundleMemberExecutionService service = new SkillSuiteBundleMemberExecutionService( + operationRepository, memberRepository, actorContextService, namespaceRepository, + skillRepository, versionRepository, visibilityChecker, publishService, + reviewSubmitService, storage, objectMapper, Clock.fixed(now, ZoneOffset.UTC)); + + assertThat(service.executeNext("operation")) + .isEqualTo(SkillSuiteBundleMemberExecutionService.ExecutionOutcome.PROGRESSED); + + verify(storage, times(1)).getObject("staged/key"); + assertThat(member.getStatus()).isEqualTo(SkillSuiteBundleMemberResultStatus.COMPLETED); + assertThatThrownBy(() -> capturedEntries.get().getFirst().content()) + .isInstanceOf(UncheckedIOException.class); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/bundle/SkillSuiteBundleMemberProgressServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/bundle/SkillSuiteBundleMemberProgressServiceTest.java new file mode 100644 index 00000000..b04175a6 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/bundle/SkillSuiteBundleMemberProgressServiceTest.java @@ -0,0 +1,182 @@ +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.NamespaceStatus; +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.SkillSuiteBundleCoordinate; +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.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 org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class SkillSuiteBundleMemberProgressServiceTest { + + private static final Instant NOW = Instant.parse("2026-09-11T08:00:00Z"); + private SkillSuiteBundleExecutionOperationRepository operationRepository; + private SkillSuiteBundleMemberResultRepository memberRepository; + private SkillRepository skillRepository; + private SkillVersionRepository versionRepository; + private SkillReviewSubmitService reviewSubmitService; + private SkillSuiteBundleMemberProgressService service; + + @BeforeEach + void setUp() { + operationRepository = mock(SkillSuiteBundleExecutionOperationRepository.class); + memberRepository = mock(SkillSuiteBundleMemberResultRepository.class); + SkillSuiteBundleActorContextService actorContextService = mock(SkillSuiteBundleActorContextService.class); + NamespaceRepository namespaceRepository = mock(NamespaceRepository.class); + skillRepository = mock(SkillRepository.class); + versionRepository = mock(SkillVersionRepository.class); + reviewSubmitService = mock(SkillReviewSubmitService.class); + when(actorContextService.requireCurrent("actor")).thenReturn( + new SkillSuiteBundleActorContextService.ActorContext(Map.of(), Set.of())); + + Namespace namespace = mock(Namespace.class); + when(namespace.getId()).thenReturn(1L); + when(namespace.getStatus()).thenReturn(NamespaceStatus.ACTIVE); + when(namespaceRepository.findBySlug("global")).thenReturn(Optional.of(namespace)); + Skill skill = mock(Skill.class); + when(skill.getId()).thenReturn(11L); + when(skill.getNamespaceId()).thenReturn(1L); + when(skill.getSlug()).thenReturn("member"); + when(skill.getOwnerId()).thenReturn("actor"); + when(skill.getStatus()).thenReturn(SkillStatus.ACTIVE); + when(skill.getVisibility()).thenReturn(SkillVisibility.PUBLIC); + when(skillRepository.findById(11L)).thenReturn(Optional.of(skill)); + + service = new SkillSuiteBundleMemberProgressService( + operationRepository, memberRepository, actorContextService, namespaceRepository, + skillRepository, versionRepository, reviewSubmitService, + Clock.fixed(NOW, ZoneOffset.UTC)); + } + + @Test + void publishedMemberCompletesAndOperationBecomesReadyForDraft() { + Fixture fixture = fixture(SkillVersionStatus.PUBLISHED, SkillVisibility.PUBLIC); + when(fixture.version().isDownloadReady()).thenReturn(true); + + assertThat(service.reconcile("operation")) + .isEqualTo(SkillSuiteBundleMemberProgressService.ProgressOutcome.READY_FOR_DRAFT); + + assertThat(fixture.member().getStatus()).isEqualTo(SkillSuiteBundleMemberResultStatus.COMPLETED); + assertThat(fixture.operation().getStatus()).isEqualTo(SkillSuiteBundleOperationStatus.RUNNING); + } + + @Test + void scanFailureBlocksTheSameBoundVersionForRetry() { + Fixture fixture = fixture(SkillVersionStatus.SCAN_FAILED, SkillVisibility.PUBLIC); + + assertThat(service.reconcile("operation")) + .isEqualTo(SkillSuiteBundleMemberProgressService.ProgressOutcome.TERMINAL); + + assertThat(fixture.member().getStatus()) + .isEqualTo(SkillSuiteBundleMemberResultStatus.BLOCKED_RETRYABLE); + assertThat(fixture.member().getSkillVersionId()).isEqualTo(12L); + assertThat(fixture.operation().getFailureCode()).isEqualTo("MEMBER_SCAN_FAILED"); + } + + @Test + void privateUploadedMemberUsesExistingConfirmationBoundary() { + Fixture fixture = fixture(SkillVersionStatus.UPLOADED, SkillVisibility.PRIVATE); + + assertThat(service.reconcile("operation")) + .isEqualTo(SkillSuiteBundleMemberProgressService.ProgressOutcome.READY_FOR_DRAFT); + + verify(reviewSubmitService).confirmPublish(11L, 12L, "actor", Map.of(), Set.of()); + assertThat(fixture.member().getStatus()).isEqualTo(SkillSuiteBundleMemberResultStatus.COMPLETED); + } + + @Test + void pendingReviewKeepsTheDurableOperationWaiting() { + Fixture fixture = fixture(SkillVersionStatus.PENDING_REVIEW, SkillVisibility.PUBLIC); + + assertThat(service.reconcile("operation")) + .isEqualTo(SkillSuiteBundleMemberProgressService.ProgressOutcome.WAITING); + + assertThat(fixture.member().getStatus()) + .isEqualTo(SkillSuiteBundleMemberResultStatus.WAITING_FOR_MEMBER); + assertThat(fixture.operation().getStatus()) + .isEqualTo(SkillSuiteBundleOperationStatus.WAITING_FOR_MEMBERS); + } + + @Test + void rejectedMemberRequiresAWholeNewPreview() { + fixture(SkillVersionStatus.REJECTED, SkillVisibility.PUBLIC); + + assertThatThrownBy(() -> service.reconcile("operation")) + .isInstanceOf(com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException.class) + .extracting("messageCode") + .isEqualTo("error.suite.bundle.member.stateChanged"); + } + + private Fixture fixture(SkillVersionStatus status, SkillVisibility visibility) { + SkillSuiteBundleExecutionOperation operation = new SkillSuiteBundleExecutionOperation( + "operation", "preview", "request", "actor", SkillSuiteBundleMode.CREATE, + 1L, "suite", null, null, "1.0.0", "archive", "a".repeat(64), + Map.of(), "digest", NOW.minusSeconds(1)); + SkillSuiteBundleMemberResult member = new SkillSuiteBundleMemberResult( + "operation", 0, new SkillSuiteBundleCoordinate("global", "member"), + SkillSuiteBundleMemberSourceType.PACKAGE, "skills/member", visibility, "1.0.0", + SkillSuiteBundleRelationshipChange.ADDED, SkillSuiteBundlePublishAction.CREATE_SKILL, + "sha256:fingerprint", null, null, List.of(), List.of(), NOW.minusSeconds(1)); + member.start(NOW.minusMillis(500)); + member.bindVersion(11L, 12L, NOW.minusMillis(400)); + member.markWaiting(NOW.minusMillis(300)); + SkillVersion version = mock(SkillVersion.class); + when(version.getId()).thenReturn(12L); + when(version.getSkillId()).thenReturn(11L); + when(version.getVersion()).thenReturn("1.0.0"); + when(version.getStatus()).thenReturn(status); + when(versionRepository.findById(12L)).thenReturn(Optional.of(version)); + Skill skill = mock(Skill.class); + when(skill.getId()).thenReturn(11L); + when(skill.getNamespaceId()).thenReturn(1L); + when(skill.getSlug()).thenReturn("member"); + when(skill.getOwnerId()).thenReturn("actor"); + when(skill.getStatus()).thenReturn(SkillStatus.ACTIVE); + when(skill.getVisibility()).thenReturn(visibility); + // Replace the default public mock for the requested visibility. + when(skillRepository.findById(11L)).thenReturn(Optional.of(skill)); + when(operationRepository.findByIdForUpdate("operation")).thenReturn(Optional.of(operation)); + when(memberRepository.findByOperationIdOrderByPositionForUpdate("operation")) + .thenReturn(List.of(member)); + return new Fixture(operation, member, version); + } + + private record Fixture( + SkillSuiteBundleExecutionOperation operation, + SkillSuiteBundleMemberResult member, + SkillVersion version + ) { + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/bundle/SkillSuiteBundleOperationCommandServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/bundle/SkillSuiteBundleOperationCommandServiceTest.java new file mode 100644 index 00000000..b9f095a1 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/bundle/SkillSuiteBundleOperationCommandServiceTest.java @@ -0,0 +1,249 @@ +package com.iflytek.skillhub.service.bundle; + +import com.iflytek.skillhub.domain.namespace.NamespaceRole; +import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; +import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException; +import com.iflytek.skillhub.domain.skill.SkillVisibility; +import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleCoordinate; +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.SkillSuiteBundleMemberSourceType; +import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleMode; +import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundlePreviewSession; +import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundlePreviewSessionRepository; +import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundlePublishAction; +import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleRelationshipChange; +import com.iflytek.skillhub.service.SecurityScanRetryAppService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class SkillSuiteBundleOperationCommandServiceTest { + + private static final Instant NOW = Instant.parse("2026-09-11T08:00:00Z"); + + private SkillSuiteBundleExecutionOperationRepository operationRepository; + private SkillSuiteBundleMemberResultRepository memberRepository; + private SkillSuiteBundlePreviewSessionRepository previewRepository; + private SkillSuiteBundlePreviewRevalidationService revalidationService; + private SecurityScanRetryAppService securityScanRetryAppService; + private SkillSuiteBundleOperationCommandService service; + + @BeforeEach + void setUp() { + operationRepository = mock(SkillSuiteBundleExecutionOperationRepository.class); + memberRepository = mock(SkillSuiteBundleMemberResultRepository.class); + previewRepository = mock(SkillSuiteBundlePreviewSessionRepository.class); + revalidationService = mock(SkillSuiteBundlePreviewRevalidationService.class); + securityScanRetryAppService = mock(SecurityScanRetryAppService.class); + service = new SkillSuiteBundleOperationCommandService( + operationRepository, memberRepository, previewRepository, revalidationService, + securityScanRetryAppService, + mock(org.springframework.context.ApplicationEventPublisher.class), + Clock.fixed(NOW, ZoneOffset.UTC)); + } + + @Test + void originalActorCancelsOperationAndOnlyUnfinishedMembers() { + SkillSuiteBundleExecutionOperation operation = operation(); + SkillSuiteBundleMemberResult planned = member("planned"); + SkillSuiteBundleMemberResult completed = member("completed"); + setStatus(completed, SkillSuiteBundleMemberResultStatus.COMPLETED); + when(operationRepository.findByIdForUpdate("operation-1")).thenReturn(Optional.of(operation)); + when(memberRepository.findByOperationIdOrderByPositionForUpdate("operation-1")) + .thenReturn(List.of(planned, completed)); + + var response = service.cancel("operation-1", "actor", Map.of(), Set.of()); + + assertThat(response.status()).isEqualTo("CANCELLED"); + assertThat(response.replayed()).isFalse(); + assertThat(operation.isReservationActive()).isFalse(); + assertThat(planned.getStatus()).isEqualTo(SkillSuiteBundleMemberResultStatus.CANCELLED); + assertThat(completed.getStatus()).isEqualTo(SkillSuiteBundleMemberResultStatus.COMPLETED); + verify(operationRepository).flush(); + verify(memberRepository).saveAll(List.of(planned, completed)); + } + + @Test + void repeatedCancelIsIdempotentAndUnrelatedCallerSeesNotFound() { + SkillSuiteBundleExecutionOperation operation = operation(); + operation.cancel(NOW.minusSeconds(1)); + when(operationRepository.findByIdForUpdate("operation-1")).thenReturn(Optional.of(operation)); + + var replay = service.cancel("operation-1", "actor", Map.of(), Set.of()); + assertThat(replay.replayed()).isTrue(); + verify(memberRepository, never()).saveAll(org.mockito.ArgumentMatchers.anyList()); + + assertThatThrownBy(() -> service.cancel( + "operation-1", "unrelated", Map.of(), Set.of())) + .isInstanceOf(DomainNotFoundException.class) + .extracting("messageCode") + .isEqualTo("error.suite.bundle.operation.notFound"); + } + + @Test + void namespaceAdminMayCancelAnOperationForGovernance() { + when(operationRepository.findByIdForUpdate("operation-1")) + .thenReturn(Optional.of(operation())); + when(memberRepository.findByOperationIdOrderByPositionForUpdate("operation-1")) + .thenReturn(List.of()); + + assertThat(service.cancel( + "operation-1", "admin", Map.of(1L, NamespaceRole.ADMIN), Set.of()).status()) + .isEqualTo("CANCELLED"); + } + + @Test + void retriesOnlyUnfinishedWorkOnTheOriginalOperationId() { + SkillSuiteBundleExecutionOperation operation = operation(); + operation.markBlockedRetryable("TEMPORARY", "try later", NOW.minusSeconds(1)); + SkillSuiteBundleMemberResult blocked = member("blocked"); + setStatus(blocked, SkillSuiteBundleMemberResultStatus.BLOCKED_RETRYABLE); + SkillSuiteBundleMemberResult completed = member("completed"); + setStatus(completed, SkillSuiteBundleMemberResultStatus.COMPLETED); + SkillSuiteBundlePreviewSession preview = mock(SkillSuiteBundlePreviewSession.class); + when(operationRepository.findByIdForUpdate("operation-1")).thenReturn(Optional.of(operation)); + when(previewRepository.findById("preview-1")).thenReturn(Optional.of(preview)); + when(memberRepository.findByOperationIdOrderByPositionForUpdate("operation-1")) + .thenReturn(List.of(blocked, completed)); + + var response = service.retry("operation-1", "actor", Map.of(), Set.of()); + + assertThat(response.operationId()).isEqualTo("operation-1"); + assertThat(response.status()).isEqualTo("RUNNING"); + assertThat(operation.isReservationActive()).isTrue(); + assertThat(operation.getFailureCode()).isNull(); + assertThat(blocked.getStatus()).isEqualTo(SkillSuiteBundleMemberResultStatus.PLANNED); + assertThat(completed.getStatus()).isEqualTo(SkillSuiteBundleMemberResultStatus.COMPLETED); + verify(revalidationService).requireUnchanged(preview, "actor", Map.of(), Set.of()); + } + + @Test + void retryMovesToRepreviewRequiredWhenTheBoundPlanChanged() { + SkillSuiteBundleExecutionOperation operation = operation(); + operation.markBlockedRetryable("TEMPORARY", null, NOW.minusSeconds(1)); + SkillSuiteBundleMemberResult blocked = member("blocked"); + setStatus(blocked, SkillSuiteBundleMemberResultStatus.BLOCKED_RETRYABLE); + SkillSuiteBundlePreviewSession preview = mock(SkillSuiteBundlePreviewSession.class); + when(operationRepository.findByIdForUpdate("operation-1")).thenReturn(Optional.of(operation)); + when(previewRepository.findById("preview-1")).thenReturn(Optional.of(preview)); + when(memberRepository.findByOperationIdOrderByPositionForUpdate("operation-1")) + .thenReturn(List.of(blocked)); + when(revalidationService.requireUnchanged(preview, "actor", Map.of(), Set.of())) + .thenThrow(new DomainBadRequestException("error.suite.bundle.preview.stateChanged")); + + var response = service.retry("operation-1", "actor", Map.of(), Set.of()); + + assertThat(response.status()).isEqualTo("REPREVIEW_REQUIRED"); + assertThat(operation.isReservationActive()).isFalse(); + assertThat(blocked.getStatus()).isEqualTo(SkillSuiteBundleMemberResultStatus.REPREVIEW_REQUIRED); + } + + @Test + void retriesFailedScanOnTheOriginalVersionWithoutReplanningOrRepublishing() { + SkillSuiteBundleExecutionOperation operation = operation(); + operation.markBlockedRetryable("MEMBER_SCAN_FAILED", null, NOW.minusSeconds(1)); + SkillSuiteBundleMemberResult blocked = member("blocked"); + setStatus(blocked, SkillSuiteBundleMemberResultStatus.BLOCKED_RETRYABLE); + org.springframework.test.util.ReflectionTestUtils.setField(blocked, "skillId", 41L); + org.springframework.test.util.ReflectionTestUtils.setField(blocked, "skillVersionId", 42L); + when(operationRepository.findByIdForUpdate("operation-1")).thenReturn(Optional.of(operation)); + when(previewRepository.findById("preview-1")).thenReturn(Optional.of(mock(SkillSuiteBundlePreviewSession.class))); + when(memberRepository.findByOperationIdOrderByPositionForUpdate("operation-1")) + .thenReturn(List.of(blocked)); + + var response = service.retry( + "operation-1", "actor", Map.of(1L, NamespaceRole.ADMIN), Set.of("SKILL_ADMIN")); + + assertThat(response.status()).isEqualTo("RUNNING"); + assertThat(blocked.getStatus()).isEqualTo(SkillSuiteBundleMemberResultStatus.WAITING_FOR_MEMBER); + verify(securityScanRetryAppService).retry( + org.mockito.ArgumentMatchers.eq(41L), org.mockito.ArgumentMatchers.eq(42L), + org.mockito.ArgumentMatchers.eq("actor"), + org.mockito.ArgumentMatchers.eq(Set.of("SKILL_ADMIN")), + org.mockito.ArgumentMatchers.eq(Map.of(1L, NamespaceRole.ADMIN)), + org.mockito.ArgumentMatchers.any()); + verify(revalidationService, never()).requireUnchanged( + org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.anyString(), + org.mockito.ArgumentMatchers.anyMap(), org.mockito.ArgumentMatchers.anySet()); + } + + @Test + void progressedOperationRetriesOnlyUnboundWorkWithoutComparingAgainstObsoletePreviewState() { + SkillSuiteBundleExecutionOperation operation = operation(); + operation.markBlockedRetryable("MEMBER_EXECUTION_FAILED", null, NOW.minusSeconds(1)); + SkillSuiteBundleMemberResult completed = member("completed"); + org.springframework.test.util.ReflectionTestUtils.setField(completed, "skillId", 11L); + org.springframework.test.util.ReflectionTestUtils.setField(completed, "skillVersionId", 12L); + setStatus(completed, SkillSuiteBundleMemberResultStatus.COMPLETED); + SkillSuiteBundleMemberResult blocked = member("blocked"); + setStatus(blocked, SkillSuiteBundleMemberResultStatus.BLOCKED_RETRYABLE); + when(operationRepository.findByIdForUpdate("operation-1")).thenReturn(Optional.of(operation)); + when(previewRepository.findById("preview-1")).thenReturn(Optional.of(mock(SkillSuiteBundlePreviewSession.class))); + when(memberRepository.findByOperationIdOrderByPositionForUpdate("operation-1")) + .thenReturn(List.of(completed, blocked)); + + assertThat(service.retry("operation-1", "actor", Map.of(), Set.of()).status()).isEqualTo("RUNNING"); + + assertThat(completed.getStatus()).isEqualTo(SkillSuiteBundleMemberResultStatus.COMPLETED); + assertThat(blocked.getStatus()).isEqualTo(SkillSuiteBundleMemberResultStatus.PLANNED); + verify(revalidationService, never()).requireUnchanged( + org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.anyString(), + org.mockito.ArgumentMatchers.anyMap(), org.mockito.ArgumentMatchers.anySet()); + } + + @Test + void retryRejectsNonRetryableOrUnrelatedOperations() { + when(operationRepository.findByIdForUpdate("operation-1")).thenReturn(Optional.of(operation())); + + assertThatThrownBy(() -> service.retry("operation-1", "actor", Map.of(), Set.of())) + .isInstanceOf(DomainBadRequestException.class) + .extracting("messageCode") + .isEqualTo("error.suite.bundle.operation.retry.notAllowed"); + + assertThatThrownBy(() -> service.retry("operation-1", "unrelated", Map.of(), Set.of())) + .isInstanceOf(DomainNotFoundException.class) + .extracting("messageCode") + .isEqualTo("error.suite.bundle.operation.notFound"); + } + + private SkillSuiteBundleExecutionOperation operation() { + return new SkillSuiteBundleExecutionOperation( + "operation-1", "preview-1", "request-1", "actor", SkillSuiteBundleMode.CREATE, + 1L, "suite", null, null, "1.0.0", "archive.zip", "a".repeat(64), + Map.of("plan", "value"), "warning-digest", NOW.minusSeconds(60)); + } + + private SkillSuiteBundleMemberResult member(String slug) { + return new SkillSuiteBundleMemberResult( + "operation-1", 0, new SkillSuiteBundleCoordinate("global", slug), + SkillSuiteBundleMemberSourceType.PACKAGE, "skills/" + slug, SkillVisibility.PUBLIC, + "1.0.0", SkillSuiteBundleRelationshipChange.ADDED, + SkillSuiteBundlePublishAction.CREATE_SKILL, "sha256:" + slug, null, null, + List.of(), List.of(), NOW.minusSeconds(60)); + } + + private void setStatus( + SkillSuiteBundleMemberResult member, + SkillSuiteBundleMemberResultStatus status + ) { + org.springframework.test.util.ReflectionTestUtils.setField(member, "status", status); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/bundle/SkillSuiteBundleOperationQueryServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/bundle/SkillSuiteBundleOperationQueryServiceTest.java new file mode 100644 index 00000000..4e71a839 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/bundle/SkillSuiteBundleOperationQueryServiceTest.java @@ -0,0 +1,235 @@ +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.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.SkillSuiteVersion; +import com.iflytek.skillhub.domain.suite.SkillSuiteVersionRepository; +import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleCoordinate; +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.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 com.iflytek.skillhub.dto.PageResponse; +import com.iflytek.skillhub.dto.SkillSuiteBundleOperationPageResponse; +import com.iflytek.skillhub.dto.SkillSuiteBundleOperationSummaryResponse; +import com.iflytek.skillhub.repository.SkillSuiteBundleOperationQueryRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +class SkillSuiteBundleOperationQueryServiceTest { + + private static final Instant NOW = Instant.parse("2026-09-11T08:00:00Z"); + + private SkillSuiteBundleExecutionOperationRepository operationRepository; + private SkillSuiteBundleMemberResultRepository memberRepository; + private NamespaceRepository namespaceRepository; + private SkillRepository skillRepository; + private SkillSuiteVersionRepository suiteVersionRepository; + private SkillSuiteBundleOperationQueryRepository operationQueryRepository; + private SkillSuiteBundleOperationQueryService service; + + @BeforeEach + void setUp() { + operationRepository = mock(SkillSuiteBundleExecutionOperationRepository.class); + memberRepository = mock(SkillSuiteBundleMemberResultRepository.class); + namespaceRepository = mock(NamespaceRepository.class); + skillRepository = mock(SkillRepository.class); + suiteVersionRepository = mock(SkillSuiteVersionRepository.class); + operationQueryRepository = mock(SkillSuiteBundleOperationQueryRepository.class); + service = new SkillSuiteBundleOperationQueryService( + operationRepository, memberRepository, namespaceRepository, + skillRepository, suiteVersionRepository, new VisibilityChecker(), operationQueryRepository); + } + + @Test + void ownerReceivesRedactedOperationAndMemberProgress() { + SkillSuiteBundleExecutionOperation operation = operation(); + Namespace namespace = mock(Namespace.class); + when(namespace.getSlug()).thenReturn("global"); + when(namespace.getId()).thenReturn(1L); + when(operationRepository.findById("operation-1")).thenReturn(Optional.of(operation)); + when(namespaceRepository.findById(1L)).thenReturn(Optional.of(namespace)); + when(namespaceRepository.findBySlugIn(List.of("global"))).thenReturn(List.of(namespace)); + when(memberRepository.findByOperationIdOrderByPosition("operation-1")) + .thenReturn(List.of(member())); + Skill skill = skill(10L, 1L, "other", SkillVisibility.PUBLIC); + when(skillRepository.findByIdIn(List.of(10L))).thenReturn(List.of(skill)); + + var response = service.get("operation-1", "actor", Map.of(), Set.of()); + + assertThat(response.targetCoordinate()).isEqualTo("@global/suite"); + assertThat(response.targetNamespaceId()).isEqualTo(1L); + assertThat(response.members()).singleElement().satisfies(member -> { + assertThat(member.coordinate()).isEqualTo("@global/member"); + assertThat(member.packagePath()).isEqualTo("members/member"); + assertThat(member.version()).isEqualTo("1.0.0"); + assertThat(member.redacted()).isFalse(); + }); + assertThat(response.toString()) + .doesNotContain("actor") + .doesNotContain("request-1") + .doesNotContain("temporary/archive.zip") + .doesNotContain("internal-plan-value"); + } + + @Test + void namespaceAdminCanReadButUnrelatedCallerCannotProbeOperationId() { + when(operationRepository.findById("operation-1")).thenReturn(Optional.of(operation())); + Namespace namespace = mock(Namespace.class); + when(namespace.getSlug()).thenReturn("global"); + when(namespaceRepository.findById(1L)).thenReturn(Optional.of(namespace)); + + assertThat(service.get( + "operation-1", "admin", Map.of(1L, NamespaceRole.ADMIN), Set.of())).isNotNull(); + + assertThatThrownBy(() -> service.get( + "operation-1", "unrelated", Map.of(), Set.of())) + .isInstanceOf(DomainNotFoundException.class) + .extracting("messageCode") + .isEqualTo("error.suite.bundle.operation.notFound"); + verify(memberRepository, times(1)).findByOperationIdOrderByPosition("operation-1"); + } + + @Test + void redactsPrivateMemberMetadataWhenOperationOwnerLostMemberAccess() { + Namespace targetNamespace = mock(Namespace.class); + when(targetNamespace.getSlug()).thenReturn("global"); + Namespace privateNamespace = mock(Namespace.class); + when(privateNamespace.getSlug()).thenReturn("private-team"); + when(privateNamespace.getId()).thenReturn(2L); + when(operationRepository.findById("operation-1")).thenReturn(Optional.of(operation())); + when(namespaceRepository.findById(1L)).thenReturn(Optional.of(targetNamespace)); + when(namespaceRepository.findBySlugIn(List.of("private-team"))) + .thenReturn(List.of(privateNamespace)); + when(memberRepository.findByOperationIdOrderByPosition("operation-1")) + .thenReturn(List.of(member("private-team", SkillVisibility.PRIVATE))); + Skill privateSkill = skill(10L, 2L, "different-owner", SkillVisibility.PRIVATE); + when(skillRepository.findByIdIn(List.of(10L))).thenReturn(List.of(privateSkill)); + + var response = service.get("operation-1", "actor", Map.of(), Set.of()); + + assertThat(response.members()).singleElement().satisfies(member -> { + assertThat(member.redacted()).isTrue(); + assertThat(member.status()).isNotNull(); + assertThat(member.coordinate()).isNull(); + assertThat(member.packagePath()).isNull(); + assertThat(member.skillId()).isNull(); + assertThat(member.version()).isNull(); + assertThat(member.errors()).isEmpty(); + assertThat(member.warnings()).isEmpty(); + }); + } + + @Test + void delegatesActiveOperationPagingToTheReadModel() { + PageResponse expected = + new PageResponse<>(List.of(), 0, 2, 50); + when(operationQueryRepository.findActive("actor", 2, 50)).thenReturn(expected); + + assertThat(service.listActive("actor", 2, 100)).isSameAs(expected); + verify(operationQueryRepository).findActive("actor", 2, 50); + } + + @Test + void updateOperationIncludesItsBaseSuiteVersion() { + SkillSuiteBundleExecutionOperation operation = new SkillSuiteBundleExecutionOperation( + "operation-update", "preview-update", "request-update", "actor", SkillSuiteBundleMode.UPDATE, + 1L, "suite", 40L, 50L, "1.1.0", "temporary/archive.zip", "a".repeat(64), + Map.of(), "warning-digest", NOW); + Namespace namespace = mock(Namespace.class); + when(namespace.getSlug()).thenReturn("global"); + SkillSuiteVersion baseVersion = mock(SkillSuiteVersion.class); + when(baseVersion.getVersion()).thenReturn("1.0.0"); + when(operationRepository.findById("operation-update")).thenReturn(Optional.of(operation)); + when(namespaceRepository.findById(1L)).thenReturn(Optional.of(namespace)); + when(memberRepository.findByOperationIdOrderByPosition("operation-update")).thenReturn(List.of()); + when(suiteVersionRepository.findById(50L)).thenReturn(Optional.of(baseVersion)); + + var response = service.get("operation-update", "actor", Map.of(), Set.of()); + + assertThat(response.baseVersion()).isEqualTo("1.0.0"); + } + + @Test + void updateOperationRemainsReadableWhenItsBaseSuiteVersionWasDeleted() { + SkillSuiteBundleExecutionOperation operation = new SkillSuiteBundleExecutionOperation( + "operation-update", "preview-update", "request-update", "actor", SkillSuiteBundleMode.UPDATE, + 1L, "suite", 40L, null, "1.1.0", "temporary/archive.zip", "a".repeat(64), + Map.of(), "warning-digest", NOW); + Namespace namespace = mock(Namespace.class); + when(namespace.getSlug()).thenReturn("global"); + when(operationRepository.findById("operation-update")).thenReturn(Optional.of(operation)); + when(namespaceRepository.findById(1L)).thenReturn(Optional.of(namespace)); + when(memberRepository.findByOperationIdOrderByPosition("operation-update")).thenReturn(List.of()); + + var response = service.get("operation-update", "actor", Map.of(), Set.of()); + + assertThat(response.mode()).isEqualTo(SkillSuiteBundleMode.UPDATE); + assertThat(response.targetCoordinate()).isEqualTo("@global/suite"); + assertThat(response.baseVersion()).isNull(); + verifyNoInteractions(suiteVersionRepository); + } + + @Test + void delegatesOperationHistoryPagingToTheReadModel() { + SkillSuiteBundleOperationPageResponse expected = + new SkillSuiteBundleOperationPageResponse(List.of(), 0, 2, 50, true); + when(operationQueryRepository.findMine("actor", 2, 50)).thenReturn(expected); + + assertThat(service.listMine("actor", 2, 100)).isSameAs(expected); + verify(operationQueryRepository).findMine("actor", 2, 50); + } + + private SkillSuiteBundleExecutionOperation operation() { + return new SkillSuiteBundleExecutionOperation( + "operation-1", "preview-1", "request-1", "actor", SkillSuiteBundleMode.CREATE, + 1L, "suite", null, null, "1.0.0", "temporary/archive.zip", "a".repeat(64), + Map.of("secret", "internal-plan-value"), "warning-digest", NOW); + } + + private SkillSuiteBundleMemberResult member() { + return member("global", SkillVisibility.PUBLIC); + } + + private SkillSuiteBundleMemberResult member(String namespace, SkillVisibility visibility) { + return new SkillSuiteBundleMemberResult( + "operation-1", 0, new SkillSuiteBundleCoordinate(namespace, "member"), + SkillSuiteBundleMemberSourceType.PACKAGE, "members/member", visibility, "1.0.0", + SkillSuiteBundleRelationshipChange.ADDED, SkillSuiteBundlePublishAction.CREATE_VERSION, + "sha256:member", 10L, 20L, List.of(), List.of("warning"), NOW); + } + + private Skill skill(Long id, Long namespaceId, String ownerId, SkillVisibility visibility) { + Skill skill = mock(Skill.class); + when(skill.getId()).thenReturn(id); + when(skill.getNamespaceId()).thenReturn(namespaceId); + when(skill.getOwnerId()).thenReturn(ownerId); + when(skill.getVisibility()).thenReturn(visibility); + when(skill.getLatestVersionId()).thenReturn(20L); + return skill; + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/bundle/SkillSuiteBundlePackageAnalyzerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/bundle/SkillSuiteBundlePackageAnalyzerTest.java new file mode 100644 index 00000000..f2ed5d65 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/bundle/SkillSuiteBundlePackageAnalyzerTest.java @@ -0,0 +1,210 @@ +package com.iflytek.skillhub.service.bundle; + +import com.iflytek.skillhub.domain.skill.metadata.SkillMetadataParser; +import com.iflytek.skillhub.domain.skill.validation.SkillPackagePolicy; +import com.iflytek.skillhub.domain.skill.validation.SkillPackageValidator; +import com.iflytek.skillhub.domain.skill.validation.BasicPrePublishValidator; +import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleManifestParser; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayInputStream; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.ArrayList; +import java.util.HexFormat; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class SkillSuiteBundlePackageAnalyzerTest { + + private final SkillMetadataParser metadataParser = new SkillMetadataParser(); + private final SkillSuiteBundlePackageAnalyzer analyzer = new SkillSuiteBundlePackageAnalyzer( + new SkillSuiteBundleManifestParser(), metadataParser, new SkillPackageValidator(metadataParser)); + + @Test + void analyzesAnOuterDirectoryWithoutMixingMemberPackages() throws Exception { + List entries = List.of( + entry("bundle/SUITE.yaml", manifest(""" + - skill: "@global/first" + package: + path: skills/first + visibility: PUBLIC + - skill: "@global/second" + package: + path: skills/second + visibility: PUBLIC + - skill: "@global/shared" + reference: + version: 2.0.0 + """, "@global/first")), + entry("bundle/skills/first/SKILL.md", skillMd("first", "1.0.0")), + entry("bundle/skills/first/notes.txt", "first"), + entry("bundle/skills/second/SKILL.md", skillMd("second", "1.1.0")), + entry("bundle/skills/second/notes.txt", "second"), + entry("__MACOSX/._SUITE.yaml", "ignored") + ); + + SkillSuiteBundlePackageAnalyzer.BundleAnalysis result = analyzer.analyze(entries); + + assertThat(result.errors()).isEmpty(); + assertThat(result.packageMembers()).hasSize(2); + assertThat(result.packageMembers().getFirst().files()) + .extracting(SkillSuiteBundlePackageAnalyzer.StagedMemberFile::relativePath) + .containsExactly("SKILL.md", "notes.txt"); + assertThat(result.packageMembers().get(1).files()) + .extracting(SkillSuiteBundlePackageAnalyzer.StagedMemberFile::relativePath) + .containsExactly("SKILL.md", "notes.txt"); + assertThat(result.packageMembers()).allSatisfy(member -> { + assertThat(member.validation().passed()).isTrue(); + assertThat(member.fingerprint()).startsWith("sha256:"); + }); + } + + @Test + void reportsMissingRootSkillMdAndDoesNotPromoteNestedOne() throws Exception { + SkillSuiteBundlePackageAnalyzer.BundleAnalysis result = analyzer.analyze(List.of( + entry("SUITE.yaml", manifest(packageMember("missing"), "@global/missing")), + entry("skills/missing/docs/SKILL.md", skillMd("missing", "1.0.0")) + )); + + assertThat(result.packageMembers()).singleElement().satisfies(member -> + assertThat(member.validation().errors()) + .contains("Missing required file: SKILL.md at root") + .anyMatch(error -> error.contains("Nested SKILL.md is not allowed"))); + } + + @Test + void rejectsDuplicateCanonicalArchivePaths() throws Exception { + SkillSuiteBundlePackageAnalyzer.BundleAnalysis result = analyzer.analyze(List.of( + entry("SUITE.yaml", manifest(packageMember("duplicate"), "@global/duplicate")), + entry("skills/duplicate/SKILL.md", skillMd("duplicate", "1.0.0")), + entry("skills/duplicate/skill.md", skillMd("duplicate", "1.0.0")) + )); + + assertThat(result.errors()).anyMatch(error -> error.contains("Duplicate archive path")); + } + + @Test + void rejectsFilesOutsideDeclaredMemberDirectories() throws Exception { + SkillSuiteBundlePackageAnalyzer.BundleAnalysis result = analyzer.analyze(List.of( + entry("SUITE.yaml", manifest(packageMember("declared"), "@global/declared")), + entry("skills/declared/SKILL.md", skillMd("declared", "1.0.0")), + entry("skills/other/SKILL.md", skillMd("other", "1.0.0")), + entry("README.md", "unclaimed") + )); + + assertThat(result.errors()) + .anyMatch(error -> error.contains("Unclaimed archive entry: skills/other/SKILL.md")) + .anyMatch(error -> error.contains("Unclaimed archive entry: README.md")); + } + + @Test + void reportsManifestAndSkillMetadataConflict() throws Exception { + SkillSuiteBundlePackageAnalyzer.BundleAnalysis result = analyzer.analyze(List.of( + entry("SUITE.yaml", manifest(packageMember("expected"), "@global/expected")), + entry("skills/expected/SKILL.md", skillMd("different-name", "1.0.0")) + )); + + assertThat(result.packageMembers()).singleElement().satisfies(member -> + assertThat(member.validation().errors()) + .anyMatch(error -> error.contains("does not match manifest skill @global/expected"))); + } + + @Test + void appliesOrdinarySingleSkillFileLimitsPerMember() throws Exception { + SkillPackageValidator oneFileValidator = new SkillPackageValidator( + metadataParser, 1, SkillPackagePolicy.MAX_SINGLE_FILE_SIZE, + SkillPackagePolicy.MAX_TOTAL_PACKAGE_SIZE, SkillPackagePolicy.ALLOWED_EXTENSIONS); + SkillSuiteBundlePackageAnalyzer constrained = new SkillSuiteBundlePackageAnalyzer( + new SkillSuiteBundleManifestParser(), metadataParser, oneFileValidator); + + SkillSuiteBundlePackageAnalyzer.BundleAnalysis result = constrained.analyze(List.of( + entry("SUITE.yaml", manifest(packageMember("limited"), "@global/limited")), + entry("skills/limited/SKILL.md", skillMd("limited", "1.0.0")), + entry("skills/limited/extra.txt", "extra") + )); + + assertThat(result.packageMembers()).singleElement().satisfies(member -> + assertThat(member.validation().errors()).contains("Too many files: 2 (max: 1)")); + } + + @Test + void reusesOrdinaryCredentialPrecheckWithoutBlockingPlaceholderValues() throws Exception { + SkillSuiteBundlePackageAnalyzer credentialAware = new SkillSuiteBundlePackageAnalyzer( + new SkillSuiteBundleManifestParser(), metadataParser, + new SkillPackageValidator(metadataParser), new BasicPrePublishValidator()); + + SkillSuiteBundlePackageAnalyzer.BundleAnalysis warning = credentialAware.analyze(List.of( + entry("SUITE.yaml", manifest(packageMember("credential"), "@global/credential")), + entry("skills/credential/SKILL.md", skillMd("credential", "1.0.0")), + entry("skills/credential/config.env", "API_KEY=sk-abcdefghijklmnopqrstuvwxyz") + )); + SkillSuiteBundlePackageAnalyzer.BundleAnalysis placeholder = credentialAware.analyze(List.of( + entry("SUITE.yaml", manifest(packageMember("credential"), "@global/credential")), + entry("skills/credential/SKILL.md", skillMd("credential", "1.0.0")), + entry("skills/credential/config.env", "API_KEY=your-api-key-placeholder") + )); + + assertThat(warning.packageMembers()).singleElement().satisfies(member -> + assertThat(member.validation().warnings()) + .anyMatch(value -> value.contains("looks like a API key"))); + assertThat(placeholder.packageMembers()).singleElement().satisfies(member -> + assertThat(member.validation().warnings()).isEmpty()); + } + + private SkillSuiteBundleStagedEntry entry(String path, String content) throws Exception { + byte[] bytes = content.getBytes(StandardCharsets.UTF_8); + String sha256 = HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(bytes)); + return new SkillSuiteBundleStagedEntry( + path, bytes.length, "text/plain", sha256, "temporary/" + path, + () -> new ByteArrayInputStream(bytes)); + } + + private String packageMember(String slug) { + return """ + - skill: "@global/%s" + package: + path: skills/%s + visibility: PUBLIC + """.formatted(slug, slug); + } + + private String manifest(String members, String entry) { + return """ + apiVersion: skillhub.iflytek.com/v1alpha1 + kind: SkillSuiteBundle + metadata: + namespace: global + slug: test-suite + spec: + mode: CREATE + version: 1.0.0 + displayName: Test Suite + summary: Test summary + overview: Test overview + visibility: PUBLIC + entry: "%s" + members: + %s + """.formatted(entry, indent(members, 4)); + } + + private String indent(String value, int spaces) { + String prefix = " ".repeat(spaces); + List lines = new ArrayList<>(); + value.stripTrailing().lines().forEach(line -> lines.add(prefix + line)); + return String.join("\n", lines); + } + + private String skillMd(String name, String version) { + return """ + --- + name: %s + description: Test skill + version: %s + --- + Test instructions. + """.formatted(name, version); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/bundle/SkillSuiteBundlePreviewAppServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/bundle/SkillSuiteBundlePreviewAppServiceTest.java new file mode 100644 index 00000000..974fa8b4 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/bundle/SkillSuiteBundlePreviewAppServiceTest.java @@ -0,0 +1,180 @@ +package com.iflytek.skillhub.service.bundle; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.iflytek.skillhub.config.SkillSuiteBundleProperties; +import com.iflytek.skillhub.domain.namespace.NamespaceRole; +import com.iflytek.skillhub.domain.skill.SkillVisibility; +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.SkillSuiteBundleManifestParser; +import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleMode; +import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundlePreviewSession; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.mock.web.MockMultipartFile; + +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class SkillSuiteBundlePreviewAppServiceTest { + + private static final Instant NOW = Instant.parse("2026-09-11T08:00:00Z"); + + private SkillSuiteBundleArchiveService archiveService; + private SkillSuiteBundlePreviewPlanner planner; + private SkillSuiteBundlePreviewPersistenceService persistenceService; + private SkillSuiteBundlePreviewAppService service; + + @BeforeEach + void setUp() { + archiveService = mock(SkillSuiteBundleArchiveService.class); + planner = mock(SkillSuiteBundlePreviewPlanner.class); + persistenceService = mock(SkillSuiteBundlePreviewPersistenceService.class); + SkillSuiteBundleProperties properties = new SkillSuiteBundleProperties(); + properties.setPreviewTtl(Duration.ofMinutes(30)); + service = new SkillSuiteBundlePreviewAppService( + archiveService, planner, persistenceService, properties, + new ObjectMapper(), Clock.fixed(NOW, ZoneOffset.UTC)); + } + + @Test + void persistsActorBoundPreviewWithoutCreatingAnExecutionReservation() throws Exception { + SkillSuiteBundlePackageAnalyzer.BundleAnalysis packageAnalysis = packageAnalysis(List.of()); + SkillSuiteBundleArchiveService.StagedBundleAnalysis staged = staged(packageAnalysis); + SkillSuiteBundlePreviewPlanner.PreviewPlan plan = validPlan(); + when(archiveService.stageAndAnalyze(any())).thenReturn(staged); + when(planner.plan(packageAnalysis, "actor", Map.of(1L, NamespaceRole.MEMBER), Set.of())) + .thenReturn(plan); + SkillSuiteBundlePreviewAppService.PreviewOutcome result = service.preview( + upload(), "actor", Map.of(1L, NamespaceRole.MEMBER), Set.of()); + + assertThat(result.confirmable()).isTrue(); + assertThat(result.expiresAt()).isEqualTo(NOW.plus(Duration.ofMinutes(30))); + ArgumentCaptor captor = + ArgumentCaptor.forClass(SkillSuiteBundlePreviewSession.class); + verify(persistenceService).save(captor.capture()); + SkillSuiteBundlePreviewSession stored = captor.getValue(); + assertThat(stored.getActorId()).isEqualTo("actor"); + assertThat(stored.getNamespaceId()).isEqualTo(1L); + assertThat(stored.getTargetSuiteSlug()).isEqualTo("preview-suite"); + assertThat(stored.getArchiveObjectKey()).isEqualTo("temporary/archive.zip"); + assertThat(stored.getArchiveSha256()).isEqualTo("a".repeat(64)); + assertThat(stored.getWarningDigest()).isEqualTo("b".repeat(64)); + assertThat(stored.getPlan()).containsEntry("targetVersion", "1.0.0"); + assertThat(stored.getExpiresAt()).isEqualTo(result.expiresAt()); + verify(archiveService, never()).cleanupStagedObjects(staged.objectKeys()); + } + + @Test + void invalidSemanticPlanReturnsErrorsAndDeletesAllStagedObjects() throws Exception { + SkillSuiteBundlePackageAnalyzer.BundleAnalysis packageAnalysis = packageAnalysis(List.of()); + SkillSuiteBundleArchiveService.StagedBundleAnalysis staged = staged(packageAnalysis); + SkillSuiteBundlePreviewPlanner.PreviewPlan invalid = new SkillSuiteBundlePreviewPlanner.PreviewPlan( + SkillSuiteBundleMode.CREATE, new SkillSuiteBundleCoordinate("global", "preview-suite"), + 1L, null, null, "1.0.0", "Preview Suite", "Summary", "Overview", + SkillVisibility.PUBLIC, List.of(), List.of(), List.of("blocked"), List.of(), "b".repeat(64)); + when(archiveService.stageAndAnalyze(any())).thenReturn(staged); + when(planner.plan(any(), any(), any(), any())).thenReturn(invalid); + + SkillSuiteBundlePreviewAppService.PreviewOutcome result = service.preview( + upload(), "actor", Map.of(), Set.of()); + + assertThat(result.confirmable()).isFalse(); + assertThat(result.errors()).containsExactly("blocked"); + verify(archiveService).cleanupStagedObjects(staged.objectKeys()); + verify(persistenceService, never()).save(any()); + } + + @Test + void persistenceFailureRollsBackPreviewAndDeletesAllStagedObjects() throws Exception { + SkillSuiteBundlePackageAnalyzer.BundleAnalysis packageAnalysis = packageAnalysis(List.of()); + SkillSuiteBundleArchiveService.StagedBundleAnalysis staged = staged(packageAnalysis); + when(archiveService.stageAndAnalyze(any())).thenReturn(staged); + when(planner.plan(any(), any(), any(), any())).thenReturn(validPlan()); + doThrow(new IllegalStateException("database unavailable")).when(persistenceService).save(any()); + + assertThatThrownBy(() -> service.preview(upload(), "actor", Map.of(), Set.of())) + .isInstanceOf(IllegalStateException.class) + .hasMessage("database unavailable"); + verify(archiveService).cleanupStagedObjects(staged.objectKeys()); + } + + @Test + void structuralMemberErrorsAreReturnedWithTheirCoordinate() throws Exception { + SkillSuiteBundlePackageAnalyzer.MemberPackageAnalysis invalidMember = + new SkillSuiteBundlePackageAnalyzer.MemberPackageAnalysis( + new SkillSuiteBundleCoordinate("global", "reference"), "skills/reference", + null, ValidationResult.fail("SKILL.md is required"), List.of(), "sha256:invalid"); + SkillSuiteBundlePackageAnalyzer.BundleAnalysis packageAnalysis = new + SkillSuiteBundlePackageAnalyzer.BundleAnalysis( + packageAnalysis(List.of()).manifest(), List.of(invalidMember), List.of()); + SkillSuiteBundleArchiveService.StagedBundleAnalysis staged = staged(packageAnalysis); + when(archiveService.stageAndAnalyze(any())).thenReturn(staged); + + SkillSuiteBundlePreviewAppService.PreviewOutcome result = service.preview( + upload(), "actor", Map.of(), Set.of()); + + assertThat(result.confirmable()).isFalse(); + assertThat(result.errors()).containsExactly("@global/reference: SKILL.md is required"); + verify(planner, never()).plan(any(), any(), any(), any()); + verify(persistenceService, never()).save(any()); + } + + private SkillSuiteBundleArchiveService.StagedBundleAnalysis staged( + SkillSuiteBundlePackageAnalyzer.BundleAnalysis packageAnalysis + ) { + return new SkillSuiteBundleArchiveService.StagedBundleAnalysis( + "temporary/archive.zip", "a".repeat(64), packageAnalysis, + List.of("temporary/archive.zip", "temporary/member-file")); + } + + private SkillSuiteBundlePreviewPlanner.PreviewPlan validPlan() { + return new SkillSuiteBundlePreviewPlanner.PreviewPlan( + SkillSuiteBundleMode.CREATE, new SkillSuiteBundleCoordinate("global", "preview-suite"), + 1L, null, null, "1.0.0", "Preview Suite", "Summary", "Overview", + SkillVisibility.PUBLIC, List.of(), List.of(), List.of(), List.of(), "b".repeat(64)); + } + + private SkillSuiteBundlePackageAnalyzer.BundleAnalysis packageAnalysis(List errors) { + SkillSuiteBundleManifest manifest = new SkillSuiteBundleManifestParser().parse(""" + apiVersion: skillhub.iflytek.com/v1alpha1 + kind: SkillSuiteBundle + metadata: + namespace: global + slug: preview-suite + spec: + mode: CREATE + version: 1.0.0 + displayName: Preview Suite + summary: Summary + overview: Overview + visibility: PUBLIC + entry: "@global/reference" + members: + - skill: "@global/reference" + reference: + version: 1.0.0 + """); + return new SkillSuiteBundlePackageAnalyzer.BundleAnalysis(manifest, List.of(), errors); + } + + private MockMultipartFile upload() { + return new MockMultipartFile("file", "bundle.zip", "application/zip", new byte[]{1}); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/bundle/SkillSuiteBundlePreviewPlannerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/bundle/SkillSuiteBundlePreviewPlannerTest.java new file mode 100644 index 00000000..cdf40189 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/bundle/SkillSuiteBundlePreviewPlannerTest.java @@ -0,0 +1,698 @@ +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.skill.metadata.SkillMetadataParser; +import com.iflytek.skillhub.domain.skill.validation.SkillPackageValidator; +import com.iflytek.skillhub.domain.suite.SkillSuite; +import com.iflytek.skillhub.domain.suite.SkillSuiteMemberSelection; +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.SkillSuiteBundleManifestParser; +import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundlePublishAction; +import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleRelationshipChange; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayInputStream; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.ArrayList; +import java.util.HexFormat; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class SkillSuiteBundlePreviewPlannerTest { + + private NamespaceRepository namespaceRepository; + private SkillRepository skillRepository; + private SkillVersionRepository skillVersionRepository; + private SkillFileRepository skillFileRepository; + private SkillSuiteRepository suiteRepository; + private SkillSuiteVersionRepository suiteVersionRepository; + private SkillSuiteVersionMemberRepository suiteMemberRepository; + private SecurityScanService securityScanService; + private SkillSuiteBundlePreviewPlanner planner; + + @BeforeEach + void setUp() { + namespaceRepository = mock(NamespaceRepository.class); + skillRepository = mock(SkillRepository.class); + skillVersionRepository = mock(SkillVersionRepository.class); + skillFileRepository = mock(SkillFileRepository.class); + suiteRepository = mock(SkillSuiteRepository.class); + suiteVersionRepository = mock(SkillSuiteVersionRepository.class); + suiteMemberRepository = mock(SkillSuiteVersionMemberRepository.class); + securityScanService = mock(SecurityScanService.class); + when(securityScanService.isEnabled()).thenReturn(true); + planner = new SkillSuiteBundlePreviewPlanner( + namespaceRepository, skillRepository, skillVersionRepository, skillFileRepository, + suiteRepository, suiteVersionRepository, suiteMemberRepository, + new VisibilityChecker(), securityScanService, + Clock.fixed(Instant.parse("2026-09-11T06:00:00Z"), ZoneOffset.UTC)); + } + + @Test + void plansNewPackageAndCrossOwnerPublicReferenceWithoutPublishingEither() throws Exception { + Namespace global = namespace(1L, "global"); + Namespace shared = namespace(2L, "shared"); + Skill referenced = skill(20L, 2L, "reference", "other-owner", SkillVisibility.PUBLIC, 200L); + SkillVersion referencedVersion = version(200L, 20L, "2.0.0", SkillVersionStatus.PUBLISHED, true); + when(namespaceRepository.findBySlugIn(anyList())).thenReturn(List.of(global, shared)); + when(suiteRepository.findByNamespaceIdAndSlug(1L, "mixed-suite")).thenReturn(Optional.empty()); + when(skillRepository.findByNamespaceIdInAndSlugIn(anyList(), anyList())).thenReturn(List.of(referenced)); + when(skillVersionRepository.findBySkillIdInAndStatus(anyList(), eq(SkillVersionStatus.PENDING_REVIEW))) + .thenReturn(List.of()); + when(skillVersionRepository.findBySkillIdInAndVersionIn(anyList(), anyList())) + .thenReturn(List.of(referencedVersion)); + when(skillVersionRepository.findByIdIn(anyList())).thenReturn(List.of(referencedVersion)); + when(skillFileRepository.findByVersionIdIn(anyList())).thenReturn(List.of()); + + SkillSuiteBundlePreviewPlanner.PreviewPlan result = planner.plan( + analyze(manifest("mixed-suite", "CREATE", null, """ + - skill: "@global/new-skill" + package: + path: skills/new-skill + visibility: PUBLIC + - skill: "@shared/reference" + reference: + version: 2.0.0 + """, "@global/new-skill", true), + Map.of("skills/new-skill/SKILL.md", skillMd("new-skill", "1.0.0"))), + "actor", Map.of(1L, NamespaceRole.MEMBER), Set.of()); + + assertThat(result.confirmable()).isTrue(); + assertThat(result.members()).extracting(SkillSuiteBundlePreviewPlanner.MemberPlan::publishAction) + .containsExactly( + SkillSuiteBundlePublishAction.CREATE_SKILL, + SkillSuiteBundlePublishAction.REFERENCE_VERSION); + assertThat(result.members().get(1).skillId()).isEqualTo(20L); + verify(skillRepository, never()).save(org.mockito.ArgumentMatchers.any()); + verify(skillVersionRepository, never()).save(org.mockito.ArgumentMatchers.any()); + } + + @Test + void blocksPendingReviewAndAnExistingNonPublishedTargetVersion() throws Exception { + Namespace global = namespace(1L, "global"); + Skill existing = skill(10L, 1L, "owned", "actor", SkillVisibility.PUBLIC, 100L); + SkillVersion current = version(100L, 10L, "1.0.0", SkillVersionStatus.PUBLISHED, true); + SkillVersion target = version(101L, 10L, "2.0.0", SkillVersionStatus.UPLOADED, false); + SkillVersion pending = version(102L, 10L, "1.5.0", SkillVersionStatus.PENDING_REVIEW, false); + when(namespaceRepository.findBySlugIn(anyList())).thenReturn(List.of(global)); + when(suiteRepository.findByNamespaceIdAndSlug(1L, "blocked-suite")).thenReturn(Optional.empty()); + when(skillRepository.findByNamespaceIdInAndSlugIn(anyList(), anyList())).thenReturn(List.of(existing)); + when(skillVersionRepository.findBySkillIdInAndStatus(anyList(), eq(SkillVersionStatus.PENDING_REVIEW))) + .thenReturn(List.of(pending)); + when(skillVersionRepository.findBySkillIdInAndVersionIn(anyList(), anyList())) + .thenReturn(List.of(target)); + when(skillVersionRepository.findByIdIn(anyList())).thenReturn(List.of(current)); + when(skillFileRepository.findByVersionIdIn(anyList())).thenReturn(List.of()); + + SkillSuiteBundlePreviewPlanner.PreviewPlan result = planner.plan( + analyze(manifest("blocked-suite", "CREATE", null, packageMember("owned", false), + "@global/owned", true), + Map.of("skills/owned/SKILL.md", skillMd("owned", "2.0.0"))), + "actor", Map.of(1L, NamespaceRole.MEMBER), Set.of()); + + assertThat(result.confirmable()).isFalse(); + assertThat(result.members()).singleElement().satisfies(member -> + assertThat(member.errors()) + .anyMatch(error -> error.contains("pending review")) + .anyMatch(error -> error.contains("non-published"))); + } + + @Test + void keepsWarningsExplicitAndBoundToADeterministicDigest() throws Exception { + Namespace global = namespace(1L, "global"); + when(namespaceRepository.findBySlugIn(anyList())).thenReturn(List.of(global)); + when(suiteRepository.findByNamespaceIdAndSlug(1L, "warning-suite")).thenReturn(Optional.empty()); + when(skillRepository.findByNamespaceIdInAndSlugIn(anyList(), anyList())).thenReturn(List.of()); + String yaml = manifest("warning-suite", "CREATE", null, + packageMember("warning-skill", true), "@global/warning-skill", true) + .replace("visibility: PUBLIC", "visibility: PRIVATE"); + + SkillSuiteBundlePreviewPlanner.PreviewPlan result = planner.plan( + analyze(yaml, Map.of( + "skills/warning-skill/SKILL.md", skillMd("warning-skill", "1.0.0"), + "skills/warning-skill/tool.exe", "not-an-executable")), + "actor", Map.of(1L, NamespaceRole.MEMBER), Set.of()); + + assertThat(result.confirmable()).isTrue(); + assertThat(result.requiresWarningConfirmation()).isTrue(); + assertThat(result.warningDigest()).hasSize(64); + assertThat(result.warnings()).anyMatch(warning -> warning.contains("tool.exe")); + } + + @Test + void suitePermissionDoesNotGrantPublishingPermissionForAnotherOwnersSkill() throws Exception { + Namespace global = namespace(1L, "global"); + Skill otherOwnersSkill = skill( + 10L, 1L, "foreign", "other-owner", SkillVisibility.PUBLIC, 100L); + SkillVersion current = version(100L, 10L, "1.0.0", SkillVersionStatus.PUBLISHED, true); + when(namespaceRepository.findBySlugIn(anyList())).thenReturn(List.of(global)); + when(suiteRepository.findByNamespaceIdAndSlug(1L, "permission-suite")).thenReturn(Optional.empty()); + when(skillRepository.findByNamespaceIdInAndSlugIn(anyList(), anyList())) + .thenReturn(List.of(otherOwnersSkill)); + when(skillVersionRepository.findBySkillIdInAndStatus(anyList(), eq(SkillVersionStatus.PENDING_REVIEW))) + .thenReturn(List.of()); + when(skillVersionRepository.findBySkillIdInAndVersionIn(anyList(), anyList())).thenReturn(List.of()); + when(skillVersionRepository.findByIdIn(anyList())).thenReturn(List.of(current)); + when(skillFileRepository.findByVersionIdIn(anyList())).thenReturn(List.of()); + + SkillSuiteBundlePreviewPlanner.PreviewPlan result = planner.plan( + analyze(manifest("permission-suite", "CREATE", null, + packageMember("foreign", false), "@global/foreign", true), + Map.of("skills/foreign/SKILL.md", skillMd("foreign", "2.0.0"))), + "actor", Map.of(1L, NamespaceRole.MEMBER), Set.of()); + + assertThat(result.confirmable()).isFalse(); + assertThat(result.members()).singleElement().satisfies(member -> + assertThat(member.errors()).contains("No permission to publish existing Skill")); + } + + @Test + void blocksMemberVisibilityThatCannotServeTheSuiteAudience() throws Exception { + Namespace global = namespace(1L, "global"); + when(namespaceRepository.findBySlugIn(anyList())).thenReturn(List.of(global)); + when(suiteRepository.findByNamespaceIdAndSlug(1L, "visibility-suite")).thenReturn(Optional.empty()); + when(skillRepository.findByNamespaceIdInAndSlugIn(anyList(), anyList())).thenReturn(List.of()); + String yaml = manifest("visibility-suite", "CREATE", null, + packageMember("private-member", true), "@global/private-member", true) + .replace(" visibility: PUBLIC", " visibility: PRIVATE"); + + SkillSuiteBundlePreviewPlanner.PreviewPlan result = planner.plan( + analyze(yaml, Map.of( + "skills/private-member/SKILL.md", skillMd("private-member", "1.0.0"))), + "actor", Map.of(1L, NamespaceRole.MEMBER), Set.of()); + + assertThat(result.confirmable()).isFalse(); + assertThat(result.members()).singleElement().satisfies(member -> + assertThat(member.errors()).contains("New Skill visibility is incompatible with Suite audience")); + } + + @Test + void blocksExistingSuiteCoordinateBeforeAnyMemberWrite() throws Exception { + Namespace global = namespace(1L, "global"); + SkillSuite existingSuite = mock(SkillSuite.class); + when(existingSuite.getId()).thenReturn(50L); + when(namespaceRepository.findBySlugIn(anyList())).thenReturn(List.of(global)); + when(suiteRepository.findByNamespaceIdAndSlug(1L, "existing-suite")) + .thenReturn(Optional.of(existingSuite)); + when(skillRepository.findByNamespaceIdInAndSlugIn(anyList(), anyList())).thenReturn(List.of()); + + SkillSuiteBundlePreviewPlanner.PreviewPlan result = planner.plan( + analyze(manifest("existing-suite", "CREATE", null, + packageMember("member", true), "@global/member", true), + Map.of("skills/member/SKILL.md", skillMd("member", "1.0.0"))), + "actor", Map.of(1L, NamespaceRole.MEMBER), Set.of()); + + assertThat(result.confirmable()).isFalse(); + assertThat(result.errors()).contains("Target Suite coordinate already exists"); + verify(skillRepository, never()).save(org.mockito.ArgumentMatchers.any()); + } + + @Test + void blocksExistingTargetSuiteVersion() throws Exception { + Namespace global = namespace(1L, "global"); + SkillSuite suite = mock(SkillSuite.class); + when(suite.getId()).thenReturn(50L); + when(suite.getNamespaceId()).thenReturn(1L); + when(suite.getCreatedBy()).thenReturn("actor"); + when(suite.getStatus()).thenReturn(SkillSuiteStatus.ACTIVE); + SkillSuiteVersion base = mock(SkillSuiteVersion.class); + when(base.getId()).thenReturn(60L); + when(base.getStatus()).thenReturn(SkillSuiteVersionStatus.PUBLISHED); + when(base.getSummary()).thenReturn("Summary"); + when(base.getOverview()).thenReturn("Overview"); + SkillSuiteVersion existingTarget = mock(SkillSuiteVersion.class); + when(namespaceRepository.findBySlugIn(anyList())).thenReturn(List.of(global)); + when(suiteRepository.findByNamespaceIdAndSlug(1L, "version-conflict")) + .thenReturn(Optional.of(suite)); + when(suiteVersionRepository.findBySuiteIdAndVersion(50L, "1.0.0")) + .thenReturn(Optional.of(base)); + when(suiteVersionRepository.findBySuiteIdAndVersion(50L, "1.1.0")) + .thenReturn(Optional.of(existingTarget)); + when(suiteMemberRepository.findBySuiteVersionIdOrderByPosition(60L)).thenReturn(List.of()); + when(skillRepository.findByNamespaceIdInAndSlugIn(anyList(), anyList())).thenReturn(List.of()); + + SkillSuiteBundlePreviewPlanner.PreviewPlan result = planner.plan( + analyze(manifest("version-conflict", "UPDATE", "1.0.0", + packageMember("member", true), "@global/member", false), + Map.of("skills/member/SKILL.md", skillMd("member", "1.0.0"))), + "actor", Map.of(1L, NamespaceRole.MEMBER), Set.of()); + + assertThat(result.confirmable()).isFalse(); + assertThat(result.errors()).contains("Target Suite version already exists"); + } + + @Test + void updateInheritsPresentationAndReportsRemovedBaselineMembers() throws Exception { + Namespace global = namespace(1L, "global"); + SkillSuite suite = mock(SkillSuite.class); + when(suite.getId()).thenReturn(50L); + when(suite.getNamespaceId()).thenReturn(1L); + when(suite.getCreatedBy()).thenReturn("actor"); + when(suite.getStatus()).thenReturn(SkillSuiteStatus.ACTIVE); + SkillSuiteVersion base = mock(SkillSuiteVersion.class); + when(base.getId()).thenReturn(60L); + when(base.getStatus()).thenReturn(SkillSuiteVersionStatus.PUBLISHED); + when(base.getSummary()).thenReturn("Inherited summary"); + when(base.getOverview()).thenReturn("Inherited overview"); + Skill keptSkill = skill(10L, 1L, "kept", "other", SkillVisibility.PUBLIC, 100L); + SkillVersion keptVersion = version(100L, 10L, "1.0.0", SkillVersionStatus.PUBLISHED, true); + SkillSuiteVersionMember kept = baselineMember(60L, 10L, 100L, "global", "kept", "1.0.0", 0, true); + SkillSuiteVersionMember removed = baselineMember(60L, 11L, 110L, "global", "removed", "1.0.0", 1, false); + + when(namespaceRepository.findBySlugIn(anyList())).thenReturn(List.of(global)); + when(suiteRepository.findByNamespaceIdAndSlug(1L, "update-suite")).thenReturn(Optional.of(suite)); + when(suiteVersionRepository.findBySuiteIdAndVersion(50L, "1.0.0")).thenReturn(Optional.of(base)); + when(suiteVersionRepository.findBySuiteIdAndVersion(50L, "1.1.0")).thenReturn(Optional.empty()); + when(suiteMemberRepository.findBySuiteVersionIdOrderByPosition(60L)).thenReturn(List.of(kept, removed)); + when(skillRepository.findByNamespaceIdInAndSlugIn(anyList(), anyList())).thenReturn(List.of(keptSkill)); + when(skillVersionRepository.findBySkillIdInAndStatus(anyList(), eq(SkillVersionStatus.PENDING_REVIEW))) + .thenReturn(List.of()); + when(skillVersionRepository.findBySkillIdInAndVersionIn(anyList(), anyList())) + .thenReturn(List.of(keptVersion)); + when(skillVersionRepository.findByIdIn(anyList())).thenReturn(List.of(keptVersion)); + when(skillFileRepository.findByVersionIdIn(anyList())).thenReturn(List.of()); + + SkillSuiteBundlePreviewPlanner.PreviewPlan result = planner.plan( + analyze(manifest("update-suite", "UPDATE", "1.0.0", """ + - skill: "@global/kept" + reference: + version: 1.0.0 + """, "@global/kept", false), Map.of()), + "actor", Map.of(1L, NamespaceRole.MEMBER), Set.of()); + + assertThat(result.confirmable()).isTrue(); + assertThat(result.summary()).isEqualTo("Inherited summary"); + assertThat(result.overview()).isEqualTo("Inherited overview"); + assertThat(result.members()).singleElement().satisfies(member -> + assertThat(member.relationship()).isEqualTo(SkillSuiteBundleRelationshipChange.UNCHANGED)); + assertThat(result.removedMembers()).singleElement().satisfies(member -> + assertThat(member.coordinate().canonical()).isEqualTo("@global/removed")); + assertThat(result.removedMembers().getFirst().relationship()) + .isEqualTo(SkillSuiteBundleRelationshipChange.REMOVED); + assertThat(result.removedMembers().getFirst().publishAction()) + .isEqualTo(SkillSuiteBundlePublishAction.NONE); + } + + @Test + void updateIsNotConfirmableWhenPresentationCannotBeInherited() throws Exception { + Namespace global = namespace(1L, "global"); + SkillSuite suite = mock(SkillSuite.class); + when(suite.getId()).thenReturn(50L); + when(suite.getNamespaceId()).thenReturn(1L); + when(suite.getCreatedBy()).thenReturn("actor"); + when(suite.getStatus()).thenReturn(SkillSuiteStatus.ACTIVE); + SkillSuiteVersion base = mock(SkillSuiteVersion.class); + when(base.getId()).thenReturn(60L); + when(base.getStatus()).thenReturn(SkillSuiteVersionStatus.PUBLISHED); + when(base.getSummary()).thenReturn(" "); + when(base.getOverview()).thenReturn(null); + Skill keptSkill = skill(10L, 1L, "kept", "other", SkillVisibility.PUBLIC, 100L); + SkillVersion keptVersion = version(100L, 10L, "1.0.0", SkillVersionStatus.PUBLISHED, true); + SkillSuiteVersionMember kept = baselineMember( + 60L, 10L, 100L, "global", "kept", "1.0.0", 0, true); + + when(namespaceRepository.findBySlugIn(anyList())).thenReturn(List.of(global)); + when(suiteRepository.findByNamespaceIdAndSlug(1L, "update-suite")).thenReturn(Optional.of(suite)); + when(suiteVersionRepository.findBySuiteIdAndVersion(50L, "1.0.0")).thenReturn(Optional.of(base)); + when(suiteVersionRepository.findBySuiteIdAndVersion(50L, "1.1.0")).thenReturn(Optional.empty()); + when(suiteMemberRepository.findBySuiteVersionIdOrderByPosition(60L)).thenReturn(List.of(kept)); + when(skillRepository.findByNamespaceIdInAndSlugIn(anyList(), anyList())).thenReturn(List.of(keptSkill)); + when(skillVersionRepository.findBySkillIdInAndStatus(anyList(), eq(SkillVersionStatus.PENDING_REVIEW))) + .thenReturn(List.of()); + when(skillVersionRepository.findBySkillIdInAndVersionIn(anyList(), anyList())) + .thenReturn(List.of(keptVersion)); + when(skillVersionRepository.findByIdIn(anyList())).thenReturn(List.of(keptVersion)); + when(skillFileRepository.findByVersionIdIn(anyList())).thenReturn(List.of()); + + SkillSuiteBundlePreviewPlanner.PreviewPlan result = planner.plan( + analyze(manifest("update-suite", "UPDATE", "1.0.0", """ + - skill: "@global/kept" + reference: + version: 1.0.0 + """, "@global/kept", false), Map.of()), + "actor", Map.of(1L, NamespaceRole.MEMBER), Set.of()); + + assertThat(result.confirmable()).isFalse(); + assertThat(result.summary()).isBlank(); + assertThat(result.overview()).isNull(); + assertThat(result.errors()).contains( + "Suite summary is required after inheritance", + "Suite overview is required after inheritance"); + } + + @Test + void rejectsAnUpdateThatDoesNotChangePresentationOrMembers() throws Exception { + Namespace global = namespace(1L, "global"); + SkillSuite suite = updateSuite(); + SkillSuiteVersion base = updateBaseVersion(); + Skill keptSkill = skill(10L, 1L, "kept", "other", SkillVisibility.PUBLIC, 100L); + SkillVersion keptVersion = version(100L, 10L, "1.0.0", SkillVersionStatus.PUBLISHED, true); + SkillSuiteVersionMember kept = baselineMember( + 60L, 10L, 100L, "global", "kept", "1.0.0", 0, true); + + stubUpdate("no-op-suite", global, suite, base, List.of(kept), List.of(keptSkill), List.of(keptVersion)); + + SkillSuiteBundlePreviewPlanner.PreviewPlan result = planner.plan( + analyze(manifest("no-op-suite", "UPDATE", "1.0.0", """ + - skill: "@global/kept" + reference: + version: 1.0.0 + """, "@global/kept", false), Map.of()), + "actor", Map.of(1L, NamespaceRole.MEMBER), Set.of()); + + assertThat(result.confirmable()).isFalse(); + assertThat(result.members()).singleElement().satisfies(member -> + assertThat(member.relationship()).isEqualTo(SkillSuiteBundleRelationshipChange.UNCHANGED)); + assertThat(result.errors()).contains( + "Bundle does not contain an effective change from the base Suite version"); + } + + @Test + void treatsMemberReorderingAsAnEffectiveUpdate() throws Exception { + Namespace global = namespace(1L, "global"); + SkillSuite suite = updateSuite(); + SkillSuiteVersion base = updateBaseVersion(); + Skill firstSkill = skill(10L, 1L, "first", "other", SkillVisibility.PUBLIC, 100L); + Skill secondSkill = skill(11L, 1L, "second", "other", SkillVisibility.PUBLIC, 110L); + SkillVersion firstVersion = version(100L, 10L, "1.0.0", SkillVersionStatus.PUBLISHED, true); + SkillVersion secondVersion = version(110L, 11L, "1.0.0", SkillVersionStatus.PUBLISHED, true); + List baseline = List.of( + baselineMember(60L, 10L, 100L, "global", "first", "1.0.0", 0, true), + baselineMember(60L, 11L, 110L, "global", "second", "1.0.0", 1, false)); + stubUpdate("reordered-suite", global, suite, base, baseline, + List.of(firstSkill, secondSkill), List.of(firstVersion, secondVersion)); + + SkillSuiteBundlePreviewPlanner.PreviewPlan result = planner.plan( + analyze(manifest("reordered-suite", "UPDATE", "1.0.0", """ + - skill: "@global/second" + reference: + version: 1.0.0 + - skill: "@global/first" + reference: + version: 1.0.0 + """, "@global/first", false), Map.of()), + "actor", Map.of(1L, NamespaceRole.MEMBER), Set.of()); + + assertThat(result.confirmable()).isTrue(); + assertThat(result.members()).extracting(SkillSuiteBundlePreviewPlanner.MemberPlan::relationship) + .containsExactly( + SkillSuiteBundleRelationshipChange.UPDATED, + SkillSuiteBundleRelationshipChange.UPDATED); + } + + @Test + void treatsEntryChangeAsAnEffectiveUpdateEvenWhenPinnedVersionsStayTheSame() throws Exception { + Namespace global = namespace(1L, "global"); + SkillSuite suite = updateSuite(); + SkillSuiteVersion base = updateBaseVersion(); + Skill firstSkill = skill(10L, 1L, "first", "other", SkillVisibility.PUBLIC, 100L); + Skill secondSkill = skill(11L, 1L, "second", "other", SkillVisibility.PUBLIC, 110L); + SkillVersion firstVersion = version(100L, 10L, "1.0.0", SkillVersionStatus.PUBLISHED, true); + SkillVersion secondVersion = version(110L, 11L, "1.0.0", SkillVersionStatus.PUBLISHED, true); + List baseline = List.of( + baselineMember(60L, 10L, 100L, "global", "first", "1.0.0", 0, true), + baselineMember(60L, 11L, 110L, "global", "second", "1.0.0", 1, false)); + stubUpdate("entry-suite", global, suite, base, baseline, + List.of(firstSkill, secondSkill), List.of(firstVersion, secondVersion)); + + SkillSuiteBundlePreviewPlanner.PreviewPlan result = planner.plan( + analyze(manifest("entry-suite", "UPDATE", "1.0.0", """ + - skill: "@global/first" + reference: + version: 1.0.0 + - skill: "@global/second" + reference: + version: 1.0.0 + """, "@global/second", false), Map.of()), + "actor", Map.of(1L, NamespaceRole.MEMBER), Set.of()); + + assertThat(result.confirmable()).isTrue(); + assertThat(result.members()).extracting(SkillSuiteBundlePreviewPlanner.MemberPlan::relationship) + .containsExactly( + SkillSuiteBundleRelationshipChange.UPDATED, + SkillSuiteBundleRelationshipChange.UPDATED); + } + + @Test + void oneHundredExistingMembersUseOnlyBoundedBatchReads() throws Exception { + Namespace global = namespace(1L, "global"); + StringBuilder memberYaml = new StringBuilder(); + Map files = new LinkedHashMap<>(); + List skills = new ArrayList<>(); + List versions = new ArrayList<>(); + List storedFiles = new ArrayList<>(); + for (int index = 0; index < 100; index++) { + String slug = "member-" + index; + memberYaml.append(packageMember(slug, false)); + String path = "skills/" + slug + "/SKILL.md"; + String content = skillMd(slug, "1.0.0"); + files.put(path, content); + long skillId = 1_000L + index; + long versionId = 2_000L + index; + skills.add(skill(skillId, 1L, slug, "actor", SkillVisibility.PUBLIC, versionId)); + versions.add(version(versionId, skillId, "1.0.0", SkillVersionStatus.PUBLISHED, true)); + storedFiles.add(skillFile(versionId, "SKILL.md", sha256(content))); + } + when(namespaceRepository.findBySlugIn(anyList())).thenReturn(List.of(global)); + when(suiteRepository.findByNamespaceIdAndSlug(1L, "large-suite")).thenReturn(Optional.empty()); + when(skillRepository.findByNamespaceIdInAndSlugIn(anyList(), anyList())).thenReturn(skills); + when(skillVersionRepository.findBySkillIdInAndStatus(anyList(), eq(SkillVersionStatus.PENDING_REVIEW))) + .thenReturn(List.of()); + when(skillVersionRepository.findBySkillIdInAndVersionIn(anyList(), anyList())).thenReturn(versions); + when(skillVersionRepository.findByIdIn(anyList())).thenReturn(versions); + when(skillFileRepository.findByVersionIdIn(anyList())).thenReturn(storedFiles); + + SkillSuiteBundlePreviewPlanner.PreviewPlan result = planner.plan( + analyze(manifest("large-suite", "CREATE", null, memberYaml.toString(), + "@global/member-0", true), files), + "actor", Map.of(1L, NamespaceRole.MEMBER), Set.of()); + + assertThat(result.confirmable()).isTrue(); + assertThat(result.members()).hasSize(100) + .allSatisfy(member -> assertThat(member.publishAction()) + .isEqualTo(SkillSuiteBundlePublishAction.REUSE_VERSION)); + verify(namespaceRepository, times(1)).findBySlugIn(anyList()); + verify(skillRepository, times(1)).findByNamespaceIdInAndSlugIn(anyList(), anyList()); + verify(skillVersionRepository, times(1)) + .findBySkillIdInAndStatus(anyList(), eq(SkillVersionStatus.PENDING_REVIEW)); + verify(skillVersionRepository, times(1)).findBySkillIdInAndVersionIn(anyList(), anyList()); + verify(skillVersionRepository, times(1)).findByIdIn(anyList()); + verify(skillFileRepository, times(1)).findByVersionIdIn(anyList()); + } + + private SkillSuiteBundlePackageAnalyzer.BundleAnalysis analyze( + String manifest, Map memberFiles + ) throws Exception { + List entries = new ArrayList<>(); + entries.add(staged("SUITE.yaml", manifest)); + for (Map.Entry file : memberFiles.entrySet()) { + entries.add(staged(file.getKey(), file.getValue())); + } + SkillMetadataParser metadataParser = new SkillMetadataParser(); + return new SkillSuiteBundlePackageAnalyzer( + new SkillSuiteBundleManifestParser(), metadataParser, + new SkillPackageValidator(metadataParser)).analyze(entries); + } + + private SkillSuiteBundleStagedEntry staged(String path, String content) throws Exception { + byte[] bytes = content.getBytes(StandardCharsets.UTF_8); + return new SkillSuiteBundleStagedEntry( + path, bytes.length, "text/plain", sha256(content), "temp/" + path, + () -> new ByteArrayInputStream(bytes)); + } + + private Namespace namespace(Long id, String slug) { + Namespace namespace = mock(Namespace.class); + when(namespace.getId()).thenReturn(id); + when(namespace.getSlug()).thenReturn(slug); + when(namespace.getStatus()).thenReturn(NamespaceStatus.ACTIVE); + return namespace; + } + + private Skill skill( + Long id, Long namespaceId, String slug, String owner, + SkillVisibility visibility, Long latestVersionId + ) { + Skill skill = mock(Skill.class); + when(skill.getId()).thenReturn(id); + when(skill.getNamespaceId()).thenReturn(namespaceId); + when(skill.getSlug()).thenReturn(slug); + when(skill.getOwnerId()).thenReturn(owner); + when(skill.getVisibility()).thenReturn(visibility); + when(skill.getStatus()).thenReturn(SkillStatus.ACTIVE); + when(skill.getLatestVersionId()).thenReturn(latestVersionId); + return skill; + } + + private SkillVersion version( + Long id, Long skillId, String value, SkillVersionStatus status, boolean downloadReady + ) { + SkillVersion version = mock(SkillVersion.class); + when(version.getId()).thenReturn(id); + when(version.getSkillId()).thenReturn(skillId); + when(version.getVersion()).thenReturn(value); + when(version.getStatus()).thenReturn(status); + when(version.isDownloadReady()).thenReturn(downloadReady); + return version; + } + + private SkillFile skillFile(Long versionId, String path, String sha256) { + SkillFile file = mock(SkillFile.class); + when(file.getVersionId()).thenReturn(versionId); + when(file.getFilePath()).thenReturn(path); + when(file.getSha256()).thenReturn(sha256); + return file; + } + + private SkillSuiteVersionMember baselineMember( + Long suiteVersionId, Long skillId, Long skillVersionId, + String namespace, String slug, String version, int position, boolean entry + ) { + return new SkillSuiteVersionMember( + suiteVersionId, + new SkillSuiteMemberSelection( + skillId, skillVersionId, namespace, slug, version, "sha256:test"), + position, entry); + } + + private SkillSuite updateSuite() { + SkillSuite suite = mock(SkillSuite.class); + when(suite.getId()).thenReturn(50L); + when(suite.getNamespaceId()).thenReturn(1L); + when(suite.getCreatedBy()).thenReturn("actor"); + when(suite.getStatus()).thenReturn(SkillSuiteStatus.ACTIVE); + return suite; + } + + private SkillSuiteVersion updateBaseVersion() { + SkillSuiteVersion base = mock(SkillSuiteVersion.class); + when(base.getId()).thenReturn(60L); + when(base.getStatus()).thenReturn(SkillSuiteVersionStatus.PUBLISHED); + when(base.getDisplayName()).thenReturn("Test Suite"); + when(base.getSummary()).thenReturn("Suite summary"); + when(base.getOverview()).thenReturn("Suite overview"); + when(base.getVisibility()).thenReturn(SkillVisibility.PUBLIC); + return base; + } + + private void stubUpdate( + String suiteSlug, + Namespace namespace, + SkillSuite suite, + SkillSuiteVersion base, + List baseline, + List skills, + List versions + ) { + when(namespaceRepository.findBySlugIn(anyList())).thenReturn(List.of(namespace)); + when(suiteRepository.findByNamespaceIdAndSlug(1L, suiteSlug)).thenReturn(Optional.of(suite)); + when(suiteVersionRepository.findBySuiteIdAndVersion(50L, "1.0.0")).thenReturn(Optional.of(base)); + when(suiteVersionRepository.findBySuiteIdAndVersion(50L, "1.1.0")).thenReturn(Optional.empty()); + when(suiteMemberRepository.findBySuiteVersionIdOrderByPosition(60L)).thenReturn(baseline); + when(skillRepository.findByNamespaceIdInAndSlugIn(anyList(), anyList())).thenReturn(skills); + when(skillVersionRepository.findBySkillIdInAndStatus(anyList(), eq(SkillVersionStatus.PENDING_REVIEW))) + .thenReturn(List.of()); + when(skillVersionRepository.findBySkillIdInAndVersionIn(anyList(), anyList())).thenReturn(versions); + when(skillVersionRepository.findByIdIn(anyList())).thenReturn(versions); + when(skillFileRepository.findByVersionIdIn(anyList())).thenReturn(List.of()); + } + + private String manifest( + String suiteSlug, + String mode, + String baseVersion, + String members, + String entry, + boolean includePresentation + ) { + String base = baseVersion == null ? "" : " baseVersion: " + baseVersion + "\n"; + String presentation = includePresentation + ? " summary: Suite summary\n overview: Suite overview\n" + : ""; + return """ + apiVersion: skillhub.iflytek.com/v1alpha1 + kind: SkillSuiteBundle + metadata: + namespace: global + slug: %s + spec: + mode: %s + %s version: %s + displayName: Test Suite + %s visibility: PUBLIC + entry: "%s" + members: + %s + """.formatted(suiteSlug, mode, base, "UPDATE".equals(mode) ? "1.1.0" : "1.0.0", + presentation, entry, indent(members, 4)); + } + + private String packageMember(String slug, boolean visibility) { + return " - skill: \"@global/" + slug + "\"\n" + + " package:\n" + + " path: skills/" + slug + "\n" + + (visibility ? " visibility: PUBLIC\n" : ""); + } + + private String indent(String value, int spaces) { + String prefix = " ".repeat(spaces); + return value.stripTrailing().lines().map(line -> prefix + line).collect(java.util.stream.Collectors.joining("\n")); + } + + private String skillMd(String name, String version) { + return """ + --- + name: %s + description: Skill description + version: %s + --- + Instructions. + """.formatted(name, version); + } + + private String sha256(String content) { + try { + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256") + .digest(content.getBytes(StandardCharsets.UTF_8))); + } catch (Exception exception) { + throw new IllegalStateException(exception); + } + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/bundle/SkillSuiteBundlePreviewRevalidationServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/bundle/SkillSuiteBundlePreviewRevalidationServiceTest.java new file mode 100644 index 00000000..9354fa46 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/bundle/SkillSuiteBundlePreviewRevalidationServiceTest.java @@ -0,0 +1,148 @@ +package com.iflytek.skillhub.service.bundle; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; +import com.iflytek.skillhub.domain.skill.SkillVisibility; +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.SkillSuiteBundleMemberSourceType; +import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleMode; +import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundlePreviewSession; +import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundlePublishAction; +import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleRelationshipChange; +import com.iflytek.skillhub.storage.ObjectStorageService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class SkillSuiteBundlePreviewRevalidationServiceTest { + + private static final Instant NOW = Instant.parse("2026-09-11T08:00:00Z"); + private static final TypeReference> JSON_OBJECT = new TypeReference<>() { }; + + private final ObjectMapper objectMapper = new ObjectMapper(); + private SkillSuiteBundlePreviewPlanner planner; + private ObjectStorageService objectStorageService; + private SkillSuiteBundlePreviewRevalidationService service; + + @BeforeEach + void setUp() { + planner = mock(SkillSuiteBundlePreviewPlanner.class); + objectStorageService = mock(ObjectStorageService.class); + service = new SkillSuiteBundlePreviewRevalidationService(planner, objectStorageService, objectMapper); + } + + @Test + void replansFromPersistedMemberFactsWithoutExtractingTheArchiveAgain() { + SkillSuiteBundleManifest manifest = manifest(); + SkillSuiteBundlePreviewPlanner.PreviewPlan plan = plan(); + when(objectStorageService.exists("archive.zip")).thenReturn(true); + when(planner.plan(any(), any(), any(), any())).thenReturn(plan); + + var result = service.requireUnchanged(preview(manifest, plan), "actor", Map.of(), Set.of()); + + assertThat(result.plan()).isEqualTo(plan); + ArgumentCaptor analysis = + ArgumentCaptor.forClass(SkillSuiteBundlePackageAnalyzer.BundleAnalysis.class); + verify(planner).plan(analysis.capture(), any(), any(), any()); + verify(objectStorageService).exists("archive.zip"); + assertThat(analysis.getValue().packageMembers()).singleElement().satisfies(member -> { + assertThat(member.directory()).isEqualTo("skills/member"); + assertThat(member.metadata().version()).isEqualTo("1.0.0"); + assertThat(member.fingerprint()).isEqualTo("sha256:member"); + }); + } + + @Test + void missingArchiveRequiresANewPreviewBeforePlanning() { + when(objectStorageService.exists("archive.zip")).thenReturn(false); + + assertStateChanged(preview(manifest(), plan())); + + verify(planner, never()).plan(any(), any(), any(), any()); + } + + @Test + void changedLivePlanRequiresANewPreview() { + SkillSuiteBundlePreviewPlanner.PreviewPlan original = plan(); + SkillSuiteBundlePreviewPlanner.PreviewPlan changed = new SkillSuiteBundlePreviewPlanner.PreviewPlan( + original.mode(), original.target(), original.targetNamespaceId(), original.targetSuiteId(), + original.baseSuiteVersionId(), original.targetVersion(), original.displayName(), + original.summary(), original.overview(), original.visibility(), original.members(), + original.removedMembers(), List.of("state changed"), original.warnings(), original.warningDigest()); + when(objectStorageService.exists("archive.zip")).thenReturn(true); + when(planner.plan(any(), any(), any(), any())).thenReturn(changed); + + assertStateChanged(preview(manifest(), original)); + } + + private void assertStateChanged(SkillSuiteBundlePreviewSession preview) { + assertThatThrownBy(() -> service.requireUnchanged(preview, "actor", Map.of(), Set.of())) + .isInstanceOf(DomainBadRequestException.class) + .extracting("messageCode") + .isEqualTo("error.suite.bundle.preview.stateChanged"); + } + + private SkillSuiteBundlePreviewSession preview( + SkillSuiteBundleManifest manifest, + SkillSuiteBundlePreviewPlanner.PreviewPlan plan + ) { + return new SkillSuiteBundlePreviewSession( + "preview-1", "actor", SkillSuiteBundleMode.CREATE, 1L, "suite", null, null, + "1.0.0", "archive.zip", "a".repeat(64), + objectMapper.convertValue(manifest, JSON_OBJECT), objectMapper.convertValue(plan, JSON_OBJECT), + "warning-digest", NOW.plusSeconds(300), NOW.minusSeconds(60)); + } + + private SkillSuiteBundlePreviewPlanner.PreviewPlan plan() { + var member = new SkillSuiteBundlePreviewPlanner.MemberPlan( + new SkillSuiteBundleCoordinate("global", "member"), SkillSuiteBundleMemberSourceType.PACKAGE, + SkillSuiteBundleRelationshipChange.ADDED, SkillSuiteBundlePublishAction.CREATE_SKILL, + null, null, SkillVisibility.PUBLIC, "1.0.0", "sha256:member", + List.of(new SkillSuiteBundlePackageAnalyzer.StagedMemberFile( + "SKILL.md", 100, "text/markdown", "b".repeat(64), "staged/member/SKILL.md")), + List.of(), List.of()); + return new SkillSuiteBundlePreviewPlanner.PreviewPlan( + SkillSuiteBundleMode.CREATE, new SkillSuiteBundleCoordinate("global", "suite"), + 1L, null, null, "1.0.0", "Suite", "Summary", "Overview", + SkillVisibility.PUBLIC, List.of(member), List.of(), List.of(), List.of(), "warning-digest"); + } + + private SkillSuiteBundleManifest manifest() { + return new SkillSuiteBundleManifestParser().parse(""" + apiVersion: skillhub.iflytek.com/v1alpha1 + kind: SkillSuiteBundle + metadata: + namespace: global + slug: suite + spec: + mode: CREATE + version: 1.0.0 + displayName: Suite + summary: Summary + overview: Overview + visibility: PUBLIC + entry: "@global/member" + members: + - skill: "@global/member" + package: + path: skills/member + visibility: PUBLIC + """); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/bundle/SkillSuiteBundleResponseMapperTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/bundle/SkillSuiteBundleResponseMapperTest.java new file mode 100644 index 00000000..0b5ed704 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/bundle/SkillSuiteBundleResponseMapperTest.java @@ -0,0 +1,49 @@ +package com.iflytek.skillhub.service.bundle; + +import com.iflytek.skillhub.domain.skill.SkillVisibility; +import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleCoordinate; +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.junit.jupiter.api.Test; + +import java.time.Instant; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class SkillSuiteBundleResponseMapperTest { + + @Test + void exposesPublicationDiffButNotTemporaryObjectKeys() { + SkillSuiteBundlePreviewPlanner.MemberPlan member = new SkillSuiteBundlePreviewPlanner.MemberPlan( + new SkillSuiteBundleCoordinate("global", "member"), SkillSuiteBundleMemberSourceType.PACKAGE, + SkillSuiteBundleRelationshipChange.ADDED, SkillSuiteBundlePublishAction.CREATE_SKILL, + null, null, SkillVisibility.PUBLIC, "1.0.0", "sha256:member", + List.of(new SkillSuiteBundlePackageAnalyzer.StagedMemberFile( + "SKILL.md", 100, "text/markdown", "a".repeat(64), + "temporary/suite-bundles/private/entries/1")), + List.of(), List.of("credential warning")); + SkillSuiteBundlePreviewPlanner.PreviewPlan plan = new SkillSuiteBundlePreviewPlanner.PreviewPlan( + SkillSuiteBundleMode.CREATE, new SkillSuiteBundleCoordinate("global", "suite"), + 1L, null, null, "1.0.0", "Suite", "Summary", "Overview", + SkillVisibility.PUBLIC, List.of(member), List.of(), List.of(), + List.of("@global/member: credential warning"), "warning-digest"); + Instant expiresAt = Instant.parse("2026-09-11T09:00:00Z"); + + var response = new SkillSuiteBundleResponseMapper().toResponse( + new SkillSuiteBundlePreviewAppService.PreviewOutcome( + "preview-1", expiresAt, null, plan)); + + assertThat(response.previewToken()).isEqualTo("preview-1"); + assertThat(response.expiresAt()).isEqualTo(expiresAt); + assertThat(response.target().coordinate()).isEqualTo("@global/suite"); + assertThat(response.members()).singleElement().satisfies(mapped -> { + assertThat(mapped.coordinate()).isEqualTo("@global/member"); + assertThat(mapped.publishAction()).isEqualTo(SkillSuiteBundlePublishAction.CREATE_SKILL); + assertThat(mapped.warnings()).containsExactly("credential warning"); + }); + assertThat(response.toString()).doesNotContain("temporary/suite-bundles"); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/bundle/SkillSuiteBundleStagedCleanupServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/bundle/SkillSuiteBundleStagedCleanupServiceTest.java new file mode 100644 index 00000000..e7782a3a --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/bundle/SkillSuiteBundleStagedCleanupServiceTest.java @@ -0,0 +1,166 @@ +package com.iflytek.skillhub.service.bundle; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.iflytek.skillhub.domain.skill.SkillVisibility; +import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleCoordinate; +import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleExecutionOperation; +import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleExecutionOperationRepository; +import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleMemberSourceType; +import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleMode; +import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundlePreviewSession; +import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundlePreviewSessionRepository; +import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundlePublishAction; +import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleRelationshipChange; +import com.iflytek.skillhub.storage.ObjectStorageService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class SkillSuiteBundleStagedCleanupServiceTest { + + private static final Instant NOW = Instant.parse("2026-09-11T08:00:00Z"); + private SkillSuiteBundlePreviewSessionRepository previewRepository; + private SkillSuiteBundleExecutionOperationRepository operationRepository; + private ObjectStorageService storage; + private ObjectMapper objectMapper; + private SkillSuiteBundleStagedCleanupService service; + + @BeforeEach + void setUp() { + previewRepository = mock(SkillSuiteBundlePreviewSessionRepository.class); + operationRepository = mock(SkillSuiteBundleExecutionOperationRepository.class); + storage = mock(ObjectStorageService.class); + objectMapper = mock(ObjectMapper.class); + service = new SkillSuiteBundleStagedCleanupService( + previewRepository, operationRepository, storage, objectMapper, + Clock.fixed(NOW, ZoneOffset.UTC)); + } + + @Test + void deletesOnlyBoundStagingKeysThenRemovesExpiredPreviewRecord() { + SkillSuiteBundlePreviewSession preview = preview(); + preview.markExpired(); + when(previewRepository.findByIdForUpdate("preview")).thenReturn(Optional.of(preview)); + when(operationRepository.findByPreviewToken("preview")).thenReturn(Optional.empty()); + when(objectMapper.convertValue(preview.getPlan(), SkillSuiteBundlePreviewPlanner.PreviewPlan.class)) + .thenReturn(plan()); + + service.cleanupExpiredPreview("preview"); + + verify(storage).deleteObjects(List.of( + "temporary/suite-bundles/staging/bundle.zip", + "temporary/suite-bundles/staging/skills/member/SKILL.md")); + verify(previewRepository).delete(preview); + assertThat(preview.getStagedObjectsCleanedAt()).isEqualTo(NOW); + } + + @Test + void storageFailureRetainsRetryableEvidenceAndPreviewRow() { + SkillSuiteBundlePreviewSession preview = preview(); + preview.markExpired(); + when(previewRepository.findByIdForUpdate("preview")).thenReturn(Optional.of(preview)); + when(operationRepository.findByPreviewToken("preview")).thenReturn(Optional.empty()); + when(objectMapper.convertValue(preview.getPlan(), SkillSuiteBundlePreviewPlanner.PreviewPlan.class)) + .thenReturn(plan()); + doThrow(new IllegalStateException("storage unavailable")).when(storage) + .deleteObjects(org.mockito.ArgumentMatchers.anyList()); + + service.cleanupExpiredPreview("preview"); + + assertThat(preview.getStagedCleanupFailureCode()).isEqualTo("STORAGE_DELETE_FAILED"); + assertThat(preview.getStagedCleanupFailedAt()).isEqualTo(NOW); + assertThat(preview.getStagedObjectsCleanedAt()).isNull(); + verify(previewRepository, never()).delete(preview); + verify(previewRepository).save(preview); + } + + @Test + void activeOperationIsNeverCleanedEvenWhenItsOriginalPreviewTtlPassed() { + SkillSuiteBundleExecutionOperation operation = operation(); + when(operationRepository.findByIdForUpdate("operation")).thenReturn(Optional.of(operation)); + + service.cleanupTerminalOperation("operation"); + + verify(storage, never()).deleteObjects(org.mockito.ArgumentMatchers.anyList()); + assertThat(operation.getStagedObjectsCleanedAt()).isNull(); + } + + @Test + void terminalOperationCleansStagingWithoutDeletingPublishedMemberRecords() { + SkillSuiteBundleExecutionOperation operation = operation(); + operation.cancel(NOW.minusSeconds(1)); + when(operationRepository.findByIdForUpdate("operation")).thenReturn(Optional.of(operation)); + when(objectMapper.convertValue(operation.getPlan(), SkillSuiteBundlePreviewPlanner.PreviewPlan.class)) + .thenReturn(plan()); + + service.cleanupTerminalOperation("operation"); + + verify(storage).deleteObjects(List.of( + "temporary/suite-bundles/staging/bundle.zip", + "temporary/suite-bundles/staging/skills/member/SKILL.md")); + assertThat(operation.getStagedObjectsCleanedAt()).isEqualTo(NOW); + verify(operationRepository).save(operation); + } + + @Test + void refusesToDeleteAnyKeyOutsideTheDedicatedStagingPrefix() { + SkillSuiteBundleExecutionOperation operation = operation(); + operation.cancel(NOW.minusSeconds(1)); + org.springframework.test.util.ReflectionTestUtils.setField( + operation, "archiveObjectKey", "packages/1/2/bundle.zip"); + when(operationRepository.findByIdForUpdate("operation")).thenReturn(Optional.of(operation)); + when(objectMapper.convertValue(operation.getPlan(), SkillSuiteBundlePreviewPlanner.PreviewPlan.class)) + .thenReturn(plan()); + + service.cleanupTerminalOperation("operation"); + + verify(storage, never()).deleteObjects(org.mockito.ArgumentMatchers.anyList()); + assertThat(operation.getStagedCleanupFailureCode()).isEqualTo("STAGED_KEY_SCOPE_INVALID"); + } + + private SkillSuiteBundlePreviewSession preview() { + return new SkillSuiteBundlePreviewSession( + "preview", "actor", SkillSuiteBundleMode.CREATE, 1L, "suite", null, null, + "1.0.0", "temporary/suite-bundles/staging/bundle.zip", "a".repeat(64), + Map.of("manifest", "value"), Map.of("plan", "value"), "digest", + NOW.minusSeconds(1), NOW.minusSeconds(60)); + } + + private SkillSuiteBundleExecutionOperation operation() { + return new SkillSuiteBundleExecutionOperation( + "operation", "preview", "request", "actor", SkillSuiteBundleMode.CREATE, + 1L, "suite", null, null, "1.0.0", + "temporary/suite-bundles/staging/bundle.zip", "a".repeat(64), + Map.of("plan", "value"), "digest", NOW.minusSeconds(60)); + } + + private SkillSuiteBundlePreviewPlanner.PreviewPlan plan() { + var file = new SkillSuiteBundlePackageAnalyzer.StagedMemberFile( + "SKILL.md", 12, "text/markdown", "b".repeat(64), + "temporary/suite-bundles/staging/skills/member/SKILL.md"); + var member = new SkillSuiteBundlePreviewPlanner.MemberPlan( + new SkillSuiteBundleCoordinate("global", "member"), + SkillSuiteBundleMemberSourceType.PACKAGE, + SkillSuiteBundleRelationshipChange.ADDED, + SkillSuiteBundlePublishAction.CREATE_SKILL, + null, null, SkillVisibility.PRIVATE, "1.0.0", "sha256:fingerprint", + List.of(file), List.of(), List.of()); + return new SkillSuiteBundlePreviewPlanner.PreviewPlan( + SkillSuiteBundleMode.CREATE, new SkillSuiteBundleCoordinate("global", "suite"), + 1L, null, null, "1.0.0", "Suite", "Summary", "Overview", + SkillVisibility.PRIVATE, List.of(member), List.of(), List.of(), List.of(), "digest"); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/task/SkillSuiteBundleRecoveryTaskTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/task/SkillSuiteBundleRecoveryTaskTest.java new file mode 100644 index 00000000..822b025e --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/task/SkillSuiteBundleRecoveryTaskTest.java @@ -0,0 +1,37 @@ +package com.iflytek.skillhub.task; + +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.service.bundle.SkillSuiteBundleCoordinator; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Set; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class SkillSuiteBundleRecoveryTaskTest { + + @Test + void advancesOnlyTheBoundedRecoverableQueryResults() { + SkillSuiteBundleExecutionOperationRepository repository = + mock(SkillSuiteBundleExecutionOperationRepository.class); + SkillSuiteBundleCoordinator coordinator = mock(SkillSuiteBundleCoordinator.class); + SkillSuiteBundleExecutionOperation first = mock(SkillSuiteBundleExecutionOperation.class); + SkillSuiteBundleExecutionOperation second = mock(SkillSuiteBundleExecutionOperation.class); + when(first.getOperationId()).thenReturn("operation-a"); + when(second.getOperationId()).thenReturn("operation-b"); + when(repository.findTop100ByStatusInOrderByUpdatedAtAsc(Set.of( + SkillSuiteBundleOperationStatus.RUNNING, + SkillSuiteBundleOperationStatus.WAITING_FOR_MEMBERS))) + .thenReturn(List.of(first, second)); + + new SkillSuiteBundleRecoveryTask(repository, coordinator).recover(); + + verify(coordinator).advance("operation-a"); + verify(coordinator).advance("operation-b"); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/task/SkillSuiteBundleStagedCleanupTaskTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/task/SkillSuiteBundleStagedCleanupTaskTest.java new file mode 100644 index 00000000..1a7edb24 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/task/SkillSuiteBundleStagedCleanupTaskTest.java @@ -0,0 +1,46 @@ +package com.iflytek.skillhub.task; + +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.service.bundle.SkillSuiteBundleStagedCleanupService; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Set; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class SkillSuiteBundleStagedCleanupTaskTest { + + @Test + void expiresPreviewsAndProcessesOnlyBoundedCleanupCandidates() { + SkillSuiteBundlePreviewSessionRepository previews = mock(SkillSuiteBundlePreviewSessionRepository.class); + SkillSuiteBundleExecutionOperationRepository operations = + mock(SkillSuiteBundleExecutionOperationRepository.class); + SkillSuiteBundleStagedCleanupService service = mock(SkillSuiteBundleStagedCleanupService.class); + SkillSuiteBundlePreviewSession preview = mock(SkillSuiteBundlePreviewSession.class); + SkillSuiteBundleExecutionOperation operation = mock(SkillSuiteBundleExecutionOperation.class); + when(preview.getToken()).thenReturn("preview"); + when(operation.getOperationId()).thenReturn("operation"); + when(previews.findTop100ByStatusAndStagedObjectsCleanedAtIsNullOrderByExpiresAtAsc( + SkillSuiteBundlePreviewStatus.EXPIRED)).thenReturn(List.of(preview)); + Set terminal = Set.of( + SkillSuiteBundleOperationStatus.REPREVIEW_REQUIRED, + SkillSuiteBundleOperationStatus.SUITE_DRAFT_CREATED, + SkillSuiteBundleOperationStatus.CANCELLED); + when(operations.findTop100ByStatusInAndStagedObjectsCleanedAtIsNullOrderByCompletedAtAsc(terminal)) + .thenReturn(List.of(operation)); + + new SkillSuiteBundleStagedCleanupTask(previews, operations, service).cleanup(); + + verify(service).expireReadyPreviews(); + verify(service).cleanupExpiredPreview("preview"); + verify(service).cleanupTerminalOperation("operation"); + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistry.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistry.java index eac1e955..06b01000 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistry.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistry.java @@ -83,6 +83,9 @@ public class RouteSecurityPolicyRegistry { RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/web/skills/*/*/tags/*/file"), RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/web/labels"), RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/v1/suites/*/*"), + RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/v1/suites/*/*/labels"), + RouteAuthorizationPolicy.authenticated(HttpMethod.PUT, "/api/v1/suites/*/*/labels/*"), + RouteAuthorizationPolicy.authenticated(HttpMethod.DELETE, "/api/v1/suites/*/*/labels/*"), RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/v1/resources"), RouteAuthorizationPolicy.authenticated(HttpMethod.GET, "/api/v1/me/suites"), RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/v1/suites/*/*/versions"), @@ -102,7 +105,15 @@ public class RouteSecurityPolicyRegistry { RouteAuthorizationPolicy.authenticated(HttpMethod.DELETE, "/api/v1/suites/*"), RouteAuthorizationPolicy.authenticated(HttpMethod.POST, "/api/v1/suites/reviews/*/approve"), RouteAuthorizationPolicy.authenticated(HttpMethod.POST, "/api/v1/suites/reviews/*/reject"), + RouteAuthorizationPolicy.authenticated(HttpMethod.POST, "/api/v1/suite-bundles/preview"), + RouteAuthorizationPolicy.authenticated(HttpMethod.POST, "/api/v1/suite-bundles/previews/*/confirm"), + RouteAuthorizationPolicy.authenticated(HttpMethod.GET, "/api/v1/suite-bundles/operations/*"), + RouteAuthorizationPolicy.authenticated(HttpMethod.POST, "/api/v1/suite-bundles/operations/*/cancel"), + RouteAuthorizationPolicy.authenticated(HttpMethod.POST, "/api/v1/suite-bundles/operations/*/retry"), RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/web/suites/*/*"), + RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/web/suites/*/*/labels"), + RouteAuthorizationPolicy.authenticated(HttpMethod.PUT, "/api/web/suites/*/*/labels/*"), + RouteAuthorizationPolicy.authenticated(HttpMethod.DELETE, "/api/web/suites/*/*/labels/*"), RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/web/resources"), RouteAuthorizationPolicy.authenticated(HttpMethod.GET, "/api/web/me/suites"), RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/web/suites/*/*/versions"), @@ -122,6 +133,11 @@ public class RouteSecurityPolicyRegistry { RouteAuthorizationPolicy.authenticated(HttpMethod.DELETE, "/api/web/suites/*"), RouteAuthorizationPolicy.authenticated(HttpMethod.POST, "/api/web/suites/reviews/*/approve"), RouteAuthorizationPolicy.authenticated(HttpMethod.POST, "/api/web/suites/reviews/*/reject"), + RouteAuthorizationPolicy.authenticated(HttpMethod.POST, "/api/web/suite-bundles/preview"), + RouteAuthorizationPolicy.authenticated(HttpMethod.POST, "/api/web/suite-bundles/previews/*/confirm"), + RouteAuthorizationPolicy.authenticated(HttpMethod.GET, "/api/web/suite-bundles/operations/*"), + RouteAuthorizationPolicy.authenticated(HttpMethod.POST, "/api/web/suite-bundles/operations/*/cancel"), + RouteAuthorizationPolicy.authenticated(HttpMethod.POST, "/api/web/suite-bundles/operations/*/retry"), RouteAuthorizationPolicy.roles(HttpMethod.DELETE, "/api/v1/skills/id/*", "SUPER_ADMIN"), RouteAuthorizationPolicy.roles(HttpMethod.DELETE, "/api/v1/skills/*/*", "SUPER_ADMIN"), RouteAuthorizationPolicy.authenticated(HttpMethod.DELETE, "/api/web/skills/id/*"), @@ -172,6 +188,8 @@ public class RouteSecurityPolicyRegistry { ApiTokenPolicy.allow(HttpMethod.GET, "/api/web/namespaces"), ApiTokenPolicy.allow(HttpMethod.GET, "/api/web/namespaces/*"), ApiTokenPolicy.allow(HttpMethod.GET, "/api/v1/suites/**"), + ApiTokenPolicy.require(HttpMethod.PUT, "/api/v1/suites/*/*/labels/*", "skill:publish"), + ApiTokenPolicy.require(HttpMethod.DELETE, "/api/v1/suites/*/*/labels/*", "skill:publish"), ApiTokenPolicy.allow(HttpMethod.GET, "/api/v1/resources"), ApiTokenPolicy.require(HttpMethod.GET, "/api/v1/me/suites", "skill:read"), ApiTokenPolicy.allow(HttpMethod.POST, "/api/v1/suites/*/*/install-plan"), @@ -187,7 +205,14 @@ public class RouteSecurityPolicyRegistry { ApiTokenPolicy.require(HttpMethod.POST, "/api/v1/suites/*/archive", "skill:publish"), ApiTokenPolicy.require(HttpMethod.POST, "/api/v1/suites/*/unarchive", "skill:publish"), ApiTokenPolicy.require(HttpMethod.DELETE, "/api/v1/suites/*", "skill:delete"), + ApiTokenPolicy.require(HttpMethod.POST, "/api/v1/suite-bundles/preview", "skill:publish"), + ApiTokenPolicy.require(HttpMethod.POST, "/api/v1/suite-bundles/previews/*/confirm", "skill:publish"), + ApiTokenPolicy.require(HttpMethod.GET, "/api/v1/suite-bundles/operations/*", "skill:publish"), + ApiTokenPolicy.require(HttpMethod.POST, "/api/v1/suite-bundles/operations/*/cancel", "skill:publish"), + ApiTokenPolicy.require(HttpMethod.POST, "/api/v1/suite-bundles/operations/*/retry", "skill:publish"), ApiTokenPolicy.allow(HttpMethod.GET, "/api/web/suites/**"), + ApiTokenPolicy.require(HttpMethod.PUT, "/api/web/suites/*/*/labels/*", "skill:publish"), + ApiTokenPolicy.require(HttpMethod.DELETE, "/api/web/suites/*/*/labels/*", "skill:publish"), ApiTokenPolicy.allow(HttpMethod.GET, "/api/web/resources"), ApiTokenPolicy.require(HttpMethod.GET, "/api/web/me/suites", "skill:read"), ApiTokenPolicy.allow(HttpMethod.POST, "/api/web/suites/*/*/install-plan"), @@ -203,6 +228,11 @@ public class RouteSecurityPolicyRegistry { ApiTokenPolicy.require(HttpMethod.POST, "/api/web/suites/*/archive", "skill:publish"), ApiTokenPolicy.require(HttpMethod.POST, "/api/web/suites/*/unarchive", "skill:publish"), ApiTokenPolicy.require(HttpMethod.DELETE, "/api/web/suites/*", "skill:delete"), + ApiTokenPolicy.require(HttpMethod.POST, "/api/web/suite-bundles/preview", "skill:publish"), + ApiTokenPolicy.require(HttpMethod.POST, "/api/web/suite-bundles/previews/*/confirm", "skill:publish"), + ApiTokenPolicy.require(HttpMethod.GET, "/api/web/suite-bundles/operations/*", "skill:publish"), + ApiTokenPolicy.require(HttpMethod.POST, "/api/web/suite-bundles/operations/*/cancel", "skill:publish"), + ApiTokenPolicy.require(HttpMethod.POST, "/api/web/suite-bundles/operations/*/retry", "skill:publish"), ApiTokenPolicy.allow(HttpMethod.GET, "/api/v1/resolve/**"), ApiTokenPolicy.allow(HttpMethod.GET, "/api/v1/download"), ApiTokenPolicy.allow(HttpMethod.GET, "/api/v1/download/**"), diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistryTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistryTest.java index c6ffd3d1..1fc54c60 100644 --- a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistryTest.java +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistryTest.java @@ -74,6 +74,12 @@ class RouteSecurityPolicyRegistryTest { registry.accessLevel("GET", "/api/web/me/suites")); assertEquals(RouteSecurityPolicyRegistry.AccessLevel.PERMIT_ALL, registry.accessLevel("GET", "/api/v1/suites/global/starter")); + assertEquals(RouteSecurityPolicyRegistry.AccessLevel.PERMIT_ALL, + registry.accessLevel("GET", "/api/v1/suites/global/starter/labels")); + assertEquals(RouteSecurityPolicyRegistry.AccessLevel.AUTHENTICATED, + registry.accessLevel("PUT", "/api/v1/suites/global/starter/labels/official")); + assertEquals(RouteSecurityPolicyRegistry.AccessLevel.AUTHENTICATED, + registry.accessLevel("DELETE", "/api/web/suites/global/starter/labels/official")); assertEquals(RouteSecurityPolicyRegistry.AccessLevel.PERMIT_ALL, registry.accessLevel("POST", "/api/v1/suites/global/starter/install-plan")); assertEquals(RouteSecurityPolicyRegistry.AccessLevel.AUTHENTICATED, @@ -88,6 +94,13 @@ class RouteSecurityPolicyRegistryTest { "GET", "/api/v1/suites/global/starter", Set.of()).allowed()); assertTrue(registry.authorizeApiToken( "POST", "/api/v1/suites/global/starter/install-plan", Set.of()).allowed()); + var deniedAttachLabel = registry.authorizeApiToken( + "PUT", "/api/v1/suites/global/starter/labels/official", Set.of("skill:read")); + var allowedAttachLabel = registry.authorizeApiToken( + "PUT", "/api/v1/suites/global/starter/labels/official", Set.of("skill:publish")); + assertFalse(deniedAttachLabel.allowed()); + assertEquals("skill:publish", deniedAttachLabel.requiredScope()); + assertTrue(allowedAttachLabel.allowed()); var deniedCreate = registry.authorizeApiToken("POST", "/api/v1/suites", Set.of("skill:read")); var allowedCreate = registry.authorizeApiToken("POST", "/api/v1/suites", Set.of("skill:publish")); @@ -302,6 +315,32 @@ class RouteSecurityPolicyRegistryTest { "Authorization routes with no API-token policy and no session-only declaration: " + gaps); } + @Test + void suiteBundleWritesRequireAuthenticationAndPublishTokenScope() { + for (String path : List.of( + "/api/v1/suite-bundles/preview", + "/api/v1/suite-bundles/previews/token/confirm", + "/api/v1/suite-bundles/operations/operation/cancel", + "/api/v1/suite-bundles/operations/operation/retry", + "/api/web/suite-bundles/preview", + "/api/web/suite-bundles/previews/token/confirm", + "/api/web/suite-bundles/operations/operation/cancel", + "/api/web/suite-bundles/operations/operation/retry")) { + assertEquals(RouteSecurityPolicyRegistry.AccessLevel.AUTHENTICATED, + registry.accessLevel("POST", path)); + assertTrue(registry.authorizeApiToken("POST", path, Set.of("skill:publish")).allowed()); + assertFalse(registry.authorizeApiToken("POST", path, Set.of("skill:read")).allowed()); + } + for (String path : List.of( + "/api/v1/suite-bundles/operations/operation", + "/api/web/suite-bundles/operations/operation")) { + assertEquals(RouteSecurityPolicyRegistry.AccessLevel.AUTHENTICATED, + registry.accessLevel("GET", path)); + assertTrue(registry.authorizeApiToken("GET", path, Set.of("skill:publish")).allowed()); + assertFalse(registry.authorizeApiToken("GET", path, Set.of("skill:read")).allowed()); + } + } + @Test void sessionOnlyRoutes_areRejectedForApiTokens() { for (String route : registry.sessionOnlyRoutes()) { diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/event/SkillSuiteBundleAdvanceRequestedEvent.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/event/SkillSuiteBundleAdvanceRequestedEvent.java new file mode 100644 index 00000000..739d6ce7 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/event/SkillSuiteBundleAdvanceRequestedEvent.java @@ -0,0 +1,5 @@ +package com.iflytek.skillhub.domain.event; + +/** Requests asynchronous progress for one durable Suite Bundle operation. */ +public record SkillSuiteBundleAdvanceRequestedEvent(String operationId) { +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/label/LabelPermissionChecker.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/label/LabelPermissionChecker.java index e718c3c5..50abece9 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/label/LabelPermissionChecker.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/label/LabelPermissionChecker.java @@ -2,6 +2,9 @@ package com.iflytek.skillhub.domain.label; import com.iflytek.skillhub.domain.namespace.NamespaceRole; import com.iflytek.skillhub.domain.skill.Skill; +import com.iflytek.skillhub.domain.suite.SkillSuite; +import com.iflytek.skillhub.domain.suite.SkillSuiteActionContext; +import com.iflytek.skillhub.domain.suite.SkillSuiteAuthorizationPolicy; import java.util.Map; import java.util.Set; import org.springframework.stereotype.Component; @@ -32,4 +35,23 @@ public class LabelPermissionChecker { || namespaceRole == NamespaceRole.ADMIN || namespaceRole == NamespaceRole.OWNER; } + + public boolean canManageSuiteLabel( + SkillSuite suite, + LabelDefinition labelDefinition, + String userId, + Map userNamespaceRoles, + Set platformRoles + ) { + if (platformRoles.contains("SUPER_ADMIN")) { + return true; + } + if (userId == null || labelDefinition.getType() == LabelType.PRIVILEGED) { + return false; + } + return SkillSuiteAuthorizationPolicy.canCreateVersion( + suite, + new SkillSuiteActionContext( + userId, userNamespaceRoles, platformRoles, null, null, null)); + } } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/label/SkillSuiteLabel.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/label/SkillSuiteLabel.java new file mode 100644 index 00000000..cbedf5d5 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/label/SkillSuiteLabel.java @@ -0,0 +1,68 @@ +package com.iflytek.skillhub.domain.label; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.PrePersist; +import jakarta.persistence.Table; + +import java.time.Clock; +import java.time.Instant; + +/** Direct Suite-to-Label association; it never propagates to member Skills. */ +@Entity +@Table(name = "skill_suite_label") +public class SkillSuiteLabel { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "suite_id", nullable = false) + private Long suiteId; + + @Column(name = "label_id", nullable = false) + private Long labelId; + + @Column(name = "created_by", length = 128) + private String createdBy; + + @Column(name = "created_at", nullable = false, updatable = false) + private Instant createdAt; + + protected SkillSuiteLabel() { + } + + public SkillSuiteLabel(Long suiteId, Long labelId, String createdBy) { + this.suiteId = suiteId; + this.labelId = labelId; + this.createdBy = createdBy; + } + + @PrePersist + protected void onCreate() { + createdAt = Instant.now(Clock.systemUTC()); + } + + public Long getId() { + return id; + } + + public Long getSuiteId() { + return suiteId; + } + + public Long getLabelId() { + return labelId; + } + + public String getCreatedBy() { + return createdBy; + } + + public Instant getCreatedAt() { + return createdAt; + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/label/SkillSuiteLabelRepository.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/label/SkillSuiteLabelRepository.java new file mode 100644 index 00000000..0efed581 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/label/SkillSuiteLabelRepository.java @@ -0,0 +1,13 @@ +package com.iflytek.skillhub.domain.label; + +import java.util.List; +import java.util.Optional; + +public interface SkillSuiteLabelRepository { + List findBySuiteId(Long suiteId); + List findBySuiteIdIn(List suiteIds); + List findByLabelId(Long labelId); + Optional findBySuiteIdAndLabelId(Long suiteId, Long labelId); + SkillSuiteLabel save(SkillSuiteLabel suiteLabel); + void delete(SkillSuiteLabel suiteLabel); +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/label/SkillSuiteLabelService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/label/SkillSuiteLabelService.java new file mode 100644 index 00000000..add5bbcc --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/label/SkillSuiteLabelService.java @@ -0,0 +1,122 @@ +package com.iflytek.skillhub.domain.label; + +import com.iflytek.skillhub.domain.namespace.NamespaceRole; +import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; +import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; +import com.iflytek.skillhub.domain.suite.SkillSuite; +import com.iflytek.skillhub.domain.suite.SkillSuiteRepository; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** Manages direct Suite labels without touching Suite versions or member Skill metadata. */ +@Service +public class SkillSuiteLabelService { + + private final int maxLabelsPerSuite; + private final SkillSuiteRepository suiteRepository; + private final LabelDefinitionRepository labelDefinitionRepository; + private final SkillSuiteLabelRepository suiteLabelRepository; + private final LabelPermissionChecker labelPermissionChecker; + + public SkillSuiteLabelService( + SkillSuiteRepository suiteRepository, + LabelDefinitionRepository labelDefinitionRepository, + SkillSuiteLabelRepository suiteLabelRepository, + LabelPermissionChecker labelPermissionChecker, + @Value("${skillhub.label.max-per-suite:10}") int maxLabelsPerSuite + ) { + this.suiteRepository = suiteRepository; + this.labelDefinitionRepository = labelDefinitionRepository; + this.suiteLabelRepository = suiteLabelRepository; + this.labelPermissionChecker = labelPermissionChecker; + if (maxLabelsPerSuite <= 0) { + throw new IllegalArgumentException("skillhub.label.max-per-suite must be greater than 0"); + } + this.maxLabelsPerSuite = maxLabelsPerSuite; + } + + public List listSuiteLabels(Long suiteId) { + return suiteLabelRepository.findBySuiteId(suiteId); + } + + public List listSuiteLabelsBySuiteIds(List suiteIds) { + if (suiteIds == null || suiteIds.isEmpty()) { + return List.of(); + } + return suiteLabelRepository.findBySuiteIdIn(suiteIds); + } + + public List listByLabelId(Long labelId) { + return suiteLabelRepository.findByLabelId(labelId); + } + + @Transactional + public SkillSuiteLabel attachLabel( + Long suiteId, + String labelSlug, + String operatorId, + Map namespaceRoles, + Set platformRoles + ) { + SkillSuite suite = findSuite(suiteId); + LabelDefinition label = findLabel(labelSlug); + requirePermission(suite, label, operatorId, namespaceRoles, platformRoles); + + return suiteLabelRepository.findBySuiteIdAndLabelId(suiteId, label.getId()) + .orElseGet(() -> { + List existing = suiteLabelRepository.findBySuiteId(suiteId); + if (existing.size() >= maxLabelsPerSuite) { + throw new DomainBadRequestException( + "label.suite.too_many", suiteId, maxLabelsPerSuite); + } + return suiteLabelRepository.save( + new SkillSuiteLabel(suiteId, label.getId(), operatorId)); + }); + } + + @Transactional + public void detachLabel( + Long suiteId, + String labelSlug, + String operatorId, + Map namespaceRoles, + Set platformRoles + ) { + SkillSuite suite = findSuite(suiteId); + LabelDefinition label = findLabel(labelSlug); + requirePermission(suite, label, operatorId, namespaceRoles, platformRoles); + SkillSuiteLabel suiteLabel = suiteLabelRepository.findBySuiteIdAndLabelId(suiteId, label.getId()) + .orElseThrow(() -> new DomainBadRequestException( + "label.suite.not_found", suiteId, labelSlug)); + suiteLabelRepository.delete(suiteLabel); + } + + private SkillSuite findSuite(Long suiteId) { + return suiteRepository.findById(suiteId) + .orElseThrow(() -> new DomainBadRequestException("error.suite.notFound", suiteId)); + } + + private LabelDefinition findLabel(String labelSlug) { + String normalized = LabelSlugValidator.normalize(labelSlug); + return labelDefinitionRepository.findBySlugIgnoreCase(normalized) + .orElseThrow(() -> new DomainBadRequestException("label.not_found", normalized)); + } + + private void requirePermission( + SkillSuite suite, + LabelDefinition label, + String operatorId, + Map namespaceRoles, + Set platformRoles + ) { + if (!labelPermissionChecker.canManageSuiteLabel( + suite, label, operatorId, namespaceRoles, platformRoles)) { + throw new DomainForbiddenException("label.suite.no_permission"); + } + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceRepository.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceRepository.java index 58e32532..cdedbfec 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceRepository.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceRepository.java @@ -13,6 +13,7 @@ public interface NamespaceRepository { Optional findById(Long id); List findAll(); List findByIdIn(List ids); + List findBySlugIn(List slugs); Page findByIdIn(List ids, Pageable pageable); Optional findBySlug(String slug); Page findByStatus(NamespaceStatus status, Pageable pageable); diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/security/SecurityScanService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/security/SecurityScanService.java index 6c96c3a7..507bb8f1 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/security/SecurityScanService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/security/SecurityScanService.java @@ -15,9 +15,11 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.io.IOException; +import java.io.InputStream; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; +import java.nio.file.StandardCopyOption; import java.time.Clock; import java.time.Instant; import java.util.List; @@ -217,7 +219,9 @@ public class SecurityScanService { if (parent != null) { Files.createDirectories(parent); } - Files.write(filePath, entry.content()); + try (InputStream input = entry.openStream()) { + Files.copy(input, filePath, StandardCopyOption.REPLACE_EXISTING); + } } return skillDir; } catch (IOException e) { diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillFileRepository.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillFileRepository.java index 5a863645..a110762e 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillFileRepository.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillFileRepository.java @@ -7,6 +7,7 @@ import java.util.List; */ public interface SkillFileRepository { List findByVersionId(Long versionId); + List findByVersionIdIn(List versionIds); SkillFile save(SkillFile file); List saveAll(Iterable files); void deleteByVersionId(Long versionId); diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillRepository.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillRepository.java index c8cf6f49..5794656f 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillRepository.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillRepository.java @@ -14,6 +14,7 @@ public interface SkillRepository { List findByIdIn(List ids); List findAll(); List findByNamespaceIdAndSlug(Long namespaceId, String slug); + List findByNamespaceIdInAndSlugIn(List namespaceIds, List slugs); Optional findByNamespaceIdAndSlugAndOwnerId(Long namespaceId, String slug, String ownerId); List findByNamespaceIdAndStatus(Long namespaceId, SkillStatus status); boolean existsByNamespaceId(Long namespaceId); diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillVersionRepository.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillVersionRepository.java index 91f103c7..67eeb59c 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillVersionRepository.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillVersionRepository.java @@ -18,6 +18,11 @@ public interface SkillVersionRepository { } List findByIdIn(List ids); List findBySkillIdIn(List skillIds); + default List findBySkillIdInAndVersionIn(List skillIds, List versions) { + return findBySkillIdIn(skillIds).stream() + .filter(version -> versions.contains(version.getVersion())) + .toList(); + } List findBySkillIdInAndStatus(List skillIds, SkillVersionStatus status); List findBySkillId(Long skillId); List findBySkillIdForUpdate(Long skillId); diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillPublishService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillPublishService.java index 8313534f..8fb01137 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillPublishService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillPublishService.java @@ -15,6 +15,7 @@ import com.iflytek.skillhub.domain.review.ReviewTaskRepository; import com.iflytek.skillhub.domain.security.SecurityScanService; import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; +import com.iflytek.skillhub.domain.shared.exception.LocalizedDomainException; import com.iflytek.skillhub.domain.skill.*; import com.iflytek.skillhub.domain.skill.metadata.ComplianceMetadataService; import com.iflytek.skillhub.domain.skill.metadata.ComplianceSnapshot; @@ -36,9 +37,10 @@ import org.springframework.transaction.support.TransactionSynchronization; import org.springframework.transaction.support.TransactionSynchronizationManager; import java.io.IOException; -import java.io.ByteArrayInputStream; -import java.io.ByteArrayOutputStream; import java.io.InputStream; +import java.io.OutputStream; +import java.nio.file.Files; +import java.nio.file.Path; import java.security.MessageDigest; import java.time.Clock; import java.time.Instant; @@ -50,7 +52,9 @@ import java.util.HexFormat; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Set; +import java.util.stream.Collectors; import java.util.zip.ZipEntry; import java.util.zip.ZipOutputStream; @@ -79,6 +83,15 @@ public class SkillPublishService { SkillVersion version ) {} + private record StagedPackageFile( + Path path, + String filePath, + String storageKey, + long size, + String contentType, + String sha256 + ) {} + private final NamespaceRepository namespaceRepository; private final NamespaceMemberRepository namespaceMemberRepository; private final SkillRepository skillRepository; @@ -290,6 +303,38 @@ public class SkillPublishService { return publishFromEntriesInternal(namespaceSlug, entries, publisherId, visibility, platformRoles, confirmWarnings, false, false); } + /** + * Publishes one package that was explicitly bound by a confirmed Suite Bundle plan. + * + *

Unlike the interactive single-Skill path, this entry point never withdraws a pending + * review and never replaces an existing unpublished version. The expected identity is checked + * again in the write transaction so a stale Bundle plan cannot target a different Skill or + * version after confirmation. + */ + @Transactional + public PublishResult publishBundleMemberFromEntries( + String namespaceSlug, + Long expectedSkillId, + String expectedSkillSlug, + String expectedVersion, + List entries, + Map stagedSha256, + String publisherId, + SkillVisibility visibility, + Map userNamespaceRoles, + Set platformRoles, + boolean confirmWarnings + ) { + BundlePublicationTarget target = new BundlePublicationTarget( + expectedSkillId, expectedSkillSlug, expectedVersion, + userNamespaceRoles == null ? Map.of() : Map.copyOf(userNamespaceRoles), + stagedSha256 == null ? Map.of() : Map.copyOf(stagedSha256)); + return publishFromEntriesInternal( + namespaceSlug, entries, publisherId, visibility, + platformRoles == null ? Set.of() : platformRoles, + confirmWarnings, false, false, target); + } + /** * Rebuilds a new version from an already published version by copying its * stored files and rewriting the embedded metadata version field. @@ -341,6 +386,21 @@ public class SkillPublishService { boolean confirmWarnings, boolean forceAutoPublish, boolean bypassMembershipCheck) { + return publishFromEntriesInternal( + namespaceSlug, entries, publisherId, visibility, platformRoles, + confirmWarnings, forceAutoPublish, bypassMembershipCheck, null); + } + + private PublishResult publishFromEntriesInternal( + String namespaceSlug, + List entries, + String publisherId, + SkillVisibility visibility, + Set platformRoles, + boolean confirmWarnings, + boolean forceAutoPublish, + boolean bypassMembershipCheck, + BundlePublicationTarget bundleTarget) { // 1. Find namespace by slug Namespace namespace = namespaceRepository.findBySlug(namespaceSlug) @@ -376,6 +436,15 @@ public class SkillPublishService { metadata = new SkillMetadata(metadata.name(), metadata.description(), autoVersion, metadata.body(), metadata.frontmatter()); } String skillSlug = SlugValidator.slugify(metadata.name()); + if (bundleTarget != null + && (!bundleTarget.expectedSkillSlug().equals(skillSlug) + || !bundleTarget.expectedVersion().equals(metadata.version()))) { + throw bundleStateChanged(); + } + if (bundleTarget != null && !bundleTarget.stagedSha256().keySet().equals( + entries.stream().map(PackageEntry::path).collect(Collectors.toSet()))) { + throw bundleStateChanged(); + } // 5. Run PrePublishValidator PrePublishValidator.SkillPackageContext context = new PrePublishValidator.SkillPackageContext( @@ -403,7 +472,9 @@ public class SkillPublishService { // Check if any other owner's skill has published versions // Only PUBLISHED status blocks same-name publishing (UPLOADED/PENDING_REVIEW allowed) for (Skill existing : existingSkills) { - if (!existing.getOwnerId().equals(publisherId)) { + boolean isBoundTarget = bundleTarget != null + && Objects.equals(existing.getId(), bundleTarget.expectedSkillId()); + if (!existing.getOwnerId().equals(publisherId) && !isBoundTarget) { boolean hasPublished = !skillVersionRepository .findBySkillIdAndStatus(existing.getId(), SkillVersionStatus.PUBLISHED) .isEmpty(); @@ -418,24 +489,10 @@ public class SkillPublishService { } } - // Find or create skill for current user - Skill skill = skillRepository.findByNamespaceIdAndSlugAndOwnerId(namespace.getId(), skillSlug, publisherId) - .orElseGet(() -> { - Skill newSkill = new Skill(namespace.getId(), skillSlug, publisherId, visibility); - newSkill.setCreatedBy(publisherId); - try { - Skill savedSkill = skillRepository.save(newSkill); - // save() may defer the unique-constraint check until transaction commit. - // Flush here so this boundary can translate the coordinate race. - skillRepository.flush(); - return savedSkill; - } catch (DataIntegrityViolationException ex) { - // A concurrent publish for the same (namespace, slug, owner) coordinate inserted - // the skill first and won the unique-constraint race. Surface a deterministic - // business conflict instead of leaking the violation as an HTTP 500. - throw new DomainBadRequestException("error.skill.publish.concurrentConflict", skillSlug); - } - }); + Skill skill = bundleTarget == null + ? findOrCreateOwnedSkill(namespace, skillSlug, publisherId, visibility) + : resolveBundleTargetSkill(namespace, existingSkills, publisherId, visibility, + platformRoles, bundleTarget); if (skill.getStatus() == SkillStatus.ARCHIVED) { throw new DomainBadRequestException("error.skill.publish.archived", skillSlug); @@ -445,16 +502,24 @@ public class SkillPublishService { // When publishing a new version, existing PENDING_REVIEW versions are withdrawn to UPLOADED status List pendingVersions = skillVersionRepository .findBySkillIdAndStatus(skill.getId(), SkillVersionStatus.PENDING_REVIEW); - for (SkillVersion pending : pendingVersions) { - reviewTaskRepository.findBySkillVersionIdAndStatus(pending.getId(), ReviewTaskStatus.PENDING) - .ifPresent(reviewTaskRepository::delete); - pending.setStatus(SkillVersionStatus.UPLOADED); - skillVersionRepository.save(pending); + if (bundleTarget != null && !pendingVersions.isEmpty()) { + throw bundleStateChanged(); + } + if (bundleTarget == null) { + for (SkillVersion pending : pendingVersions) { + reviewTaskRepository.findBySkillVersionIdAndStatus(pending.getId(), ReviewTaskStatus.PENDING) + .ifPresent(reviewTaskRepository::delete); + pending.setStatus(SkillVersionStatus.UPLOADED); + skillVersionRepository.save(pending); + } } // 7. Check version doesn't already exist java.util.Optional existingVersion = skillVersionRepository.findBySkillIdAndVersion(skill.getId(), metadata.version()); if (existingVersion.isPresent()) { + if (bundleTarget != null) { + throw bundleStateChanged(); + } SkillVersion matchedVersion = existingVersion.get(); if (matchedVersion.getStatus() == SkillVersionStatus.PUBLISHED) { throw new DomainBadRequestException("error.skill.version.exists", metadata.version()); @@ -502,56 +567,81 @@ public class SkillPublishService { List skillFiles = new ArrayList<>(); long totalSize = 0; + Path bundleZip = null; + List stagedFiles = new ArrayList<>(); + List temporaryEntryFiles = new ArrayList<>(); try { MessageDigest digest = MessageDigest.getInstance("SHA-256"); HexFormat hexFormat = HexFormat.of(); - - for (PackageEntry entry : entries) { - String storageKey = String.format("skills/%d/%d/%s", skill.getId(), version.getId(), entry.path()); - - // Upload to storage - objectStorageService.putObject( - storageKey, - new ByteArrayInputStream(entry.content()), - entry.size(), - entry.contentType() - ); - - // Compute SHA-256 - byte[] hash = digest.digest(entry.content()); - String sha256 = hexFormat.formatHex(hash); - - // Create SkillFile record - SkillFile skillFile = new SkillFile( - version.getId(), - entry.path(), - entry.size(), - entry.contentType(), - sha256, - storageKey - ); - skillFiles.add(skillFile); - totalSize += entry.size(); - - digest.reset(); + bundleZip = Files.createTempFile("skillhub-package-", ".zip"); + try (OutputStream bundleOutput = Files.newOutputStream(bundleZip); + ZipOutputStream zipOutput = new ZipOutputStream(bundleOutput)) { + for (PackageEntry entry : entries) { + String storageKey = String.format( + "skills/%d/%d/%s", skill.getId(), version.getId(), entry.path()); + Path stagedFile = Files.createTempFile("skillhub-package-entry-", ".tmp"); + temporaryEntryFiles.add(stagedFile); + ZipEntry zipEntry = new ZipEntry(entry.path()); + zipOutput.putNextEntry(zipEntry); + long actualSize = 0; + digest.reset(); + try (InputStream input = entry.openStream(); + OutputStream stagedOutput = Files.newOutputStream(stagedFile)) { + byte[] buffer = new byte[64 * 1024]; + int read; + while ((read = input.read(buffer)) != -1) { + stagedOutput.write(buffer, 0, read); + zipOutput.write(buffer, 0, read); + digest.update(buffer, 0, read); + actualSize += read; + } + } + zipOutput.closeEntry(); + if (actualSize != entry.size()) { + throw new DomainBadRequestException( + "error.suite.bundle.member.stateChanged"); + } + String sha256 = hexFormat.formatHex(digest.digest()); + String expectedSha256 = bundleTarget == null + ? null + : bundleTarget.stagedSha256().get(entry.path()); + if (expectedSha256 != null && !expectedSha256.equalsIgnoreCase(sha256)) { + throw new DomainBadRequestException( + "error.suite.bundle.member.stateChanged"); + } + stagedFiles.add(new StagedPackageFile( + stagedFile, entry.path(), storageKey, actualSize, entry.contentType(), sha256)); + } + zipOutput.finish(); } + for (StagedPackageFile staged : stagedFiles) { + try (InputStream stagedInput = Files.newInputStream(staged.path())) { + objectStorageService.putObject( + staged.storageKey(), stagedInput, staged.size(), staged.contentType()); + } + skillFiles.add(new SkillFile( + version.getId(), staged.filePath(), staged.size(), + staged.contentType(), staged.sha256(), staged.storageKey())); + totalSize += staged.size(); + } + String bundleKey = String.format("packages/%d/%d/bundle.zip", skill.getId(), version.getId()); + long bundleSize = Files.size(bundleZip); + try (InputStream bundleInput = Files.newInputStream(bundleZip)) { + objectStorageService.putObject( + bundleKey, bundleInput, bundleSize, "application/zip"); + } + } catch (LocalizedDomainException exception) { + throw exception; } catch (Exception e) { throw new IllegalStateException("Failed to process files", e); + } finally { + temporaryEntryFiles.forEach(this::deleteTemporaryFile); + deleteTemporaryFile(bundleZip); } // 10. Save SkillFile records skillFileRepository.saveAll(skillFiles); - // 10.5 Build and upload bundle zip for download endpoints - byte[] bundleZip = buildBundle(entries); - String bundleKey = String.format("packages/%d/%d/bundle.zip", skill.getId(), version.getId()); - objectStorageService.putObject( - bundleKey, - new ByteArrayInputStream(bundleZip), - bundleZip.length, - "application/zip" - ); - // 11. Update version stats version.setFileCount(skillFiles.size()); version.setTotalSize(totalSize); @@ -597,6 +687,65 @@ public class SkillPublishService { return new PublishResult(skill.getId(), skill.getSlug(), version); } + private Skill findOrCreateOwnedSkill( + Namespace namespace, + String skillSlug, + String publisherId, + SkillVisibility visibility + ) { + return skillRepository.findByNamespaceIdAndSlugAndOwnerId(namespace.getId(), skillSlug, publisherId) + .orElseGet(() -> createSkill(namespace, skillSlug, publisherId, visibility)); + } + + private Skill resolveBundleTargetSkill( + Namespace namespace, + List coordinateSkills, + String publisherId, + SkillVisibility visibility, + Set platformRoles, + BundlePublicationTarget target + ) { + if (target.expectedSkillId() == null) { + if (!coordinateSkills.isEmpty()) { + throw bundleStateChanged(); + } + return createSkill(namespace, target.expectedSkillSlug(), publisherId, visibility); + } + Skill skill = coordinateSkills.stream() + .filter(candidate -> Objects.equals(candidate.getId(), target.expectedSkillId())) + .findFirst() + .orElseThrow(this::bundleStateChanged); + if (!Objects.equals(skill.getNamespaceId(), namespace.getId()) + || !skill.getSlug().equals(target.expectedSkillSlug()) + || skill.getVisibility() != visibility) { + throw bundleStateChanged(); + } + assertCanManageLifecycle(skill, publisherId, target.userNamespaceRoles(), platformRoles); + return skill; + } + + private Skill createSkill( + Namespace namespace, + String skillSlug, + String publisherId, + SkillVisibility visibility + ) { + Skill newSkill = new Skill(namespace.getId(), skillSlug, publisherId, visibility); + newSkill.setCreatedBy(publisherId); + try { + Skill savedSkill = skillRepository.save(newSkill); + // save() may defer the unique-constraint check until transaction commit. + skillRepository.flush(); + return savedSkill; + } catch (DataIntegrityViolationException ex) { + throw new DomainBadRequestException("error.skill.publish.concurrentConflict", skillSlug); + } + } + + private DomainBadRequestException bundleStateChanged() { + return new DomainBadRequestException("error.suite.bundle.member.stateChanged"); + } + private void deleteReplaceableVersionArtifacts(Skill skill, SkillVersion version, String namespaceSlug) { if (!REPLACEABLE_VERSION_STATUSES.contains(version.getStatus())) { throw new DomainBadRequestException("error.skill.version.exists", version.getVersion()); @@ -693,15 +842,42 @@ public class SkillPublishService { private void assertCanManageLifecycle(Skill skill, String actorUserId, Map userNamespaceRoles) { + assertCanManageLifecycle(skill, actorUserId, userNamespaceRoles, Set.of()); + } + + private void assertCanManageLifecycle(Skill skill, + String actorUserId, + Map userNamespaceRoles, + Set platformRoles) { NamespaceRole namespaceRole = userNamespaceRoles.get(skill.getNamespaceId()); boolean canManage = skill.getOwnerId().equals(actorUserId) || namespaceRole == NamespaceRole.ADMIN - || namespaceRole == NamespaceRole.OWNER; + || namespaceRole == NamespaceRole.OWNER + || platformRoles.contains("SUPER_ADMIN"); if (!canManage) { throw new DomainForbiddenException("error.skill.lifecycle.noPermission"); } } + private record BundlePublicationTarget( + Long expectedSkillId, + String expectedSkillSlug, + String expectedVersion, + Map userNamespaceRoles, + Map stagedSha256 + ) { + private BundlePublicationTarget { + if (expectedSkillSlug == null || expectedSkillSlug.isBlank() + || expectedVersion == null || expectedVersion.isBlank()) { + throw new IllegalArgumentException("Bundle publication target must include slug and version"); + } + if (stagedSha256.values().stream().anyMatch( + hash -> hash == null || !hash.matches("[0-9a-f]{64}"))) { + throw new IllegalArgumentException("Bundle staged hashes must be lowercase SHA-256 values"); + } + } + } + private Instant currentTime() { return Instant.now(clock); } @@ -766,19 +942,14 @@ public class SkillPublishService { return parsedMetadata; } - private byte[] buildBundle(List entries) { - try (ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); - ZipOutputStream zipOutputStream = new ZipOutputStream(outputStream)) { - for (PackageEntry entry : entries) { - ZipEntry zipEntry = new ZipEntry(entry.path()); - zipOutputStream.putNextEntry(zipEntry); - zipOutputStream.write(entry.content()); - zipOutputStream.closeEntry(); - } - zipOutputStream.finish(); - return outputStream.toByteArray(); - } catch (Exception e) { - throw new IllegalStateException("Failed to build bundle zip", e); + private void deleteTemporaryFile(Path path) { + if (path == null) { + return; + } + try { + Files.deleteIfExists(path); + } catch (IOException exception) { + log.warn("Failed to delete temporary package file {}", path, exception); } } } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillReviewSubmitService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillReviewSubmitService.java index 348693a1..2f912d63 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillReviewSubmitService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillReviewSubmitService.java @@ -15,6 +15,7 @@ import org.springframework.transaction.annotation.Transactional; import java.time.Clock; import java.time.Instant; import java.util.Map; +import java.util.Set; /** * Service for submitting skill versions for review and confirming private publishes. @@ -114,13 +115,20 @@ public class SkillReviewSubmitService { @Transactional public void confirmPublish(Long skillId, Long versionId, String actorUserId, Map userNamespaceRoles) { + confirmPublish(skillId, versionId, actorUserId, userNamespaceRoles, Set.of()); + } + + @Transactional + public void confirmPublish(Long skillId, Long versionId, String actorUserId, + Map userNamespaceRoles, + Set platformRoles) { Skill skill = skillRepository.findById(skillId) .orElseThrow(() -> new DomainBadRequestException("error.skill.notFound", skillId)); SkillVersion version = skillVersionRepository.findById(versionId) .orElseThrow(() -> new DomainBadRequestException("error.skill.version.notFound", versionId)); // Validate ownership - assertCanManageLifecycle(skill, actorUserId, userNamespaceRoles); + assertCanManageLifecycle(skill, actorUserId, userNamespaceRoles, platformRoles); // Validate skill visibility is PRIVATE if (skill.getVisibility() != SkillVisibility.PRIVATE) { @@ -153,10 +161,17 @@ public class SkillReviewSubmitService { } private void assertCanManageLifecycle(Skill skill, String actorUserId, Map userNamespaceRoles) { + assertCanManageLifecycle(skill, actorUserId, userNamespaceRoles, Set.of()); + } + + private void assertCanManageLifecycle(Skill skill, String actorUserId, + Map userNamespaceRoles, + Set platformRoles) { NamespaceRole namespaceRole = userNamespaceRoles.get(skill.getNamespaceId()); boolean canManage = skill.getOwnerId().equals(actorUserId) || namespaceRole == NamespaceRole.ADMIN - || namespaceRole == NamespaceRole.OWNER; + || namespaceRole == NamespaceRole.OWNER + || platformRoles.contains("SUPER_ADMIN"); if (!canManage) { throw new DomainForbiddenException("error.skill.lifecycle.noPermission"); } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation/PackageEntry.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation/PackageEntry.java index d9091652..ec5576c1 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation/PackageEntry.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation/PackageEntry.java @@ -1,8 +1,83 @@ package com.iflytek.skillhub.domain.skill.validation; -public record PackageEntry( - String path, - byte[] content, - long size, - String contentType -) {} +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.UncheckedIOException; +import java.util.Objects; + +/** A package file whose content can be reopened without retaining the whole package in memory. */ +public final class PackageEntry { + + @FunctionalInterface + public interface ContentSource { + InputStream open() throws IOException; + } + + private final String path; + private final long size; + private final String contentType; + private final ContentSource contentSource; + private final byte[] materializedContent; + + public PackageEntry(String path, byte[] content, long size, String contentType) { + byte[] requiredContent = Objects.requireNonNull(content, "content"); + this.path = Objects.requireNonNull(path, "path"); + this.size = size; + this.contentType = contentType; + this.contentSource = () -> new ByteArrayInputStream(requiredContent); + this.materializedContent = requiredContent; + } + + private PackageEntry(String path, long size, String contentType, ContentSource contentSource) { + this.path = Objects.requireNonNull(path, "path"); + this.size = size; + this.contentType = contentType; + this.contentSource = Objects.requireNonNull(contentSource, "contentSource"); + this.materializedContent = null; + } + + public static PackageEntry streaming( + String path, long size, String contentType, ContentSource contentSource + ) { + return new PackageEntry(path, size, contentType, contentSource); + } + + public String path() { + return path; + } + + public long size() { + return size; + } + + public String contentType() { + return contentType; + } + + public InputStream openStream() throws IOException { + return contentSource.open(); + } + + /** + * Materializes one file for validators that inspect content. Whole-package processing should + * prefer {@link #openStream()} so only the current file occupies heap memory. + */ + public byte[] content() { + if (materializedContent != null) { + return materializedContent; + } + if (size < 0 || size >= Integer.MAX_VALUE) { + throw new IllegalStateException("Invalid package entry size: " + path); + } + try (InputStream input = openStream()) { + byte[] content = input.readNBytes((int) size + 1); + if (content.length != size) { + throw new IllegalStateException("Package entry size changed: " + path); + } + return content; + } catch (IOException exception) { + throw new UncheckedIOException("Failed to read package entry: " + path, exception); + } + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/SkillSuiteLifecycleService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/SkillSuiteLifecycleService.java index 5d435d2c..6d487ebb 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/SkillSuiteLifecycleService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/SkillSuiteLifecycleService.java @@ -83,7 +83,7 @@ public class SkillSuiteLifecycleService { if (loaded.version().getStatus() != SkillSuiteVersionStatus.DRAFT) { throw new DomainBadRequestException("error.suite.review.notDraft", loaded.version().getVersion()); } - publicationValidator.validate(loaded.suite(), loaded.version()); + publicationValidator.validateForPublication(loaded.suite(), loaded.version()); loaded.version().setStatus(SkillSuiteVersionStatus.PENDING_REVIEW); versionRepository.save(loaded.version()); @@ -111,7 +111,7 @@ public class SkillSuiteLifecycleService { if (loaded.version().getStatus() != SkillSuiteVersionStatus.DRAFT) { throw new DomainBadRequestException("error.suite.publish.notDraft", loaded.version().getVersion()); } - publicationValidator.validate(loaded.suite(), loaded.version()); + publicationValidator.validateForPublication(loaded.suite(), loaded.version()); publish(loaded.suite(), loaded.version(), context.actorUserId()); audit(context, "PUBLISH_SKILL_SUITE_VERSION", "SKILL_SUITE_VERSION", versionId, null); log.info("Private Suite published [suiteId={}, versionId={}, actorId={}, requestId={}]", @@ -128,7 +128,7 @@ public class SkillSuiteLifecycleService { if (loaded.version().getStatus() != SkillSuiteVersionStatus.PENDING_REVIEW) { throw new DomainBadRequestException("error.suite.review.notPending", loaded.version().getVersion()); } - publicationValidator.validate(loaded.suite(), loaded.version()); + publicationValidator.validateForPublication(loaded.suite(), loaded.version()); completeTask(task, ReviewTaskStatus.APPROVED, context.actorUserId(), comment); publish(loaded.suite(), loaded.version(), context.actorUserId()); audit(context, "APPROVE_SKILL_SUITE_REVIEW", "REVIEW_TASK", reviewTaskId, diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/SkillSuitePublicationValidator.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/SkillSuitePublicationValidator.java index e920a65f..e650adbf 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/SkillSuitePublicationValidator.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/SkillSuitePublicationValidator.java @@ -20,6 +20,11 @@ public class SkillSuitePublicationValidator { this.stateResolver = stateResolver; } + public SkillSuiteAvailability validateForPublication(SkillSuite suite, SkillSuiteVersion version) { + validateDisplayMetadata(version); + return validate(suite, version); + } + public SkillSuiteAvailability validate(SkillSuite suite, SkillSuiteVersion version) { List members = memberRepository.findBySuiteVersionIdOrderByPosition(version.getId()); @@ -39,4 +44,13 @@ public class SkillSuitePublicationValidator { } return availability; } + + private void validateDisplayMetadata(SkillSuiteVersion version) { + if (version.getSummary() == null || version.getSummary().isBlank()) { + throw new DomainBadRequestException("error.suite.summary.required"); + } + if (version.getOverview() == null || version.getOverview().isBlank()) { + throw new DomainBadRequestException("error.suite.overview.required"); + } + } } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/SkillSuiteQueryService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/SkillSuiteQueryService.java index c57cb6eb..381c1f64 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/SkillSuiteQueryService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/SkillSuiteQueryService.java @@ -135,7 +135,8 @@ public class SkillSuiteQueryService { Comparator.nullsLast(Comparator.reverseOrder()))) .map(version -> new VersionSummary( version.getId(), version.getVersion(), version.getStatus(), - version.getVisibility(), version.getPublishedAt(), + version.getVisibility(), version.getChangelog(), version.getCreatedBy(), + version.getPublishedAt(), version.getYankedAt(), version.getCreatedAt())) .toList(); } @@ -228,6 +229,8 @@ public class SkillSuiteQueryService { String version, SkillSuiteVersionStatus status, SkillVisibility visibility, + String changelog, + String createdBy, java.time.Instant publishedAt, java.time.Instant yankedAt, java.time.Instant createdAt diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/bundle/SkillSuiteBundleCoordinate.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/bundle/SkillSuiteBundleCoordinate.java new file mode 100644 index 00000000..351d2437 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/bundle/SkillSuiteBundleCoordinate.java @@ -0,0 +1,9 @@ +package com.iflytek.skillhub.domain.suite.bundle; + +/** A normalized SkillHub namespace/slug coordinate. */ +public record SkillSuiteBundleCoordinate(String namespace, String slug) { + + public String canonical() { + return "@" + namespace + "/" + slug; + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/bundle/SkillSuiteBundleExecutionOperation.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/bundle/SkillSuiteBundleExecutionOperation.java new file mode 100644 index 00000000..f8af6d6f --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/bundle/SkillSuiteBundleExecutionOperation.java @@ -0,0 +1,244 @@ +package com.iflytek.skillhub.domain.suite.bundle; + +import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.persistence.Version; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; + +import java.time.Instant; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; + +/** Durable execution state created only after explicit Bundle confirmation. */ +@Entity +@Table(name = "skill_suite_bundle_operation") +public class SkillSuiteBundleExecutionOperation { + + @Id + @Column(name = "operation_id", length = 64) + private String operationId; + @Column(name = "preview_token", nullable = false, unique = true, length = 64) + private String previewToken; + @Column(name = "client_request_id", nullable = false, length = 64) + private String clientRequestId; + @Column(name = "actor_id", nullable = false, length = 128) + private String actorId; + @Enumerated(EnumType.STRING) + @Column(name = "mode", nullable = false, length = 16) + private SkillSuiteBundleMode mode; + @Column(name = "namespace_id", nullable = false) + private Long namespaceId; + @Column(name = "target_suite_slug", nullable = false, length = 128) + private String targetSuiteSlug; + @Column(name = "target_suite_id") + private Long targetSuiteId; + @Column(name = "base_suite_version_id") + private Long baseSuiteVersionId; + @Column(name = "target_version", nullable = false, length = 64) + private String targetVersion; + @Column(name = "reservation_key", nullable = false, length = 256) + private String reservationKey; + @Column(name = "reservation_active", nullable = false) + private boolean reservationActive; + @Column(name = "archive_object_key", nullable = false, length = 1024) + private String archiveObjectKey; + @Column(name = "archive_sha256", nullable = false, length = 64) + private String archiveSha256; + @JdbcTypeCode(SqlTypes.JSON) + @Column(name = "plan_json", nullable = false, columnDefinition = "jsonb") + private Map plan; + @Column(name = "warning_digest", nullable = false, length = 64) + private String warningDigest; + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false, length = 32) + private SkillSuiteBundleOperationStatus status; + @Column(name = "failure_code", length = 128) + private String failureCode; + @Column(name = "failure_detail") + private String failureDetail; + @Column(name = "result_suite_id") + private Long resultSuiteId; + @Column(name = "result_suite_version_id") + private Long resultSuiteVersionId; + @Column(name = "created_at", nullable = false, updatable = false) + private Instant createdAt; + @Column(name = "updated_at", nullable = false) + private Instant updatedAt; + @Column(name = "completed_at") + private Instant completedAt; + @Column(name = "staged_objects_cleaned_at") + private Instant stagedObjectsCleanedAt; + @Column(name = "staged_cleanup_failed_at") + private Instant stagedCleanupFailedAt; + @Column(name = "staged_cleanup_failure_code", length = 128) + private String stagedCleanupFailureCode; + @Version + @Column(name = "lock_version", nullable = false) + private long lockVersion; + + protected SkillSuiteBundleExecutionOperation() { + } + + public SkillSuiteBundleExecutionOperation( + String operationId, String previewToken, String clientRequestId, String actorId, + SkillSuiteBundleMode mode, Long namespaceId, String targetSuiteSlug, Long targetSuiteId, + Long baseSuiteVersionId, String targetVersion, String archiveObjectKey, + String archiveSha256, Map plan, String warningDigest, Instant now + ) { + this.operationId = operationId; + this.previewToken = previewToken; + this.clientRequestId = clientRequestId; + this.actorId = actorId; + this.mode = mode; + this.namespaceId = namespaceId; + this.targetSuiteSlug = targetSuiteSlug; + this.targetSuiteId = targetSuiteId; + this.baseSuiteVersionId = baseSuiteVersionId; + this.targetVersion = targetVersion; + this.reservationKey = reservationKey(mode, namespaceId, targetSuiteSlug, targetSuiteId, targetVersion); + this.reservationActive = true; + this.archiveObjectKey = archiveObjectKey; + this.archiveSha256 = archiveSha256; + this.plan = Collections.unmodifiableMap(new LinkedHashMap<>(plan)); + this.warningDigest = warningDigest; + this.status = SkillSuiteBundleOperationStatus.RUNNING; + this.createdAt = now; + this.updatedAt = now; + } + + public static String reservationKey( + SkillSuiteBundleMode mode, Long namespaceId, String suiteSlug, + Long suiteId, String targetVersion + ) { + if (mode == SkillSuiteBundleMode.CREATE) { + if (namespaceId == null || suiteSlug == null || suiteSlug.isBlank()) { + throw new IllegalArgumentException("CREATE reservation requires namespace and suite slug"); + } + return "create:" + namespaceId + ":" + suiteSlug; + } + if (suiteId == null || targetVersion == null || targetVersion.isBlank()) { + throw new IllegalArgumentException("UPDATE reservation requires suite and target version"); + } + return "update:" + suiteId + ":" + targetVersion; + } + + public void transition(SkillSuiteBundleOperationStatus next, Instant now) { + this.status = next; + this.updatedAt = now; + if (next == SkillSuiteBundleOperationStatus.REPREVIEW_REQUIRED + || next == SkillSuiteBundleOperationStatus.SUITE_DRAFT_CREATED + || next == SkillSuiteBundleOperationStatus.CANCELLED) { + this.reservationActive = false; + this.completedAt = now; + } + } + + public boolean cancel(Instant now) { + if (status == SkillSuiteBundleOperationStatus.CANCELLED) { + return false; + } + if (status == SkillSuiteBundleOperationStatus.SUITE_DRAFT_CREATED + || status == SkillSuiteBundleOperationStatus.REPREVIEW_REQUIRED) { + throw new DomainBadRequestException("error.suite.bundle.operation.cancel.notAllowed"); + } + transition(SkillSuiteBundleOperationStatus.CANCELLED, now); + return true; + } + + public void markBlockedRetryable(String code, String detail, Instant now) { + this.failureCode = code; + this.failureDetail = detail; + transition(SkillSuiteBundleOperationStatus.BLOCKED_RETRYABLE, now); + } + + public void retry(Instant now) { + if (status != SkillSuiteBundleOperationStatus.BLOCKED_RETRYABLE) { + throw new DomainBadRequestException("error.suite.bundle.operation.retry.notAllowed"); + } + this.failureCode = null; + this.failureDetail = null; + transition(SkillSuiteBundleOperationStatus.RUNNING, now); + } + + public void requireRetryable() { + if (status != SkillSuiteBundleOperationStatus.BLOCKED_RETRYABLE) { + throw new DomainBadRequestException("error.suite.bundle.operation.retry.notAllowed"); + } + } + + public void markRepreviewRequired(String code, Instant now) { + this.failureCode = code; + this.failureDetail = null; + transition(SkillSuiteBundleOperationStatus.REPREVIEW_REQUIRED, now); + } + + public void markWaitingForMembers(Instant now) { + this.failureCode = null; + this.failureDetail = null; + transition(SkillSuiteBundleOperationStatus.WAITING_FOR_MEMBERS, now); + } + + public void markRunning(Instant now) { + this.failureCode = null; + this.failureDetail = null; + transition(SkillSuiteBundleOperationStatus.RUNNING, now); + } + + public void markSuiteDraftCreated(Long suiteId, Long suiteVersionId, Instant now) { + if (suiteId == null || suiteVersionId == null) { + throw new IllegalArgumentException("Bundle result requires Suite and version IDs"); + } + this.resultSuiteId = suiteId; + this.resultSuiteVersionId = suiteVersionId; + this.failureCode = null; + this.failureDetail = null; + transition(SkillSuiteBundleOperationStatus.SUITE_DRAFT_CREATED, now); + } + + public void markStagedCleanupFailed(String failureCode, Instant now) { + this.stagedCleanupFailureCode = failureCode; + this.stagedCleanupFailedAt = now; + } + + public void markStagedObjectsCleaned(Instant now) { + this.stagedObjectsCleanedAt = now; + this.stagedCleanupFailedAt = null; + this.stagedCleanupFailureCode = null; + } + + public String getOperationId() { return operationId; } + public String getPreviewToken() { return previewToken; } + public String getClientRequestId() { return clientRequestId; } + public String getActorId() { return actorId; } + public SkillSuiteBundleMode getMode() { return mode; } + public Long getNamespaceId() { return namespaceId; } + public String getTargetSuiteSlug() { return targetSuiteSlug; } + public Long getTargetSuiteId() { return targetSuiteId; } + public Long getBaseSuiteVersionId() { return baseSuiteVersionId; } + public String getTargetVersion() { return targetVersion; } + public String getReservationKey() { return reservationKey; } + public boolean isReservationActive() { return reservationActive; } + public String getArchiveObjectKey() { return archiveObjectKey; } + public String getArchiveSha256() { return archiveSha256; } + public Map getPlan() { return plan; } + public String getWarningDigest() { return warningDigest; } + public SkillSuiteBundleOperationStatus getStatus() { return status; } + public String getFailureCode() { return failureCode; } + public String getFailureDetail() { return failureDetail; } + public Long getResultSuiteId() { return resultSuiteId; } + public Long getResultSuiteVersionId() { return resultSuiteVersionId; } + public Instant getCreatedAt() { return createdAt; } + public Instant getUpdatedAt() { return updatedAt; } + public Instant getCompletedAt() { return completedAt; } + public Instant getStagedObjectsCleanedAt() { return stagedObjectsCleanedAt; } + public Instant getStagedCleanupFailedAt() { return stagedCleanupFailedAt; } + public String getStagedCleanupFailureCode() { return stagedCleanupFailureCode; } + public long getLockVersion() { return lockVersion; } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/bundle/SkillSuiteBundleExecutionOperationRepository.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/bundle/SkillSuiteBundleExecutionOperationRepository.java new file mode 100644 index 00000000..51be229d --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/bundle/SkillSuiteBundleExecutionOperationRepository.java @@ -0,0 +1,20 @@ +package com.iflytek.skillhub.domain.suite.bundle; + +import java.util.Collection; +import java.util.List; +import java.util.Optional; + +public interface SkillSuiteBundleExecutionOperationRepository { + SkillSuiteBundleExecutionOperation save(SkillSuiteBundleExecutionOperation operation); + void flush(); + Optional findById(String operationId); + Optional findByIdForUpdate(String operationId); + Optional findByActorIdAndClientRequestId( + String actorId, String clientRequestId); + Optional findByPreviewToken(String previewToken); + List findTop100ByStatusInOrderByUpdatedAtAsc( + Collection statuses); + List + findTop100ByStatusInAndStagedObjectsCleanedAtIsNullOrderByCompletedAtAsc( + Collection statuses); +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/bundle/SkillSuiteBundleManifest.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/bundle/SkillSuiteBundleManifest.java new file mode 100644 index 00000000..8c7ab797 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/bundle/SkillSuiteBundleManifest.java @@ -0,0 +1,37 @@ +package com.iflytek.skillhub.domain.suite.bundle; + +import com.iflytek.skillhub.domain.skill.SkillVisibility; + +import java.util.List; + +/** Parsed root {@value #FILE_NAME} protocol document for one Suite Bundle submission. */ +public record SkillSuiteBundleManifest( + String apiVersion, + String kind, + Metadata metadata, + Spec spec +) { + public static final String FILE_NAME = "SUITE.yaml"; + public static final String API_VERSION = "skillhub.iflytek.com/v1alpha1"; + public static final String KIND = "SkillSuiteBundle"; + + public record Metadata(SkillSuiteBundleCoordinate coordinate) { + } + + public record Spec( + SkillSuiteBundleMode mode, + String baseVersion, + String version, + String displayName, + String summary, + String overview, + SkillVisibility visibility, + String changelog, + SkillSuiteBundleCoordinate entry, + List members + ) { + public Spec { + members = List.copyOf(members); + } + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/bundle/SkillSuiteBundleManifestParser.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/bundle/SkillSuiteBundleManifestParser.java new file mode 100644 index 00000000..fa93b231 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/bundle/SkillSuiteBundleManifestParser.java @@ -0,0 +1,311 @@ +package com.iflytek.skillhub.domain.suite.bundle; + +import com.iflytek.skillhub.domain.namespace.SlugValidator; +import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; +import com.iflytek.skillhub.domain.skill.SkillVisibility; +import com.iflytek.skillhub.domain.skill.validation.SkillPackagePolicy; +import org.yaml.snakeyaml.LoaderOptions; +import org.yaml.snakeyaml.Yaml; +import org.yaml.snakeyaml.constructor.SafeConstructor; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.regex.Pattern; + +/** Strict, side-effect-free parser for the root {@code SUITE.yaml} Bundle manifest. */ +public class SkillSuiteBundleManifestParser { + + private static final int MAX_YAML_ALIASES = 20; + private static final int MAX_YAML_NESTING_DEPTH = 20; + private static final int MAX_YAML_CODE_POINTS = 256_000; + private static final int MAX_MEMBERS = 100; + private static final Pattern PORTABLE_VERSION_PATTERN = + Pattern.compile("[A-Za-z0-9][A-Za-z0-9._+-]{0,63}"); + + private static final Set ROOT_FIELDS = Set.of("apiVersion", "kind", "metadata", "spec"); + private static final Set METADATA_FIELDS = Set.of("namespace", "slug"); + private static final Set SPEC_FIELDS = Set.of( + "mode", "baseVersion", "version", "displayName", "summary", "overview", + "visibility", "changelog", "entry", "members"); + private static final Set MEMBER_FIELDS = Set.of("skill", "package", "reference"); + private static final Set PACKAGE_FIELDS = Set.of("path", "visibility"); + private static final Set REFERENCE_FIELDS = Set.of("version"); + + public SkillSuiteBundleManifest parse(String yamlContent) { + if (yamlContent == null || yamlContent.isBlank()) { + throw invalid("manifest is empty"); + } + + Map root = parseYaml(yamlContent); + rejectUnknownFields(root, ROOT_FIELDS, "manifest"); + String apiVersion = requiredString(root, "apiVersion", "manifest"); + String kind = requiredString(root, "kind", "manifest"); + if (!SkillSuiteBundleManifest.API_VERSION.equals(apiVersion)) { + throw invalid("unsupported apiVersion: " + apiVersion); + } + if (!SkillSuiteBundleManifest.KIND.equals(kind)) { + throw invalid("kind must be " + SkillSuiteBundleManifest.KIND); + } + + SkillSuiteBundleManifest.Metadata metadata = parseMetadata(requiredMap(root, "metadata", "manifest")); + SkillSuiteBundleManifest.Spec spec = parseSpec(requiredMap(root, "spec", "manifest")); + return new SkillSuiteBundleManifest(apiVersion, kind, metadata, spec); + } + + private Map parseYaml(String yamlContent) { + try { + LoaderOptions options = new LoaderOptions(); + options.setAllowDuplicateKeys(false); + options.setMaxAliasesForCollections(MAX_YAML_ALIASES); + options.setNestingDepthLimit(MAX_YAML_NESTING_DEPTH); + options.setCodePointLimit(MAX_YAML_CODE_POINTS); + Object parsed = new Yaml(new SafeConstructor(options)).load(yamlContent); + if (!(parsed instanceof Map map)) { + throw invalid("manifest must be a YAML object"); + } + return map; + } catch (DomainBadRequestException exception) { + throw exception; + } catch (Exception exception) { + throw invalid("invalid YAML: " + safeMessage(exception)); + } + } + + private SkillSuiteBundleManifest.Metadata parseMetadata(Map map) { + rejectUnknownFields(map, METADATA_FIELDS, "metadata"); + String namespace = requiredString(map, "namespace", "metadata"); + String slug = requiredString(map, "slug", "metadata"); + return new SkillSuiteBundleManifest.Metadata(coordinate(namespace, slug, "metadata")); + } + + private SkillSuiteBundleManifest.Spec parseSpec(Map map) { + rejectUnknownFields(map, SPEC_FIELDS, "spec"); + SkillSuiteBundleMode mode = enumValue( + requiredString(map, "mode", "spec"), SkillSuiteBundleMode.class, "spec.mode"); + String baseVersion = optionalString(map, "baseVersion", "spec"); + if (mode == SkillSuiteBundleMode.UPDATE && baseVersion == null) { + throw invalid("spec.baseVersion is required for UPDATE"); + } + if (mode == SkillSuiteBundleMode.CREATE && baseVersion != null) { + throw invalid("spec.baseVersion is not allowed for CREATE"); + } + validateVersion(baseVersion, "spec.baseVersion"); + + String version = requiredString(map, "version", "spec"); + validateVersion(version, "spec.version"); + String displayName = boundedRequiredString(map, "displayName", "spec", 256); + String summary = mode == SkillSuiteBundleMode.CREATE + ? boundedRequiredString(map, "summary", "spec", 4_000) + : boundedOptionalString(map, "summary", "spec", 4_000); + String overview = mode == SkillSuiteBundleMode.CREATE + ? boundedRequiredString(map, "overview", "spec", 20_000) + : boundedOptionalString(map, "overview", "spec", 20_000); + SkillVisibility visibility = enumValue( + requiredString(map, "visibility", "spec"), SkillVisibility.class, "spec.visibility"); + String changelog = boundedOptionalString(map, "changelog", "spec", 4_000); + SkillSuiteBundleCoordinate entry = parseCoordinate(requiredString(map, "entry", "spec"), "spec.entry"); + List members = parseMembers(requiredList(map, "members", "spec")); + if (members.stream().noneMatch(member -> member.coordinate().equals(entry))) { + throw invalid("spec entry skill must be a member: " + entry.canonical()); + } + validatePackageDirectories(members); + + return new SkillSuiteBundleManifest.Spec( + mode, baseVersion, version, displayName, summary, overview, visibility, + changelog, entry, members); + } + + private List parseMembers(List values) { + if (values.isEmpty()) { + throw invalid("spec.members must not be empty"); + } + if (values.size() > MAX_MEMBERS) { + throw invalid("spec.members exceeds max " + MAX_MEMBERS); + } + + List members = new ArrayList<>(values.size()); + Set coordinates = new HashSet<>(); + for (int index = 0; index < values.size(); index++) { + String location = "spec.members[" + index + "]"; + if (!(values.get(index) instanceof Map memberMap)) { + throw invalid(location + " must be an object"); + } + rejectUnknownFields(memberMap, MEMBER_FIELDS, location); + SkillSuiteBundleCoordinate skill = parseCoordinate( + requiredString(memberMap, "skill", location), location + ".skill"); + if (!coordinates.add(skill)) { + throw invalid("duplicate member skill: " + skill.canonical()); + } + + boolean hasPackage = hasNonNull(memberMap, "package"); + boolean hasReference = hasNonNull(memberMap, "reference"); + if (hasPackage == hasReference) { + throw invalid(location + " must define exactly one of package or reference"); + } + + SkillSuiteBundleMember.PackageSource packageSource = hasPackage + ? parsePackage(requiredMap(memberMap, "package", location), location + ".package") + : null; + SkillSuiteBundleMember.ReferenceSource referenceSource = hasReference + ? parseReference(requiredMap(memberMap, "reference", location), location + ".reference") + : null; + members.add(new SkillSuiteBundleMember(skill, packageSource, referenceSource)); + } + return List.copyOf(members); + } + + private SkillSuiteBundleMember.PackageSource parsePackage(Map map, String location) { + rejectUnknownFields(map, PACKAGE_FIELDS, location); + String rawPath = requiredString(map, "path", location); + String path; + try { + path = SkillPackagePolicy.normalizeEntryPath(rawPath); + } catch (IllegalArgumentException exception) { + throw invalid("unsafe package path at " + location + ": " + safeMessage(exception)); + } + SkillVisibility visibility = null; + String rawVisibility = optionalString(map, "visibility", location); + if (rawVisibility != null) { + visibility = enumValue(rawVisibility, SkillVisibility.class, location + ".visibility"); + } + return new SkillSuiteBundleMember.PackageSource(path, visibility); + } + + private SkillSuiteBundleMember.ReferenceSource parseReference(Map map, String location) { + rejectUnknownFields(map, REFERENCE_FIELDS, location); + String version = requiredString(map, "version", location); + validateVersion(version, location + ".version"); + return new SkillSuiteBundleMember.ReferenceSource(version); + } + + private void validatePackageDirectories(List members) { + List directories = members.stream() + .filter(member -> member.packageSource() != null) + .map(member -> member.packageSource().path()) + .sorted() + .toList(); + for (int left = 0; left < directories.size(); left++) { + for (int right = left + 1; right < directories.size(); right++) { + String first = directories.get(left); + String second = directories.get(right); + if (second.equals(first) || second.startsWith(first + "/")) { + throw invalid("package directories must not overlap: " + first + " and " + second); + } + } + } + } + + private SkillSuiteBundleCoordinate parseCoordinate(String raw, String location) { + if (!raw.startsWith("@") || raw.indexOf('/') < 2 || raw.indexOf('/') != raw.lastIndexOf('/')) { + throw invalid(location + " must be an @namespace/slug coordinate"); + } + int slash = raw.indexOf('/'); + return coordinate(raw.substring(1, slash), raw.substring(slash + 1), location); + } + + private SkillSuiteBundleCoordinate coordinate(String namespace, String slug, String location) { + try { + if (!"global".equals(namespace)) { + SlugValidator.validate(namespace); + } + SlugValidator.validate(slug); + } catch (DomainBadRequestException exception) { + throw invalid(location + " contains an invalid coordinate"); + } + return new SkillSuiteBundleCoordinate(namespace, slug); + } + + private void validateVersion(String value, String location) { + if (value != null && !PORTABLE_VERSION_PATTERN.matcher(value).matches()) { + throw invalid(location + " must be an exact portable version"); + } + } + + private void rejectUnknownFields(Map map, Set allowed, String location) { + Set unknown = new LinkedHashSet<>(); + for (Object key : map.keySet()) { + if (!(key instanceof String stringKey) || !allowed.contains(stringKey)) { + unknown.add(String.valueOf(key)); + } + } + if (!unknown.isEmpty()) { + throw invalid("unknown field at " + location + ": " + String.join(", ", unknown)); + } + } + + private Map requiredMap(Map map, String field, String location) { + Object value = map.get(field); + if (!(value instanceof Map nested)) { + throw invalid(location + "." + field + " must be an object"); + } + return nested; + } + + private List requiredList(Map map, String field, String location) { + Object value = map.get(field); + if (!(value instanceof List list)) { + throw invalid(location + "." + field + " must be a list"); + } + return list; + } + + private String requiredString(Map map, String field, String location) { + String value = optionalString(map, field, location); + if (value == null) { + throw invalid(location + "." + field + " is required"); + } + return value; + } + + private String optionalString(Map map, String field, String location) { + Object value = map.get(field); + if (value == null) { + return null; + } + if (!(value instanceof String stringValue) || stringValue.isBlank()) { + throw invalid(location + "." + field + " must be a non-blank string"); + } + return stringValue.trim(); + } + + private String boundedRequiredString(Map map, String field, String location, int maxLength) { + String value = requiredString(map, field, location); + if (value.length() > maxLength) { + throw invalid(location + "." + field + " exceeds max length " + maxLength); + } + return value; + } + + private String boundedOptionalString(Map map, String field, String location, int maxLength) { + String value = optionalString(map, field, location); + if (value != null && value.length() > maxLength) { + throw invalid(location + "." + field + " exceeds max length " + maxLength); + } + return value; + } + + private > E enumValue(String raw, Class type, String location) { + try { + return Enum.valueOf(type, raw.toUpperCase(Locale.ROOT)); + } catch (IllegalArgumentException exception) { + throw invalid(location + " has unsupported value: " + raw); + } + } + + private boolean hasNonNull(Map map, String field) { + return map.containsKey(field) && map.get(field) != null; + } + + private String safeMessage(Exception exception) { + return exception.getMessage() == null ? exception.getClass().getSimpleName() : exception.getMessage(); + } + + private DomainBadRequestException invalid(String detail) { + return new DomainBadRequestException("error.suite.bundle.manifest.invalid", detail); + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/bundle/SkillSuiteBundleMember.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/bundle/SkillSuiteBundleMember.java new file mode 100644 index 00000000..1aa1c6b4 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/bundle/SkillSuiteBundleMember.java @@ -0,0 +1,16 @@ +package com.iflytek.skillhub.domain.suite.bundle; + +import com.iflytek.skillhub.domain.skill.SkillVisibility; + +/** One ordered Bundle member, backed by either uploaded package content or an exact reference. */ +public record SkillSuiteBundleMember( + SkillSuiteBundleCoordinate coordinate, + PackageSource packageSource, + ReferenceSource referenceSource +) { + public record PackageSource(String path, SkillVisibility visibility) { + } + + public record ReferenceSource(String version) { + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/bundle/SkillSuiteBundleMemberResult.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/bundle/SkillSuiteBundleMemberResult.java new file mode 100644 index 00000000..33ee418e --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/bundle/SkillSuiteBundleMemberResult.java @@ -0,0 +1,193 @@ +package com.iflytek.skillhub.domain.suite.bundle; + +import com.iflytek.skillhub.domain.skill.SkillVisibility; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; + +import java.time.Instant; +import java.util.List; + +/** Durable per-member plan and execution result for a confirmed Bundle operation. */ +@Entity +@Table(name = "skill_suite_bundle_member_result") +public class SkillSuiteBundleMemberResult { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + @Column(name = "operation_id", nullable = false, length = 64) + private String operationId; + @Column(name = "position", nullable = false) + private int position; + @Column(name = "namespace_slug", nullable = false, length = 128) + private String namespaceSlug; + @Column(name = "skill_slug", nullable = false, length = 128) + private String skillSlug; + @Enumerated(EnumType.STRING) + @Column(name = "source_type", nullable = false, length = 32) + private SkillSuiteBundleMemberSourceType sourceType; + @Column(name = "package_path", length = 1024) + private String packagePath; + @Enumerated(EnumType.STRING) + @Column(name = "requested_visibility", length = 32) + private SkillVisibility requestedVisibility; + @Column(name = "requested_version", length = 64) + private String requestedVersion; + @Enumerated(EnumType.STRING) + @Column(name = "relationship_change", nullable = false, length = 32) + private SkillSuiteBundleRelationshipChange relationshipChange; + @Enumerated(EnumType.STRING) + @Column(name = "publish_action", nullable = false, length = 32) + private SkillSuiteBundlePublishAction publishAction; + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false, length = 32) + private SkillSuiteBundleMemberResultStatus status; + @Column(name = "fingerprint", length = 255) + private String fingerprint; + @Column(name = "skill_id") + private Long skillId; + @Column(name = "skill_version_id") + private Long skillVersionId; + @JdbcTypeCode(SqlTypes.JSON) + @Column(name = "errors", nullable = false, columnDefinition = "jsonb") + private List errors; + @JdbcTypeCode(SqlTypes.JSON) + @Column(name = "warnings", nullable = false, columnDefinition = "jsonb") + private List warnings; + @Column(name = "created_at", nullable = false, updatable = false) + private Instant createdAt; + @Column(name = "updated_at", nullable = false) + private Instant updatedAt; + + protected SkillSuiteBundleMemberResult() { + } + + public SkillSuiteBundleMemberResult( + String operationId, int position, SkillSuiteBundleCoordinate coordinate, + SkillSuiteBundleMemberSourceType sourceType, String packagePath, + SkillVisibility requestedVisibility, String requestedVersion, + SkillSuiteBundleRelationshipChange relationshipChange, + SkillSuiteBundlePublishAction publishAction, String fingerprint, + Long skillId, Long skillVersionId, List errors, List warnings, Instant now + ) { + this.operationId = operationId; + this.position = position; + this.namespaceSlug = coordinate.namespace(); + this.skillSlug = coordinate.slug(); + this.sourceType = sourceType; + this.packagePath = packagePath; + this.requestedVisibility = requestedVisibility; + this.requestedVersion = requestedVersion; + this.relationshipChange = relationshipChange; + this.publishAction = publishAction; + this.fingerprint = fingerprint; + this.skillId = skillId; + this.skillVersionId = skillVersionId; + this.errors = List.copyOf(errors); + this.warnings = List.copyOf(warnings); + this.status = SkillSuiteBundleMemberResultStatus.PLANNED; + this.createdAt = now; + this.updatedAt = now; + } + + public void cancelUnlessCompleted(Instant now) { + if (status != SkillSuiteBundleMemberResultStatus.COMPLETED) { + status = SkillSuiteBundleMemberResultStatus.CANCELLED; + updatedAt = now; + } + } + + public boolean start(Instant now) { + if (status != SkillSuiteBundleMemberResultStatus.PLANNED) { + return false; + } + status = SkillSuiteBundleMemberResultStatus.RUNNING; + updatedAt = now; + return true; + } + + public void bindVersion(Long resolvedSkillId, Long resolvedSkillVersionId, Instant now) { + if (resolvedSkillId == null || resolvedSkillVersionId == null) { + throw new IllegalArgumentException("Completed Bundle member requires Skill and version IDs"); + } + skillId = resolvedSkillId; + skillVersionId = resolvedSkillVersionId; + updatedAt = now; + } + + public void markWaiting(Instant now) { + status = SkillSuiteBundleMemberResultStatus.WAITING_FOR_MEMBER; + updatedAt = now; + } + + public void markCompleted(Instant now) { + if (skillId == null || skillVersionId == null) { + throw new IllegalStateException("Bundle member cannot complete without bound IDs"); + } + status = SkillSuiteBundleMemberResultStatus.COMPLETED; + updatedAt = now; + } + + public void markBlockedRetryable(String errorCode, Instant now) { + status = SkillSuiteBundleMemberResultStatus.BLOCKED_RETRYABLE; + errors = appendError(errorCode); + updatedAt = now; + } + + public void markRepreviewRequired(String errorCode, Instant now) { + status = SkillSuiteBundleMemberResultStatus.REPREVIEW_REQUIRED; + errors = appendError(errorCode); + updatedAt = now; + } + + public void retryUnlessCompleted(Instant now) { + if (status == SkillSuiteBundleMemberResultStatus.BLOCKED_RETRYABLE) { + status = SkillSuiteBundleMemberResultStatus.PLANNED; + updatedAt = now; + } + } + + public void requireRepreviewUnlessCompleted(Instant now) { + if (status != SkillSuiteBundleMemberResultStatus.COMPLETED) { + status = SkillSuiteBundleMemberResultStatus.REPREVIEW_REQUIRED; + updatedAt = now; + } + } + + private List appendError(String errorCode) { + if (errorCode == null || errorCode.isBlank() || errors.contains(errorCode)) { + return errors; + } + java.util.ArrayList updated = new java.util.ArrayList<>(errors); + updated.add(errorCode); + return List.copyOf(updated); + } + + public Long getId() { return id; } + public String getOperationId() { return operationId; } + public int getPosition() { return position; } + public String getNamespaceSlug() { return namespaceSlug; } + public String getSkillSlug() { return skillSlug; } + public SkillSuiteBundleMemberSourceType getSourceType() { return sourceType; } + public String getPackagePath() { return packagePath; } + public SkillVisibility getRequestedVisibility() { return requestedVisibility; } + public String getRequestedVersion() { return requestedVersion; } + public SkillSuiteBundleRelationshipChange getRelationshipChange() { return relationshipChange; } + public SkillSuiteBundlePublishAction getPublishAction() { return publishAction; } + public SkillSuiteBundleMemberResultStatus getStatus() { return status; } + public String getFingerprint() { return fingerprint; } + public Long getSkillId() { return skillId; } + public Long getSkillVersionId() { return skillVersionId; } + public List getErrors() { return errors; } + public List getWarnings() { return warnings; } + public Instant getCreatedAt() { return createdAt; } + public Instant getUpdatedAt() { return updatedAt; } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/bundle/SkillSuiteBundleMemberResultRepository.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/bundle/SkillSuiteBundleMemberResultRepository.java new file mode 100644 index 00000000..d0d75683 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/bundle/SkillSuiteBundleMemberResultRepository.java @@ -0,0 +1,11 @@ +package com.iflytek.skillhub.domain.suite.bundle; + +import java.util.List; + +public interface SkillSuiteBundleMemberResultRepository { + List saveAll(List members); + void flush(); + List findByOperationIdOrderByPosition(String operationId); + List findByOperationIdOrderByPositionForUpdate(String operationId); + List findBySkillVersionId(Long skillVersionId); +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/bundle/SkillSuiteBundleMemberResultStatus.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/bundle/SkillSuiteBundleMemberResultStatus.java new file mode 100644 index 00000000..711b9785 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/bundle/SkillSuiteBundleMemberResultStatus.java @@ -0,0 +1,11 @@ +package com.iflytek.skillhub.domain.suite.bundle; + +public enum SkillSuiteBundleMemberResultStatus { + PLANNED, + RUNNING, + WAITING_FOR_MEMBER, + COMPLETED, + BLOCKED_RETRYABLE, + REPREVIEW_REQUIRED, + CANCELLED +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/bundle/SkillSuiteBundleMemberSourceType.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/bundle/SkillSuiteBundleMemberSourceType.java new file mode 100644 index 00000000..176a7d0a --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/bundle/SkillSuiteBundleMemberSourceType.java @@ -0,0 +1,6 @@ +package com.iflytek.skillhub.domain.suite.bundle; + +public enum SkillSuiteBundleMemberSourceType { + PACKAGE, + REFERENCE +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/bundle/SkillSuiteBundleMode.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/bundle/SkillSuiteBundleMode.java new file mode 100644 index 00000000..67e69f3b --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/bundle/SkillSuiteBundleMode.java @@ -0,0 +1,7 @@ +package com.iflytek.skillhub.domain.suite.bundle; + +/** Whether a Bundle creates a Suite or derives a new draft from an existing Suite version. */ +public enum SkillSuiteBundleMode { + CREATE, + UPDATE +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/bundle/SkillSuiteBundleOperationAuthorizationPolicy.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/bundle/SkillSuiteBundleOperationAuthorizationPolicy.java new file mode 100644 index 00000000..18618f84 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/bundle/SkillSuiteBundleOperationAuthorizationPolicy.java @@ -0,0 +1,29 @@ +package com.iflytek.skillhub.domain.suite.bundle; + +import com.iflytek.skillhub.domain.namespace.NamespaceRole; + +import java.util.Map; +import java.util.Set; + +/** Authorization shared by Bundle operation status and control commands. */ +public final class SkillSuiteBundleOperationAuthorizationPolicy { + + private SkillSuiteBundleOperationAuthorizationPolicy() { + } + + public static boolean canAccess( + SkillSuiteBundleExecutionOperation operation, + String actorId, + Map namespaceRoles, + Set platformRoles + ) { + if (operation.getActorId().equals(actorId)) { + return true; + } + if (platformRoles != null && platformRoles.contains("SUPER_ADMIN")) { + return true; + } + NamespaceRole role = namespaceRoles == null ? null : namespaceRoles.get(operation.getNamespaceId()); + return role == NamespaceRole.OWNER || role == NamespaceRole.ADMIN; + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/bundle/SkillSuiteBundleOperationStatus.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/bundle/SkillSuiteBundleOperationStatus.java new file mode 100644 index 00000000..60baab87 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/bundle/SkillSuiteBundleOperationStatus.java @@ -0,0 +1,10 @@ +package com.iflytek.skillhub.domain.suite.bundle; + +public enum SkillSuiteBundleOperationStatus { + RUNNING, + WAITING_FOR_MEMBERS, + BLOCKED_RETRYABLE, + REPREVIEW_REQUIRED, + SUITE_DRAFT_CREATED, + CANCELLED +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/bundle/SkillSuiteBundlePreviewSession.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/bundle/SkillSuiteBundlePreviewSession.java new file mode 100644 index 00000000..eab88f73 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/bundle/SkillSuiteBundlePreviewSession.java @@ -0,0 +1,161 @@ +package com.iflytek.skillhub.domain.suite.bundle; + +import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; +import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.persistence.Version; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; + +import java.time.Instant; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; + +/** Expiring, non-reserving result of parsing and planning one uploaded Suite Bundle. */ +@Entity +@Table(name = "skill_suite_bundle_preview") +public class SkillSuiteBundlePreviewSession { + + @Id + @Column(name = "token", length = 64) + private String token; + @Column(name = "actor_id", nullable = false, length = 128) + private String actorId; + @Enumerated(EnumType.STRING) + @Column(name = "mode", nullable = false, length = 16) + private SkillSuiteBundleMode mode; + @Column(name = "namespace_id", nullable = false) + private Long namespaceId; + @Column(name = "target_suite_slug", nullable = false, length = 128) + private String targetSuiteSlug; + @Column(name = "target_suite_id") + private Long targetSuiteId; + @Column(name = "base_suite_version_id") + private Long baseSuiteVersionId; + @Column(name = "target_version", nullable = false, length = 64) + private String targetVersion; + @Column(name = "archive_object_key", nullable = false, length = 1024) + private String archiveObjectKey; + @Column(name = "archive_sha256", nullable = false, length = 64) + private String archiveSha256; + @JdbcTypeCode(SqlTypes.JSON) + @Column(name = "manifest_json", nullable = false, columnDefinition = "jsonb") + private Map manifest; + @JdbcTypeCode(SqlTypes.JSON) + @Column(name = "plan_json", nullable = false, columnDefinition = "jsonb") + private Map plan; + @Column(name = "warning_digest", nullable = false, length = 64) + private String warningDigest; + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false, length = 32) + private SkillSuiteBundlePreviewStatus status; + @Column(name = "expires_at", nullable = false) + private Instant expiresAt; + @Column(name = "confirmed_at") + private Instant confirmedAt; + @Column(name = "staged_objects_cleaned_at") + private Instant stagedObjectsCleanedAt; + @Column(name = "staged_cleanup_failed_at") + private Instant stagedCleanupFailedAt; + @Column(name = "staged_cleanup_failure_code", length = 128) + private String stagedCleanupFailureCode; + @Column(name = "created_at", nullable = false, updatable = false) + private Instant createdAt; + @Version + @Column(name = "lock_version", nullable = false) + private long lockVersion; + + protected SkillSuiteBundlePreviewSession() { + } + + public SkillSuiteBundlePreviewSession( + String token, String actorId, SkillSuiteBundleMode mode, Long namespaceId, + String targetSuiteSlug, Long targetSuiteId, Long baseSuiteVersionId, String targetVersion, + String archiveObjectKey, String archiveSha256, Map manifest, + Map plan, String warningDigest, Instant expiresAt, Instant createdAt + ) { + this.token = token; + this.actorId = actorId; + this.mode = mode; + this.namespaceId = namespaceId; + this.targetSuiteSlug = targetSuiteSlug; + this.targetSuiteId = targetSuiteId; + this.baseSuiteVersionId = baseSuiteVersionId; + this.targetVersion = targetVersion; + this.archiveObjectKey = archiveObjectKey; + this.archiveSha256 = archiveSha256; + this.manifest = immutableJsonMap(manifest); + this.plan = immutableJsonMap(plan); + this.warningDigest = warningDigest; + this.status = SkillSuiteBundlePreviewStatus.PREVIEW_READY; + this.expiresAt = expiresAt; + this.createdAt = createdAt; + } + + public void markConfirmed(Instant confirmedAt) { + this.status = SkillSuiteBundlePreviewStatus.CONFIRMED; + this.confirmedAt = confirmedAt; + } + + public void markExpired() { + this.status = SkillSuiteBundlePreviewStatus.EXPIRED; + } + + public void markStagedCleanupFailed(String failureCode, Instant now) { + this.stagedCleanupFailureCode = failureCode; + this.stagedCleanupFailedAt = now; + } + + public void markStagedObjectsCleaned(Instant now) { + this.stagedObjectsCleanedAt = now; + this.stagedCleanupFailedAt = null; + this.stagedCleanupFailureCode = null; + } + + /** + * Verifies immutable PreviewSession invariants before confirmation starts revalidating live state. + */ + public void requireConfirmableBy(String actorId, String confirmedWarningDigest, Instant now) { + if (!this.actorId.equals(actorId)) { + throw new DomainForbiddenException("error.suite.bundle.preview.ownerMismatch"); + } + if (status != SkillSuiteBundlePreviewStatus.PREVIEW_READY || !expiresAt.isAfter(now)) { + throw new DomainBadRequestException("error.suite.bundle.preview.expired"); + } + if (!warningDigest.equals(confirmedWarningDigest)) { + throw new DomainBadRequestException("error.suite.bundle.preview.warningMismatch"); + } + } + + public String getToken() { return token; } + public String getActorId() { return actorId; } + public SkillSuiteBundleMode getMode() { return mode; } + public Long getNamespaceId() { return namespaceId; } + public String getTargetSuiteSlug() { return targetSuiteSlug; } + public Long getTargetSuiteId() { return targetSuiteId; } + public Long getBaseSuiteVersionId() { return baseSuiteVersionId; } + public String getTargetVersion() { return targetVersion; } + public String getArchiveObjectKey() { return archiveObjectKey; } + public String getArchiveSha256() { return archiveSha256; } + public Map getManifest() { return manifest; } + public Map getPlan() { return plan; } + public String getWarningDigest() { return warningDigest; } + public SkillSuiteBundlePreviewStatus getStatus() { return status; } + public Instant getExpiresAt() { return expiresAt; } + public Instant getConfirmedAt() { return confirmedAt; } + public Instant getStagedObjectsCleanedAt() { return stagedObjectsCleanedAt; } + public Instant getStagedCleanupFailedAt() { return stagedCleanupFailedAt; } + public String getStagedCleanupFailureCode() { return stagedCleanupFailureCode; } + public Instant getCreatedAt() { return createdAt; } + public long getLockVersion() { return lockVersion; } + + private Map immutableJsonMap(Map value) { + return Collections.unmodifiableMap(new LinkedHashMap<>(value)); + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/bundle/SkillSuiteBundlePreviewSessionRepository.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/bundle/SkillSuiteBundlePreviewSessionRepository.java new file mode 100644 index 00000000..8e826cf8 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/bundle/SkillSuiteBundlePreviewSessionRepository.java @@ -0,0 +1,16 @@ +package com.iflytek.skillhub.domain.suite.bundle; + +import java.time.Instant; +import java.util.List; +import java.util.Optional; + +public interface SkillSuiteBundlePreviewSessionRepository { + SkillSuiteBundlePreviewSession save(SkillSuiteBundlePreviewSession preview); + void flush(); + Optional findById(String token); + Optional findByIdForUpdate(String token); + int expireReadyBefore(Instant threshold); + List findTop100ByStatusAndStagedObjectsCleanedAtIsNullOrderByExpiresAtAsc( + SkillSuiteBundlePreviewStatus status); + void delete(SkillSuiteBundlePreviewSession preview); +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/bundle/SkillSuiteBundlePreviewStatus.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/bundle/SkillSuiteBundlePreviewStatus.java new file mode 100644 index 00000000..fe82d20c --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/bundle/SkillSuiteBundlePreviewStatus.java @@ -0,0 +1,7 @@ +package com.iflytek.skillhub.domain.suite.bundle; + +public enum SkillSuiteBundlePreviewStatus { + PREVIEW_READY, + CONFIRMED, + EXPIRED +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/bundle/SkillSuiteBundlePublishAction.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/bundle/SkillSuiteBundlePublishAction.java new file mode 100644 index 00000000..25534866 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/bundle/SkillSuiteBundlePublishAction.java @@ -0,0 +1,9 @@ +package com.iflytek.skillhub.domain.suite.bundle; + +public enum SkillSuiteBundlePublishAction { + CREATE_SKILL, + CREATE_VERSION, + REUSE_VERSION, + REFERENCE_VERSION, + NONE +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/bundle/SkillSuiteBundleRelationshipChange.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/bundle/SkillSuiteBundleRelationshipChange.java new file mode 100644 index 00000000..b89d190e --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/bundle/SkillSuiteBundleRelationshipChange.java @@ -0,0 +1,8 @@ +package com.iflytek.skillhub.domain.suite.bundle; + +public enum SkillSuiteBundleRelationshipChange { + ADDED, + UPDATED, + UNCHANGED, + REMOVED +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/bundle/package-info.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/bundle/package-info.java new file mode 100644 index 00000000..4eba1409 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/bundle/package-info.java @@ -0,0 +1,2 @@ +/** Suite Bundle manifest protocol and validation. */ +package com.iflytek.skillhub.domain.suite.bundle; diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/label/LabelPermissionCheckerTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/label/LabelPermissionCheckerTest.java new file mode 100644 index 00000000..04f6a5a7 --- /dev/null +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/label/LabelPermissionCheckerTest.java @@ -0,0 +1,51 @@ +package com.iflytek.skillhub.domain.label; + +import com.iflytek.skillhub.domain.namespace.NamespaceRole; +import com.iflytek.skillhub.domain.suite.SkillSuite; +import org.junit.jupiter.api.Test; + +import java.util.Map; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; + +class LabelPermissionCheckerTest { + + private final LabelPermissionChecker checker = new LabelPermissionChecker(); + private final SkillSuite suite = new SkillSuite(1L, "starter", "Starter", "author"); + + @Test + void suiteCreatorCanUseRecommendedLabelWhileStillNamespaceMember() { + assertThat(checker.canManageSuiteLabel( + suite, label(LabelType.RECOMMENDED), "author", + Map.of(1L, NamespaceRole.MEMBER), Set.of())).isTrue(); + } + + @Test + void unrelatedNamespaceMemberCannotManageSuiteLabels() { + assertThat(checker.canManageSuiteLabel( + suite, label(LabelType.RECOMMENDED), "other", + Map.of(1L, NamespaceRole.MEMBER), Set.of())).isFalse(); + } + + @Test + void namespaceAdminCanUseRecommendedButNotPrivilegedLabel() { + assertThat(checker.canManageSuiteLabel( + suite, label(LabelType.RECOMMENDED), "admin", + Map.of(1L, NamespaceRole.ADMIN), Set.of())).isTrue(); + assertThat(checker.canManageSuiteLabel( + suite, label(LabelType.PRIVILEGED), "admin", + Map.of(1L, NamespaceRole.ADMIN), Set.of())).isFalse(); + } + + @Test + void superAdminCanUsePrivilegedLabel() { + assertThat(checker.canManageSuiteLabel( + suite, label(LabelType.PRIVILEGED), "platform-admin", + Map.of(), Set.of("SUPER_ADMIN"))).isTrue(); + } + + private LabelDefinition label(LabelType type) { + return new LabelDefinition("verified", type, true, 0, "creator"); + } +} diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/label/SkillSuiteLabelServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/label/SkillSuiteLabelServiceTest.java new file mode 100644 index 00000000..e3f4f35e --- /dev/null +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/label/SkillSuiteLabelServiceTest.java @@ -0,0 +1,144 @@ +package com.iflytek.skillhub.domain.label; + +import com.iflytek.skillhub.domain.namespace.NamespaceRole; +import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; +import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; +import com.iflytek.skillhub.domain.suite.SkillSuite; +import com.iflytek.skillhub.domain.suite.SkillSuiteRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class SkillSuiteLabelServiceTest { + + @Mock private SkillSuiteRepository suiteRepository; + @Mock private LabelDefinitionRepository labelDefinitionRepository; + @Mock private SkillSuiteLabelRepository suiteLabelRepository; + @Mock private LabelPermissionChecker labelPermissionChecker; + + private SkillSuiteLabelService service; + private SkillSuite suite; + private LabelDefinition label; + + @BeforeEach + void setUp() throws Exception { + service = new SkillSuiteLabelService( + suiteRepository, labelDefinitionRepository, suiteLabelRepository, + labelPermissionChecker, 2); + suite = new SkillSuite(1L, "starter", "Starter", "author"); + label = new LabelDefinition("automation", LabelType.RECOMMENDED, true, 0, "creator"); + setId(suite, 10L); + setId(label, 20L); + } + + @Test + void attachesRecommendedLabelDirectlyToSuite() { + stubSuiteAndLabel(); + when(labelPermissionChecker.canManageSuiteLabel( + suite, label, "author", roles(), Set.of())).thenReturn(true); + when(suiteLabelRepository.findBySuiteIdAndLabelId(10L, 20L)).thenReturn(Optional.empty()); + when(suiteLabelRepository.findBySuiteId(10L)).thenReturn(List.of()); + when(suiteLabelRepository.save(any())).thenAnswer(invocation -> invocation.getArgument(0)); + + SkillSuiteLabel attached = service.attachLabel( + 10L, "Automation", "author", roles(), Set.of()); + + assertThat(attached.getSuiteId()).isEqualTo(10L); + assertThat(attached.getLabelId()).isEqualTo(20L); + assertThat(attached.getCreatedBy()).isEqualTo("author"); + } + + @Test + void repeatedAttachIsIdempotentEvenAtTheLimit() { + SkillSuiteLabel existing = new SkillSuiteLabel(10L, 20L, "author"); + stubSuiteAndLabel(); + when(labelPermissionChecker.canManageSuiteLabel( + suite, label, "author", roles(), Set.of())).thenReturn(true); + when(suiteLabelRepository.findBySuiteIdAndLabelId(10L, 20L)) + .thenReturn(Optional.of(existing)); + + assertThat(service.attachLabel(10L, "automation", "author", roles(), Set.of())) + .isSameAs(existing); + + verify(suiteLabelRepository, never()).findBySuiteId(10L); + verify(suiteLabelRepository, never()).save(any()); + } + + @Test + void rejectsNewLabelAboveConfiguredLimit() { + stubSuiteAndLabel(); + when(labelPermissionChecker.canManageSuiteLabel( + suite, label, "author", roles(), Set.of())).thenReturn(true); + when(suiteLabelRepository.findBySuiteIdAndLabelId(10L, 20L)).thenReturn(Optional.empty()); + when(suiteLabelRepository.findBySuiteId(10L)).thenReturn(List.of( + new SkillSuiteLabel(10L, 21L, "author"), + new SkillSuiteLabel(10L, 22L, "author"))); + + assertThatThrownBy(() -> service.attachLabel( + 10L, "automation", "author", roles(), Set.of())) + .isInstanceOfSatisfying(DomainBadRequestException.class, + exception -> assertThat(exception.messageCode()) + .isEqualTo("label.suite.too_many")); + + verify(suiteLabelRepository, never()).save(any()); + } + + @Test + void permissionFailureDoesNotReadOrModifyAssociations() { + stubSuiteAndLabel(); + when(labelPermissionChecker.canManageSuiteLabel( + suite, label, "other", roles(), Set.of())).thenReturn(false); + + assertThatThrownBy(() -> service.attachLabel( + 10L, "automation", "other", roles(), Set.of())) + .isInstanceOf(DomainForbiddenException.class); + + verify(suiteLabelRepository, never()).findBySuiteIdAndLabelId(any(), any()); + verify(suiteLabelRepository, never()).save(any()); + } + + @Test + void detachesOnlyTheSuiteAssociation() { + SkillSuiteLabel existing = new SkillSuiteLabel(10L, 20L, "author"); + stubSuiteAndLabel(); + when(labelPermissionChecker.canManageSuiteLabel( + suite, label, "author", roles(), Set.of())).thenReturn(true); + when(suiteLabelRepository.findBySuiteIdAndLabelId(10L, 20L)) + .thenReturn(Optional.of(existing)); + + service.detachLabel(10L, "automation", "author", roles(), Set.of()); + + verify(suiteLabelRepository).delete(existing); + } + + private void stubSuiteAndLabel() { + when(suiteRepository.findById(10L)).thenReturn(Optional.of(suite)); + when(labelDefinitionRepository.findBySlugIgnoreCase("automation")) + .thenReturn(Optional.of(label)); + } + + private Map roles() { + return Map.of(1L, NamespaceRole.MEMBER); + } + + private void setId(Object target, Long id) throws Exception { + var field = target.getClass().getDeclaredField("id"); + field.setAccessible(true); + field.set(target, id); + } +} diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillPublishServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillPublishServiceTest.java index c0fda2b6..c3c5eacd 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillPublishServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillPublishServiceTest.java @@ -8,6 +8,7 @@ import com.iflytek.skillhub.domain.namespace.Namespace; import com.iflytek.skillhub.domain.namespace.NamespaceMember; import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; 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.review.ReviewTask; @@ -36,9 +37,11 @@ import org.mockito.junit.jupiter.MockitoExtension; import java.lang.reflect.Field; import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; import java.time.Clock; import java.time.Instant; import java.time.ZoneOffset; +import java.util.HexFormat; import java.util.List; import java.util.Map; import java.util.Optional; @@ -1707,9 +1710,235 @@ class SkillPublishServiceTest { verify(reviewTaskRepository, never()).save(any(ReviewTask.class)); } + @Test + void publishBundleMember_existingSkillAsNamespaceAdmin_isBoundAndNonDestructive() throws Exception { + String actorId = "namespace-admin"; + List entries = skillEntries("test-skill", "2.0.0"); + Namespace namespace = new Namespace("test-ns", "Test NS", "owner"); + setId(namespace, 1L); + Skill skill = new Skill(1L, "test-skill", "another-owner", SkillVisibility.PUBLIC); + setId(skill, 21L); + SkillMetadata metadata = new SkillMetadata("test-skill", "Test", "2.0.0", "Body", Map.of()); + + when(namespaceRepository.findBySlug("test-ns")).thenReturn(Optional.of(namespace)); + when(namespaceMemberRepository.findByNamespaceIdAndUserId(1L, actorId)) + .thenReturn(Optional.of(mock(NamespaceMember.class))); + when(skillPackageValidator.validate(entries)).thenReturn(ValidationResult.pass()); + when(skillMetadataParser.parse(anyString())).thenReturn(metadata); + when(prePublishValidator.validate(any())).thenReturn(ValidationResult.pass()); + when(skillRepository.findByNamespaceIdAndSlug(1L, "test-skill")).thenReturn(List.of(skill)); + when(skillVersionRepository.findBySkillIdAndVersion(21L, "2.0.0")).thenReturn(Optional.empty()); + when(skillVersionRepository.save(any(SkillVersion.class))).thenAnswer(invocation -> { + SkillVersion saved = invocation.getArgument(0); + if (saved.getId() == null) setId(saved, 31L); + return saved; + }); + when(skillRepository.save(skill)).thenReturn(skill); + + SkillPublishService.PublishResult result = service.publishBundleMemberFromEntries( + "test-ns", 21L, "test-skill", "2.0.0", entries, + entries.stream().collect(java.util.stream.Collectors.toMap( + PackageEntry::path, entry -> sha256(entry.content()))), actorId, + SkillVisibility.PUBLIC, Map.of(1L, NamespaceRole.ADMIN), Set.of(), true); + + assertEquals(21L, result.skillId()); + assertEquals(31L, result.version().getId()); + assertEquals(SkillVersionStatus.PENDING_REVIEW, result.version().getStatus()); + verify(reviewTaskRepository, never()).delete(any()); + ArgumentCaptor> savedFiles = ArgumentCaptor.forClass(List.class); + verify(skillFileRepository).saveAll(savedFiles.capture()); + assertTrue(savedFiles.getValue().stream().allMatch(file -> entries.stream() + .filter(entry -> entry.path().equals(file.getFilePath())) + .anyMatch(entry -> sha256(entry.content()).equals(file.getSha256())))); + } + + @Test + void publishBundleMember_rejectsStagedContentWhoseShaChangedBeforeStorageWrites() throws Exception { + String actorId = "namespace-admin"; + List entries = skillEntries("test-skill", "2.0.0"); + Namespace namespace = new Namespace("test-ns", "Test NS", "owner"); + setId(namespace, 1L); + Skill skill = new Skill(1L, "test-skill", "another-owner", SkillVisibility.PUBLIC); + setId(skill, 21L); + + when(namespaceRepository.findBySlug("test-ns")).thenReturn(Optional.of(namespace)); + when(namespaceMemberRepository.findByNamespaceIdAndUserId(1L, actorId)) + .thenReturn(Optional.of(mock(NamespaceMember.class))); + when(skillPackageValidator.validate(entries)).thenReturn(ValidationResult.pass()); + when(skillMetadataParser.parse(anyString())).thenReturn( + new SkillMetadata("test-skill", "Test", "2.0.0", "Body", Map.of())); + when(prePublishValidator.validate(any())).thenReturn(ValidationResult.pass()); + when(skillRepository.findByNamespaceIdAndSlug(1L, "test-skill")).thenReturn(List.of(skill)); + when(skillVersionRepository.findBySkillIdAndVersion(21L, "2.0.0")).thenReturn(Optional.empty()); + when(skillVersionRepository.save(any(SkillVersion.class))).thenAnswer(invocation -> { + SkillVersion saved = invocation.getArgument(0); + if (saved.getId() == null) setId(saved, 31L); + return saved; + }); + + Map wrongHashes = entryHashes(entries); + wrongHashes.put(entries.getLast().path(), "0".repeat(64)); + DomainBadRequestException exception = assertThrows(DomainBadRequestException.class, + () -> service.publishBundleMemberFromEntries( + "test-ns", 21L, "test-skill", "2.0.0", entries, wrongHashes, actorId, + SkillVisibility.PUBLIC, Map.of(1L, NamespaceRole.ADMIN), Set.of(), true)); + + assertEquals("error.suite.bundle.member.stateChanged", exception.messageCode()); + verify(skillFileRepository, never()).saveAll(anyList()); + verify(objectStorageService, never()).putObject(anyString(), any(), anyLong(), anyString()); + } + + @Test + void publishBundleMember_rejectsIncompleteStagedHashBindingBeforeVersionWrites() throws Exception { + String actorId = "namespace-admin"; + List entries = skillEntries("test-skill", "2.0.0"); + Namespace namespace = new Namespace("test-ns", "Test NS", "owner"); + setId(namespace, 1L); + + when(namespaceRepository.findBySlug("test-ns")).thenReturn(Optional.of(namespace)); + when(namespaceMemberRepository.findByNamespaceIdAndUserId(1L, actorId)) + .thenReturn(Optional.of(mock(NamespaceMember.class))); + when(skillPackageValidator.validate(entries)).thenReturn(ValidationResult.pass()); + when(skillMetadataParser.parse(anyString())).thenReturn( + new SkillMetadata("test-skill", "Test", "2.0.0", "Body", Map.of())); + + DomainBadRequestException exception = assertThrows(DomainBadRequestException.class, + () -> service.publishBundleMemberFromEntries( + "test-ns", 21L, "test-skill", "2.0.0", entries, + Map.of("SKILL.md", sha256(entries.getFirst().content())), actorId, + SkillVisibility.PUBLIC, Map.of(1L, NamespaceRole.ADMIN), Set.of(), true)); + + assertEquals("error.suite.bundle.member.stateChanged", exception.messageCode()); + verify(skillVersionRepository, never()).save(any()); + verify(objectStorageService, never()).putObject(anyString(), any(), anyLong(), anyString()); + } + + @Test + void publishBundleMember_removedNamespaceMemberUsesRetryableAuthorizationError() throws Exception { + String actorId = "removed-member"; + List entries = skillEntries("test-skill", "2.0.0"); + Namespace namespace = new Namespace("test-ns", "Test NS", "owner"); + setId(namespace, 1L); + + when(namespaceRepository.findBySlug("test-ns")).thenReturn(Optional.of(namespace)); + when(namespaceMemberRepository.findByNamespaceIdAndUserId(1L, actorId)) + .thenReturn(Optional.empty()); + + DomainBadRequestException exception = assertThrows(DomainBadRequestException.class, + () -> service.publishBundleMemberFromEntries( + "test-ns", 21L, "test-skill", "2.0.0", entries, entryHashes(entries), actorId, + SkillVisibility.PUBLIC, Map.of(), Set.of(), true)); + + assertEquals("error.skill.publish.publisher.notMember", exception.messageCode()); + verify(skillVersionRepository, never()).save(any()); + verify(objectStorageService, never()).putObject(anyString(), any(), anyLong(), anyString()); + } + + @Test + void publishBundleMember_pendingReviewAppeared_doesNotWithdrawOrWrite() throws Exception { + String actorId = "owner"; + List entries = skillEntries("test-skill", "2.0.0"); + Namespace namespace = new Namespace("test-ns", "Test NS", actorId); + setId(namespace, 1L); + Skill skill = new Skill(1L, "test-skill", actorId, SkillVisibility.PUBLIC); + setId(skill, 21L); + SkillVersion pending = new SkillVersion(21L, "1.5.0", actorId); + pending.setStatus(SkillVersionStatus.PENDING_REVIEW); + + when(namespaceRepository.findBySlug("test-ns")).thenReturn(Optional.of(namespace)); + when(namespaceMemberRepository.findByNamespaceIdAndUserId(1L, actorId)) + .thenReturn(Optional.of(mock(NamespaceMember.class))); + when(skillPackageValidator.validate(entries)).thenReturn(ValidationResult.pass()); + when(skillMetadataParser.parse(anyString())).thenReturn( + new SkillMetadata("test-skill", "Test", "2.0.0", "Body", Map.of())); + when(prePublishValidator.validate(any())).thenReturn(ValidationResult.pass()); + when(skillRepository.findByNamespaceIdAndSlug(1L, "test-skill")).thenReturn(List.of(skill)); + when(skillVersionRepository.findBySkillIdAndStatus(21L, SkillVersionStatus.PENDING_REVIEW)) + .thenReturn(List.of(pending)); + + DomainBadRequestException exception = assertThrows(DomainBadRequestException.class, + () -> service.publishBundleMemberFromEntries( + "test-ns", 21L, "test-skill", "2.0.0", entries, entryHashes(entries), actorId, + SkillVisibility.PUBLIC, Map.of(1L, NamespaceRole.MEMBER), Set.of(), true)); + + assertEquals("error.suite.bundle.member.stateChanged", exception.messageCode()); + assertEquals(SkillVersionStatus.PENDING_REVIEW, pending.getStatus()); + verify(reviewTaskRepository, never()).delete(any()); + verify(skillVersionRepository, never()).save(any()); + verify(objectStorageService, never()).putObject(anyString(), any(), anyLong(), anyString()); + } + + @Test + void publishBundleMember_targetVersionAppeared_doesNotReplaceArtifacts() throws Exception { + String actorId = "owner"; + List entries = skillEntries("test-skill", "2.0.0"); + Namespace namespace = new Namespace("test-ns", "Test NS", actorId); + setId(namespace, 1L); + Skill skill = new Skill(1L, "test-skill", actorId, SkillVisibility.PUBLIC); + setId(skill, 21L); + SkillVersion existing = new SkillVersion(21L, "2.0.0", actorId); + existing.setStatus(SkillVersionStatus.UPLOADED); + + when(namespaceRepository.findBySlug("test-ns")).thenReturn(Optional.of(namespace)); + when(namespaceMemberRepository.findByNamespaceIdAndUserId(1L, actorId)) + .thenReturn(Optional.of(mock(NamespaceMember.class))); + when(skillPackageValidator.validate(entries)).thenReturn(ValidationResult.pass()); + when(skillMetadataParser.parse(anyString())).thenReturn( + new SkillMetadata("test-skill", "Test", "2.0.0", "Body", Map.of())); + when(prePublishValidator.validate(any())).thenReturn(ValidationResult.pass()); + when(skillRepository.findByNamespaceIdAndSlug(1L, "test-skill")).thenReturn(List.of(skill)); + when(skillVersionRepository.findBySkillIdAndVersion(21L, "2.0.0")) + .thenReturn(Optional.of(existing)); + + DomainBadRequestException exception = assertThrows(DomainBadRequestException.class, + () -> service.publishBundleMemberFromEntries( + "test-ns", 21L, "test-skill", "2.0.0", entries, entryHashes(entries), actorId, + SkillVisibility.PUBLIC, Map.of(1L, NamespaceRole.MEMBER), Set.of(), true)); + + assertEquals("error.suite.bundle.member.stateChanged", exception.messageCode()); + verify(skillFileRepository, never()).deleteByVersionId(anyLong()); + verify(skillVersionRepository, never()).delete(any()); + verify(objectStorageService, never()).putObject(anyString(), any(), anyLong(), anyString()); + } + + @Test + void publishBundleMember_newSkillCoordinateAppeared_doesNotCreateDuplicateOwnerRecord() throws Exception { + String actorId = "publisher"; + List entries = skillEntries("test-skill", "1.0.0"); + Namespace namespace = new Namespace("test-ns", "Test NS", actorId); + setId(namespace, 1L); + Skill concurrent = new Skill(1L, "test-skill", "other-owner", SkillVisibility.PUBLIC); + setId(concurrent, 22L); + + when(namespaceRepository.findBySlug("test-ns")).thenReturn(Optional.of(namespace)); + when(namespaceMemberRepository.findByNamespaceIdAndUserId(1L, actorId)) + .thenReturn(Optional.of(mock(NamespaceMember.class))); + when(skillPackageValidator.validate(entries)).thenReturn(ValidationResult.pass()); + when(skillMetadataParser.parse(anyString())).thenReturn( + new SkillMetadata("test-skill", "Test", "1.0.0", "Body", Map.of())); + when(prePublishValidator.validate(any())).thenReturn(ValidationResult.pass()); + when(skillRepository.findByNamespaceIdAndSlug(1L, "test-skill")).thenReturn(List.of(concurrent)); + when(skillVersionRepository.findBySkillIdAndStatus(22L, SkillVersionStatus.PUBLISHED)) + .thenReturn(List.of()); + + DomainBadRequestException exception = assertThrows(DomainBadRequestException.class, + () -> service.publishBundleMemberFromEntries( + "test-ns", null, "test-skill", "1.0.0", entries, entryHashes(entries), actorId, + SkillVisibility.PUBLIC, Map.of(1L, NamespaceRole.MEMBER), Set.of(), true)); + + assertEquals("error.suite.bundle.member.stateChanged", exception.messageCode()); + verify(skillRepository, never()).save(any()); + verify(skillVersionRepository, never()).save(any()); + } + private record PublishFixture(List entries) { } + private Map entryHashes(List entries) { + return entries.stream().collect(java.util.stream.Collectors.toMap( + PackageEntry::path, entry -> sha256(entry.content()))); + } + private PublishFixture stubValidPublishInputs( String namespaceSlug, String publisherId, @@ -1757,6 +1986,14 @@ class SkillPublishServiceTest { return List.of(skillMd, readme); } + private String sha256(byte[] content) { + try { + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(content)); + } catch (Exception exception) { + throw new IllegalStateException(exception); + } + } + @Test void testPublishFromEntries_concurrentSkillInsertReturnsBusinessConflict() throws Exception { String namespaceSlug = "test-ns"; diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/validation/PackageEntryTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/validation/PackageEntryTest.java new file mode 100644 index 00000000..cc7b85d7 --- /dev/null +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/validation/PackageEntryTest.java @@ -0,0 +1,42 @@ +package com.iflytek.skillhub.domain.skill.validation; + +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayInputStream; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class PackageEntryTest { + + @Test + void streamingContentIsOpenedLazilyAndCanBeReopened() throws Exception { + byte[] bytes = "streamed content".getBytes(StandardCharsets.UTF_8); + AtomicInteger opens = new AtomicInteger(); + PackageEntry entry = PackageEntry.streaming( + "README.md", bytes.length, "text/markdown", () -> { + opens.incrementAndGet(); + return new ByteArrayInputStream(bytes); + }); + + assertThat(opens).hasValue(0); + assertThat(entry.content()).isEqualTo(bytes); + try (var input = entry.openStream()) { + assertThat(input.readAllBytes()).isEqualTo(bytes); + } + assertThat(opens).hasValue(2); + } + + @Test + void materializationRejectsContentWhoseSizeChanged() { + PackageEntry entry = PackageEntry.streaming( + "README.md", 3, "text/markdown", + () -> new ByteArrayInputStream(new byte[]{1, 2, 3, 4})); + + assertThatThrownBy(entry::content) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("size changed"); + } +} diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/suite/SkillSuiteDraftServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/suite/SkillSuiteDraftServiceTest.java index 104c26b5..f880f361 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/suite/SkillSuiteDraftServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/suite/SkillSuiteDraftServiceTest.java @@ -20,6 +20,7 @@ import java.util.Set; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -88,6 +89,41 @@ class SkillSuiteDraftServiceTest { verify(publicationValidator).validate(result.suite(), result.version()); } + @Test + void savesDraftWithIncompleteDisplayMetadataWithoutApplyingPublicationRules() { + Namespace namespace = new Namespace("team", "Team", "owner"); + setId(namespace, 1L); + when(namespaceRepository.findById(1L)).thenReturn(Optional.of(namespace)); + when(suiteRepository.findByNamespaceIdAndSlug(1L, "writers")).thenReturn(Optional.empty()); + when(suiteRepository.save(any())).thenAnswer(invocation -> { + SkillSuite saved = invocation.getArgument(0); + setId(saved, 10L); + return saved; + }); + when(versionRepository.save(any())).thenAnswer(invocation -> { + SkillSuiteVersion saved = invocation.getArgument(0); + setId(saved, 20L); + return saved; + }); + when(memberRepository.saveAll(any())).thenAnswer(invocation -> invocation.getArgument(0)); + + SkillSuiteMemberSelection member = new SkillSuiteMemberSelection( + 30L, 40L, "global", "writer", "1.0.0", "sha256:abc"); + SkillSuiteDraftService.CreatedDraft result = service.create( + new CreateSkillSuiteDraftCommand( + 1L, "writers", "Writers", null, null, "1.0.0", + SkillVisibility.PRIVATE, null, 40L, List.of(member)), + new SkillSuiteActionContext( + "author", Map.of(1L, NamespaceRole.MEMBER), Set.of(), + "request-1", "127.0.0.1", "test")); + + assertThat(result.version().getSummary()).isNull(); + assertThat(result.version().getOverview()).isNull(); + assertThat(result.version().getStatus()).isEqualTo(SkillSuiteVersionStatus.DRAFT); + verify(publicationValidator).validate(result.suite(), result.version()); + verify(publicationValidator, never()).validateForPublication(any(), any()); + } + @Test void updatesOnlyAnEditableDraftAndReplacesItsExactMemberSnapshots() { Namespace namespace = new Namespace("team", "Team", "owner"); diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/suite/SkillSuiteLifecycleServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/suite/SkillSuiteLifecycleServiceTest.java index 98f9a065..af5e6441 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/suite/SkillSuiteLifecycleServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/suite/SkillSuiteLifecycleServiceTest.java @@ -71,12 +71,28 @@ class SkillSuiteLifecycleServiceTest { assertThat(version.getStatus()).isEqualTo(SkillSuiteVersionStatus.PUBLISHED); assertThat(version.getPublishedAt()).isEqualTo(Instant.parse("2026-09-07T08:00:00Z")); assertThat(suite.getLatestVersionId()).isEqualTo(20L); - verify(publicationValidator).validate(suite, version); + verify(publicationValidator).validateForPublication(suite, version); verify(auditLogService).record( "author", "PUBLISH_SKILL_SUITE_VERSION", "SKILL_SUITE_VERSION", 20L, "request-1", "127.0.0.1", "test", null); } + @Test + void incompletePrivateDraftStaysDraftWhenPublicationMetadataIsRejected() { + stubLoaded(); + doThrow(new DomainBadRequestException("error.suite.overview.required")) + .when(publicationValidator).validateForPublication(suite, version); + + assertThatThrownBy(() -> service.confirmPrivatePublish(10L, 20L, context())) + .isInstanceOf(DomainBadRequestException.class) + .hasMessage("error.suite.overview.required"); + + assertThat(version.getStatus()).isEqualTo(SkillSuiteVersionStatus.DRAFT); + assertThat(suite.getLatestVersionId()).isNull(); + verify(versionRepository, never()).save(any()); + verify(suiteRepository, never()).save(any()); + } + @Test void publicDraftCreatesTypedReviewAndBecomesPending() { version.setVisibility(SkillVisibility.PUBLIC); @@ -90,7 +106,23 @@ class SkillSuiteLifecycleServiceTest { assertThat(task.getSubjectType()).isEqualTo(ReviewSubjectType.SUITE_VERSION); assertThat(task.getSubjectId()).isEqualTo(10L); assertThat(task.getSubjectVersionId()).isEqualTo(20L); - verify(publicationValidator).validate(suite, version); + verify(publicationValidator).validateForPublication(suite, version); + } + + @Test + void incompletePublicDraftStaysDraftAndCreatesNoReviewTask() { + version.setVisibility(SkillVisibility.PUBLIC); + stubLoaded(); + doThrow(new DomainBadRequestException("error.suite.summary.required")) + .when(publicationValidator).validateForPublication(suite, version); + + assertThatThrownBy(() -> service.submitForReview(10L, 20L, context())) + .isInstanceOf(DomainBadRequestException.class) + .hasMessage("error.suite.summary.required"); + + assertThat(version.getStatus()).isEqualTo(SkillSuiteVersionStatus.DRAFT); + verify(reviewTaskRepository, never()).save(any()); + verify(versionRepository, never()).save(any()); } @Test @@ -118,8 +150,8 @@ class SkillSuiteLifecycleServiceTest { ReviewTask task = pendingReview("author"); stubLoaded(); when(reviewTaskRepository.findById(30L)).thenReturn(Optional.of(task)); - doThrow(new DomainBadRequestException("error.suite.member.unavailable")) - .when(publicationValidator).validate(suite, version); + doThrow(new DomainBadRequestException("error.suite.overview.required")) + .when(publicationValidator).validateForPublication(suite, version); assertThatThrownBy(() -> service.approveReview(30L, "Looks good", adminContext())) .isInstanceOf(DomainBadRequestException.class); diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/suite/SkillSuitePublicationValidatorTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/suite/SkillSuitePublicationValidatorTest.java new file mode 100644 index 00000000..a4598bb4 --- /dev/null +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/suite/SkillSuitePublicationValidatorTest.java @@ -0,0 +1,76 @@ +package com.iflytek.skillhub.domain.suite; + +import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; +import com.iflytek.skillhub.domain.skill.SkillVisibility; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class SkillSuitePublicationValidatorTest { + + @Mock private SkillSuiteVersionMemberRepository memberRepository; + @Mock private SkillSuiteMemberStateResolver stateResolver; + + private SkillSuitePublicationValidator validator; + private SkillSuite suite; + private SkillSuiteVersion version; + + @BeforeEach + void setUp() { + validator = new SkillSuitePublicationValidator(memberRepository, stateResolver); + suite = new SkillSuite(1L, "research-suite", "Research Suite", "author"); + version = new SkillSuiteVersion( + 10L, "1.0.0", "Research Suite", "Useful summary", + SkillVisibility.PRIVATE, "author"); + version.setOverview("## Usage\n\nRun the entry skill first."); + } + + @Test + void rejectsBlankSummaryBeforePublicationMemberChecks() { + version.setSummary(" \n "); + + assertThatThrownBy(() -> validator.validateForPublication(suite, version)) + .isInstanceOfSatisfying(DomainBadRequestException.class, + exception -> assertThat(exception.messageCode()) + .isEqualTo("error.suite.summary.required")); + + verifyNoInteractions(memberRepository, stateResolver); + } + + @Test + void rejectsBlankOverviewBeforePublicationMemberChecks() { + version.setOverview("\t"); + + assertThatThrownBy(() -> validator.validateForPublication(suite, version)) + .isInstanceOfSatisfying(DomainBadRequestException.class, + exception -> assertThat(exception.messageCode()) + .isEqualTo("error.suite.overview.required")); + + verifyNoInteractions(memberRepository, stateResolver); + } + + @Test + void completeMetadataContinuesToExactMemberValidation() { + when(memberRepository.findBySuiteVersionIdOrderByPosition(version.getId())) + .thenReturn(List.of()); + + assertThatThrownBy(() -> validator.validateForPublication(suite, version)) + .isInstanceOfSatisfying(DomainBadRequestException.class, + exception -> assertThat(exception.messageCode()) + .isEqualTo("error.suite.members.empty")); + + verify(memberRepository).findBySuiteVersionIdOrderByPosition(version.getId()); + verifyNoInteractions(stateResolver); + } +} diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/suite/SkillSuiteQueryServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/suite/SkillSuiteQueryServiceTest.java index c539e92a..2e8fedfd 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/suite/SkillSuiteQueryServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/suite/SkillSuiteQueryServiceTest.java @@ -49,7 +49,7 @@ class SkillSuiteQueryServiceTest { } @Test - void anonymousUserReadsPublishedPublicSuiteWithOrderedAvailability() { + void anonymousUserReadsHistoricalPublishedSuiteWithoutDisplayMetadata() { Namespace namespace = new Namespace("global", "Global", "system"); SkillSuite suite = new SkillSuite(1L, "writers", "Writers", "author"); SkillSuiteVersion version = new SkillSuiteVersion(10L, "1.0.0", SkillVisibility.PUBLIC, "author"); @@ -80,6 +80,8 @@ class SkillSuiteQueryServiceTest { assertThat(result.available()).isTrue(); assertThat(result.version().getVersion()).isEqualTo("1.0.0"); + assertThat(result.version().getSummary()).isNull(); + assertThat(result.version().getOverview()).isNull(); assertThat(result.members()).singleElement().satisfies(item -> { assertThat(item.snapshot().getSkillVersionId()).isEqualTo(40L); assertThat(item.availability().available()).isTrue(); @@ -244,6 +246,29 @@ class SkillSuiteQueryServiceTest { .isInstanceOf(DomainForbiddenException.class); } + @Test + void versionHistoryIncludesReleaseNotesAndCreatorForVisibleVersions() { + Namespace namespace = new Namespace("global", "Global", "system"); + SkillSuite suite = new SkillSuite(1L, "workflow", "Workflow", "suite-author"); + SkillSuiteVersion version = new SkillSuiteVersion( + 10L, "1.2.0", SkillVisibility.PUBLIC, "version-author"); + setId(namespace, 1L); + setId(suite, 10L); + setId(version, 20L); + version.setChangelog("Upgrade the extraction member"); + version.setStatus(SkillSuiteVersionStatus.PUBLISHED); + + when(namespaceRepository.findBySlug("global")).thenReturn(Optional.of(namespace)); + when(suiteRepository.findByNamespaceIdAndSlug(1L, "workflow")).thenReturn(Optional.of(suite)); + when(versionRepository.findBySuiteId(10L)).thenReturn(List.of(version)); + + SkillSuiteQueryService.VersionSummary result = service.listVersions( + "global", "workflow", null, Map.of(), Set.of()).getFirst(); + + assertThat(result.changelog()).isEqualTo("Upgrade the extraction member"); + assertThat(result.createdBy()).isEqualTo("version-author"); + } + private void setId(Object target, Long id) { try { var field = target.getClass().getDeclaredField("id"); diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/suite/bundle/SkillSuiteBundleManifestParserTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/suite/bundle/SkillSuiteBundleManifestParserTest.java new file mode 100644 index 00000000..79b90664 --- /dev/null +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/suite/bundle/SkillSuiteBundleManifestParserTest.java @@ -0,0 +1,180 @@ +package com.iflytek.skillhub.domain.suite.bundle; + +import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; +import com.iflytek.skillhub.domain.skill.SkillVisibility; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class SkillSuiteBundleManifestParserTest { + + private final SkillSuiteBundleManifestParser parser = new SkillSuiteBundleManifestParser(); + + @Test + void parsesCreateManifestWithPackageAndExactReferenceMembers() { + SkillSuiteBundleManifest manifest = parser.parse(fixture("valid-create")); + + assertThat(manifest.apiVersion()).isEqualTo("skillhub.iflytek.com/v1alpha1"); + assertThat(manifest.kind()).isEqualTo("SkillSuiteBundle"); + assertThat(manifest.metadata().coordinate().canonical()).isEqualTo("@global/clinical-workflow"); + assertThat(manifest.spec().mode()).isEqualTo(SkillSuiteBundleMode.CREATE); + assertThat(manifest.spec().baseVersion()).isNull(); + assertThat(manifest.spec().entry().canonical()).isEqualTo("@global/intake"); + assertThat(manifest.spec().members()).hasSize(2); + + SkillSuiteBundleMember packageMember = manifest.spec().members().getFirst(); + assertThat(packageMember.coordinate().canonical()).isEqualTo("@global/intake"); + assertThat(packageMember.packageSource().path()).isEqualTo("skills/intake"); + assertThat(packageMember.packageSource().visibility()).isEqualTo(SkillVisibility.PUBLIC); + assertThat(packageMember.referenceSource()).isNull(); + + SkillSuiteBundleMember referenceMember = manifest.spec().members().get(1); + assertThat(referenceMember.coordinate().canonical()).isEqualTo("@global/shared-dictionary"); + assertThat(referenceMember.referenceSource().version()).isEqualTo("2.3.1"); + assertThat(referenceMember.packageSource()).isNull(); + } + + @Test + void parsesUpdateManifestAndKeepsExistingPackageVisibilityUnspecified() { + SkillSuiteBundleManifest manifest = parser.parse(fixture("valid-update")); + + assertThat(manifest.spec().mode()).isEqualTo(SkillSuiteBundleMode.UPDATE); + assertThat(manifest.spec().baseVersion()).isEqualTo("1.0.0"); + assertThat(manifest.spec().version()).isEqualTo("1.1.0"); + assertThat(manifest.spec().members().getFirst().packageSource().visibility()).isNull(); + assertThat(manifest.spec().members().get(1).referenceSource().version()).isEqualTo("3.0.0"); + } + + @Test + void updateMayInheritSummaryAndOverviewButCreateMayNotOmitThem() { + String update = validManifest() + .replace("mode: CREATE", "mode: UPDATE\n baseVersion: 0.9.0") + .replace(" summary: Valid summary\n", "") + .replace(" overview: Valid overview\n", ""); + + SkillSuiteBundleManifest manifest = parser.parse(update); + + assertThat(manifest.spec().summary()).isNull(); + assertThat(manifest.spec().overview()).isNull(); + assertInvalid(validManifest().replace(" overview: Valid overview\n", ""), + "spec.overview is required"); + } + + @Test + void rejectsMemberThatCarriesBothPackageAndReference() { + assertInvalid(fixture("invalid-both-sources"), "exactly one of package or reference"); + } + + @Test + void rejectsDangerousPackagePath() { + assertInvalid(fixture("invalid-dangerous-path"), "unsafe package path"); + } + + @Test + void rejectsDuplicateLogicalSkillCoordinates() { + assertInvalid(validManifest().replace( + " members:\n - skill: \"@global/member\"", + " members:\n - skill: \"@global/member\"\n reference:\n version: 1.0.0\n - skill: \"@global/member\""), + "duplicate member skill"); + } + + @Test + void rejectsNestedPackageDirectories() { + String yaml = validManifest().replace( + " path: members/member\n visibility: PUBLIC", + " path: members/member\n visibility: PUBLIC\n" + + " - skill: \"@global/child\"\n" + + " package:\n" + + " path: members/member/child\n" + + " visibility: PUBLIC"); + + assertInvalid(yaml, "must not overlap"); + } + + @Test + void rejectsEntryThatIsNotAMember() { + assertInvalid(validManifest().replace("entry: \"@global/member\"", "entry: \"@global/missing\""), + "entry skill must be a member"); + } + + @Test + void rejectsUpdateWithoutBaseVersionAndCreateWithBaseVersion() { + assertInvalid(validManifest().replace("mode: CREATE", "mode: UPDATE"), + "baseVersion is required for UPDATE"); + assertInvalid(validManifest().replace("mode: CREATE", "mode: CREATE\n baseVersion: 0.9.0"), + "baseVersion is not allowed for CREATE"); + } + + @Test + void rejectsVisibilityOnReferenceMember() { + assertInvalid(validManifest() + .replace("package:\n path: members/member\n visibility: PUBLIC", + "reference:\n version: latest\n visibility: PUBLIC"), + "unknown field"); + } + + @Test + void rejectsUnsupportedVisibility() { + assertInvalid(validManifest().replace("visibility: PUBLIC", "visibility: INTERNAL"), + "unsupported value"); + } + + @Test + void rejectsUnknownFieldsAndMalformedCoordinates() { + assertInvalid(validManifest().replace("kind: SkillSuiteBundle", "kind: SkillSuiteBundle\nunexpected: true"), + "unknown field"); + assertInvalid(validManifest().replace("@global/member", "global/member"), + "coordinate"); + } + + private void assertInvalid(String yaml, String detail) { + assertThatThrownBy(() -> parser.parse(yaml)) + .isInstanceOf(DomainBadRequestException.class) + .satisfies(exception -> { + DomainBadRequestException badRequest = (DomainBadRequestException) exception; + assertThat(badRequest.messageCode()).isEqualTo("error.suite.bundle.manifest.invalid"); + assertThat(badRequest.messageArgs()).anySatisfy(argument -> + assertThat(argument.toString().toLowerCase()).contains(detail.toLowerCase())); + }); + } + + private String fixture(String name) { + String resource = "/suite-bundle/" + name + "/SUITE.yaml"; + try (InputStream input = getClass().getResourceAsStream(resource)) { + if (input == null) { + throw new IllegalStateException("Missing fixture: " + resource); + } + return new String(input.readAllBytes(), StandardCharsets.UTF_8); + } catch (IOException exception) { + throw new IllegalStateException("Failed to read fixture: " + resource, exception); + } + } + + private String validManifest() { + return """ + apiVersion: skillhub.iflytek.com/v1alpha1 + kind: SkillSuiteBundle + metadata: + namespace: global + slug: valid-suite + spec: + mode: CREATE + version: 1.0.0 + displayName: Valid Suite + summary: Valid summary + overview: Valid overview + visibility: PUBLIC + entry: "@global/member" + members: + - skill: "@global/member" + package: + path: members/member + visibility: PUBLIC + """; + } +} diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/suite/bundle/SkillSuiteBundlePreviewSessionTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/suite/bundle/SkillSuiteBundlePreviewSessionTest.java new file mode 100644 index 00000000..760e2530 --- /dev/null +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/suite/bundle/SkillSuiteBundlePreviewSessionTest.java @@ -0,0 +1,55 @@ +package com.iflytek.skillhub.domain.suite.bundle; + +import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; +import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; +import org.junit.jupiter.api.Test; + +import java.time.Instant; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class SkillSuiteBundlePreviewSessionTest { + + private static final Instant NOW = Instant.parse("2026-09-11T08:00:00Z"); + + @Test + void onlyOwnerCanConfirmReadyUnexpiredPreviewWithExactWarningDigest() { + SkillSuiteBundlePreviewSession session = preview(NOW.plusSeconds(60)); + + assertThatCode(() -> session.requireConfirmableBy("actor", "warning-digest", NOW)) + .doesNotThrowAnyException(); + assertThatThrownBy(() -> session.requireConfirmableBy("other", "warning-digest", NOW)) + .isInstanceOf(DomainForbiddenException.class) + .extracting("messageCode") + .isEqualTo("error.suite.bundle.preview.ownerMismatch"); + assertThatThrownBy(() -> session.requireConfirmableBy("actor", "changed", NOW)) + .isInstanceOf(DomainBadRequestException.class) + .extracting("messageCode") + .isEqualTo("error.suite.bundle.preview.warningMismatch"); + } + + @Test + void expiredOrAlreadyConfirmedPreviewRequiresANewPreview() { + SkillSuiteBundlePreviewSession expired = preview(NOW); + assertThatThrownBy(() -> expired.requireConfirmableBy("actor", "warning-digest", NOW)) + .isInstanceOf(DomainBadRequestException.class) + .extracting("messageCode") + .isEqualTo("error.suite.bundle.preview.expired"); + + SkillSuiteBundlePreviewSession confirmed = preview(NOW.plusSeconds(60)); + confirmed.markConfirmed(NOW); + assertThatThrownBy(() -> confirmed.requireConfirmableBy("actor", "warning-digest", NOW)) + .isInstanceOf(DomainBadRequestException.class) + .extracting("messageCode") + .isEqualTo("error.suite.bundle.preview.expired"); + } + + private SkillSuiteBundlePreviewSession preview(Instant expiresAt) { + return new SkillSuiteBundlePreviewSession( + "token", "actor", SkillSuiteBundleMode.CREATE, 1L, "suite", null, null, + "1.0.0", "archive.zip", "a".repeat(64), Map.of("manifest", "value"), + Map.of("plan", "value"), "warning-digest", expiresAt, NOW.minusSeconds(60)); + } +} diff --git a/server/skillhub-domain/src/test/resources/suite-bundle/invalid-both-sources/SUITE.yaml b/server/skillhub-domain/src/test/resources/suite-bundle/invalid-both-sources/SUITE.yaml new file mode 100644 index 00000000..4e2e3ef7 --- /dev/null +++ b/server/skillhub-domain/src/test/resources/suite-bundle/invalid-both-sources/SUITE.yaml @@ -0,0 +1,20 @@ +apiVersion: skillhub.iflytek.com/v1alpha1 +kind: SkillSuiteBundle +metadata: + namespace: global + slug: invalid-suite +spec: + mode: CREATE + version: 1.0.0 + displayName: Invalid + summary: Invalid member source + overview: Invalid member source + visibility: PUBLIC + entry: "@global/member" + members: + - skill: "@global/member" + package: + path: members/member + visibility: PUBLIC + reference: + version: 1.0.0 diff --git a/server/skillhub-domain/src/test/resources/suite-bundle/invalid-dangerous-path/SUITE.yaml b/server/skillhub-domain/src/test/resources/suite-bundle/invalid-dangerous-path/SUITE.yaml new file mode 100644 index 00000000..c9cf0c4b --- /dev/null +++ b/server/skillhub-domain/src/test/resources/suite-bundle/invalid-dangerous-path/SUITE.yaml @@ -0,0 +1,18 @@ +apiVersion: skillhub.iflytek.com/v1alpha1 +kind: SkillSuiteBundle +metadata: + namespace: global + slug: invalid-suite +spec: + mode: CREATE + version: 1.0.0 + displayName: Invalid + summary: Invalid package path + overview: Invalid package path + visibility: PUBLIC + entry: "@global/member" + members: + - skill: "@global/member" + package: + path: ../member + visibility: PUBLIC diff --git a/server/skillhub-domain/src/test/resources/suite-bundle/valid-create/SUITE.yaml b/server/skillhub-domain/src/test/resources/suite-bundle/valid-create/SUITE.yaml new file mode 100644 index 00000000..73ff75fe --- /dev/null +++ b/server/skillhub-domain/src/test/resources/suite-bundle/valid-create/SUITE.yaml @@ -0,0 +1,25 @@ +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 + changelog: 首次发布 + entry: "@global/intake" + members: + - skill: "@global/intake" + package: + path: skills/intake + visibility: PUBLIC + - skill: "@global/shared-dictionary" + reference: + version: 2.3.1 diff --git a/server/skillhub-domain/src/test/resources/suite-bundle/valid-update/SUITE.yaml b/server/skillhub-domain/src/test/resources/suite-bundle/valid-update/SUITE.yaml new file mode 100644 index 00000000..14a831ce --- /dev/null +++ b/server/skillhub-domain/src/test/resources/suite-bundle/valid-update/SUITE.yaml @@ -0,0 +1,21 @@ +apiVersion: skillhub.iflytek.com/v1alpha1 +kind: SkillSuiteBundle +metadata: + namespace: global + slug: clinical-workflow +spec: + mode: UPDATE + baseVersion: 1.0.0 + version: 1.1.0 + displayName: 临床工作流 + summary: 更新后的临床工作流 + overview: 更新成员并重新固定公共引用。 + visibility: PUBLIC + entry: "@global/intake" + members: + - skill: "@global/intake" + package: + path: members/intake + - skill: "@public/shared-dictionary" + reference: + version: 3.0.0 diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/JpaSkillRepositoryAdapter.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/JpaSkillRepositoryAdapter.java index 6a59e305..5aab079f 100644 --- a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/JpaSkillRepositoryAdapter.java +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/JpaSkillRepositoryAdapter.java @@ -47,6 +47,11 @@ public class JpaSkillRepositoryAdapter implements SkillRepository { return delegate.findByNamespaceIdAndSlug(namespaceId, slug); } + @Override + public List findByNamespaceIdInAndSlugIn(List namespaceIds, List slugs) { + return delegate.findByNamespaceIdInAndSlugIn(namespaceIds, slugs); + } + @Override public Optional findByNamespaceIdAndSlugAndOwnerId(Long namespaceId, String slug, String ownerId) { return delegate.findByNamespaceIdAndSlugAndOwnerId(namespaceId, slug, ownerId); diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/NamespaceJpaRepository.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/NamespaceJpaRepository.java index e71835a7..ed9a6724 100644 --- a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/NamespaceJpaRepository.java +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/NamespaceJpaRepository.java @@ -18,6 +18,7 @@ import java.util.Optional; public interface NamespaceJpaRepository extends JpaRepository, NamespaceRepository { List findByIdIn(List ids); + List findBySlugIn(List slugs); Page findByIdIn(List ids, Pageable pageable); Optional findBySlug(String slug); Page findByStatus(NamespaceStatus status, Pageable pageable); diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillFileJpaRepository.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillFileJpaRepository.java index 21555436..422e51ab 100644 --- a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillFileJpaRepository.java +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillFileJpaRepository.java @@ -13,5 +13,6 @@ import java.util.List; @Repository public interface SkillFileJpaRepository extends JpaRepository, SkillFileRepository { List findByVersionId(Long versionId); + List findByVersionIdIn(List versionIds); void deleteByVersionId(Long versionId); } diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillJpaRepository.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillJpaRepository.java index 2821c3ff..c9e623e2 100644 --- a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillJpaRepository.java +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillJpaRepository.java @@ -22,6 +22,7 @@ import java.util.Optional; public interface SkillJpaRepository extends JpaRepository, SkillRepository { List findByIdIn(List ids); List findByNamespaceIdAndSlug(Long namespaceId, String slug); + List findByNamespaceIdInAndSlugIn(List namespaceIds, List slugs); Optional findByNamespaceIdAndSlugAndOwnerId(Long namespaceId, String slug, String ownerId); boolean existsByNamespaceId(Long namespaceId); diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillSuiteBundleExecutionOperationJpaRepository.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillSuiteBundleExecutionOperationJpaRepository.java new file mode 100644 index 00000000..0f70175d --- /dev/null +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillSuiteBundleExecutionOperationJpaRepository.java @@ -0,0 +1,21 @@ +package com.iflytek.skillhub.infra.jpa; + +import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleExecutionOperation; +import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleExecutionOperationRepository; +import jakarta.persistence.LockModeType; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +@Repository +public interface SkillSuiteBundleExecutionOperationJpaRepository + extends JpaRepository, + SkillSuiteBundleExecutionOperationRepository { + + @Override + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("SELECT operation FROM SkillSuiteBundleExecutionOperation operation WHERE operation.operationId = :id") + java.util.Optional findByIdForUpdate(@Param("id") String operationId); +} diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillSuiteBundleMemberResultJpaRepository.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillSuiteBundleMemberResultJpaRepository.java new file mode 100644 index 00000000..39e00c0f --- /dev/null +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillSuiteBundleMemberResultJpaRepository.java @@ -0,0 +1,33 @@ +package com.iflytek.skillhub.infra.jpa; + +import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleMemberResult; +import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleMemberResultRepository; +import jakarta.persistence.LockModeType; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface SkillSuiteBundleMemberResultJpaRepository + extends JpaRepository, SkillSuiteBundleMemberResultRepository { + + @Override + default List saveAll(List members) { + return saveAllAndFlush(members); + } + + @Override + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query(""" + SELECT member FROM SkillSuiteBundleMemberResult member + WHERE member.operationId = :operationId + ORDER BY member.position + """) + List findByOperationIdOrderByPositionForUpdate( + @Param("operationId") String operationId); + +} diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillSuiteBundlePreviewSessionJpaRepository.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillSuiteBundlePreviewSessionJpaRepository.java new file mode 100644 index 00000000..59dc1b9d --- /dev/null +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillSuiteBundlePreviewSessionJpaRepository.java @@ -0,0 +1,41 @@ +package com.iflytek.skillhub.infra.jpa; + +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 jakarta.persistence.LockModeType; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.time.Instant; +import java.util.Optional; + +@Repository +public interface SkillSuiteBundlePreviewSessionJpaRepository + extends JpaRepository, SkillSuiteBundlePreviewSessionRepository { + + @Override + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("SELECT preview FROM SkillSuiteBundlePreviewSession preview WHERE preview.token = :token") + Optional findByIdForUpdate(@Param("token") String token); + + @Modifying + @Query(""" + UPDATE SkillSuiteBundlePreviewSession preview + SET preview.status = com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundlePreviewStatus.EXPIRED + WHERE preview.status = :ready + AND preview.expiresAt < :threshold + """) + int expireReadyBefore( + @Param("threshold") Instant threshold, + @Param("ready") SkillSuiteBundlePreviewStatus ready); + + @Override + default int expireReadyBefore(Instant threshold) { + return expireReadyBefore(threshold, SkillSuiteBundlePreviewStatus.PREVIEW_READY); + } +} diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillSuiteLabelJpaRepository.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillSuiteLabelJpaRepository.java new file mode 100644 index 00000000..ec730287 --- /dev/null +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillSuiteLabelJpaRepository.java @@ -0,0 +1,18 @@ +package com.iflytek.skillhub.infra.jpa; + +import com.iflytek.skillhub.domain.label.SkillSuiteLabel; +import com.iflytek.skillhub.domain.label.SkillSuiteLabelRepository; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +@Repository +public interface SkillSuiteLabelJpaRepository + extends JpaRepository, SkillSuiteLabelRepository { + List findBySuiteId(Long suiteId); + List findBySuiteIdIn(List suiteIds); + List findByLabelId(Long labelId); + Optional findBySuiteIdAndLabelId(Long suiteId, Long labelId); +} diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillVersionJpaRepository.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillVersionJpaRepository.java index 481d6127..e6bab549 100644 --- a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillVersionJpaRepository.java +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillVersionJpaRepository.java @@ -34,6 +34,7 @@ public interface SkillVersionJpaRepository extends JpaRepository findBySkillId(Long skillId); List findBySkillIdIn(List skillIds); List findBySkillIdInAndStatusOrderByCreatedAtDesc(List skillIds, SkillVersionStatus status); + List findBySkillIdInAndVersionIn(List skillIds, List versions); Optional findBySkillIdAndVersion(Long skillId, String version); @Override diff --git a/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/ResourceDiscoveryQueryService.java b/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/ResourceDiscoveryQueryService.java index be9ab9ad..3a78fe66 100644 --- a/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/ResourceDiscoveryQueryService.java +++ b/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/ResourceDiscoveryQueryService.java @@ -16,7 +16,8 @@ public interface ResourceDiscoveryQueryService { String sort, int page, int size, - Set memberNamespaceIds + Set memberNamespaceIds, + List labelSlugs ) {} record ResourceHit( diff --git a/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/postgres/PostgresResourceDiscoveryQueryService.java b/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/postgres/PostgresResourceDiscoveryQueryService.java index 141e0b6e..84da751b 100644 --- a/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/postgres/PostgresResourceDiscoveryQueryService.java +++ b/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/postgres/PostgresResourceDiscoveryQueryService.java @@ -105,6 +105,15 @@ public class PostgresResourceDiscoveryQueryService implements ResourceDiscoveryQ private static final String FILTERS = """ WHERE (:resourceType = '' OR resource_type = :resourceType) AND (:namespace = '' OR namespace_slug = :namespace) + AND (:labelFilter = FALSE OR ( + resource_type = 'SUITE' + AND resource_id IN ( + SELECT suite_label.suite_id + FROM skill_suite_label suite_label + JOIN label_definition label ON label.id = suite_label.label_id + WHERE LOWER(label.slug) IN (:labelSlugs) + ) + )) AND (:query = '' OR LOWER(slug) LIKE :queryPattern OR LOWER(display_name) LIKE :queryPattern @@ -162,10 +171,13 @@ public class PostgresResourceDiscoveryQueryService implements ResourceDiscoveryQ boolean relevance ) { Set memberNamespaceIds = input.memberNamespaceIds(); + List labelSlugs = input.labelSlugs() == null ? List.of() : input.labelSlugs(); query.setParameter("query", keyword) .setParameter("queryPattern", "%" + keyword + "%") .setParameter("namespace", namespace) .setParameter("resourceType", resourceType) + .setParameter("labelFilter", !labelSlugs.isEmpty()) + .setParameter("labelSlugs", labelSlugs.isEmpty() ? List.of("__none__") : labelSlugs) .setParameter("memberNamespaceIds", memberNamespaceIds == null || memberNamespaceIds.isEmpty() ? Set.of(-1L) diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 25080732..a724997e 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -596,6 +596,27 @@ export const labelApi = { }) }, + async listSuiteLabels(namespace: string, slug: string): Promise { + const cleanNamespace = normalizeNamespaceSlug(namespace) + return fetchJson(`${WEB_API_PREFIX}/suites/${cleanNamespace}/${encodeURIComponent(slug)}/labels`) + }, + + async attachSuiteLabel(namespace: string, slug: string, labelSlug: string): Promise { + const cleanNamespace = normalizeNamespaceSlug(namespace) + return fetchJson(`${WEB_API_PREFIX}/suites/${cleanNamespace}/${encodeURIComponent(slug)}/labels/${encodeURIComponent(labelSlug)}`, { + method: 'PUT', + headers: await ensureCsrfHeaders(), + }) + }, + + async detachSuiteLabel(namespace: string, slug: string, labelSlug: string): Promise { + const cleanNamespace = normalizeNamespaceSlug(namespace) + await fetchJson(`${WEB_API_PREFIX}/suites/${cleanNamespace}/${encodeURIComponent(slug)}/labels/${encodeURIComponent(labelSlug)}`, { + method: 'DELETE', + headers: await ensureCsrfHeaders(), + }) + }, + async listAdminDefinitions(): Promise { return fetchJson('/api/v1/admin/labels') }, @@ -967,8 +988,9 @@ export const reviewApi = { return fetchJson(`${WEB_API_PREFIX}/reviews/${id}`) }, - async listMyProgress(params: { status?: string; q?: string; page?: number; size?: number }) { + async listMyProgress(params: { subjectType?: string; status?: string; q?: string; page?: number; size?: number }) { const searchParams = new URLSearchParams() + if (params.subjectType) searchParams.set('subjectType', params.subjectType) if (params.status) searchParams.set('status', params.status) if (params.q) searchParams.set('q', params.q) searchParams.set('page', String(params.page ?? 0)) diff --git a/web/src/api/generated/schema.d.ts b/web/src/api/generated/schema.d.ts index ba5ece0d..a57d765d 100644 --- a/web/src/api/generated/schema.d.ts +++ b/web/src/api/generated/schema.d.ts @@ -38,6 +38,42 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/suites/{namespace}/{slug}/labels/{labelSlug}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** Attach an existing Registry label to a Suite */ + put: operations["attachSkillSuiteLabel"]; + post?: never; + /** Detach a Registry label from a Suite */ + delete: operations["detachSkillSuiteLabel"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/web/suites/{namespace}/{slug}/labels/{labelSlug}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** Attach an existing Registry label to a Suite */ + put: operations["attachSkillSuiteLabel_1"]; + post?: never; + /** Detach a Registry label from a Suite */ + delete: operations["detachSkillSuiteLabel_1"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/skills/{skillId}/subscription": { parameters: { query?: never; @@ -928,6 +964,142 @@ export interface paths { patch?: never; trace?: never; }; + "/api/web/suite-bundles/previews/{previewToken}/confirm": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Confirm one exact Suite Bundle preview */ + post: operations["confirmSkillSuiteBundle"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/suite-bundles/previews/{previewToken}/confirm": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Confirm one exact Suite Bundle preview */ + post: operations["confirmSkillSuiteBundle_1"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/web/suite-bundles/preview": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Validate and preview one Suite Bundle archive */ + post: operations["previewSkillSuiteBundle"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/suite-bundles/preview": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Validate and preview one Suite Bundle archive */ + post: operations["previewSkillSuiteBundle_1"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/web/suite-bundles/operations/{operationId}/retry": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Retry one blocked Suite Bundle operation */ + post: operations["retrySkillSuiteBundleOperation"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/suite-bundles/operations/{operationId}/retry": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Retry one blocked Suite Bundle operation */ + post: operations["retrySkillSuiteBundleOperation_1"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/suite-bundles/operations/{operationId}/cancel": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Cancel one active Suite Bundle operation */ + post: operations["cancelSkillSuiteBundleOperation"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/web/suite-bundles/operations/{operationId}/cancel": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Cancel one active Suite Bundle operation */ + post: operations["cancelSkillSuiteBundleOperation_1"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/skills/{namespace}/{slug}/versions/{version}/withdraw-review": { parameters: { query?: never; @@ -2434,6 +2606,40 @@ export interface paths { patch?: never; trace?: never; }; + "/api/web/suites/{namespace}/{slug}/labels": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List direct labels on one visible Suite */ + get: operations["listSkillSuiteLabels"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/suites/{namespace}/{slug}/labels": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List direct labels on one visible Suite */ + get: operations["listSkillSuiteLabels_1"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/suites/{namespace}/{slug}": { parameters: { query?: never; @@ -2502,6 +2708,108 @@ export interface paths { patch?: never; trace?: never; }; + "/api/web/suite-bundles/operations/{operationId}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get one authorized Suite Bundle operation */ + get: operations["getSkillSuiteBundleOperation"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/suite-bundles/operations/{operationId}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get one authorized Suite Bundle operation */ + get: operations["getSkillSuiteBundleOperation_1"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/suite-bundles/operations/mine": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List current and completed Bundle operations started by the current user */ + get: operations["listMySkillSuiteBundleOperations"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/web/suite-bundles/operations/mine": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List current and completed Bundle operations started by the current user */ + get: operations["listMySkillSuiteBundleOperations_1"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/web/suite-bundles/operations/active": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List active Bundle operations started by the current user */ + get: operations["listActiveSkillSuiteBundleOperations"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/suite-bundles/operations/active": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List active Bundle operations started by the current user */ + get: operations["listActiveSkillSuiteBundleOperations_1"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/web/skills": { parameters: { query?: never; @@ -2870,6 +3178,38 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/skills/{namespace}/{slug}/suite-memberships": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["listSuiteMemberships"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/web/skills/{namespace}/{slug}/suite-memberships": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["listSuiteMemberships_1"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/web/skills/{namespace}/{slug}/resolve": { parameters: { query?: never; @@ -3480,6 +3820,40 @@ export interface paths { patch?: never; trace?: never; }; + "/api/web/me/suites/workspace": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List owner workbench with merged Suite creation progress */ + get: operations["listMySkillSuiteWorkspace"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/me/suites/workspace": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List owner workbench with merged Suite creation progress */ + get: operations["listMySkillSuiteWorkspace_1"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/web/me/suites": { parameters: { query?: never; @@ -4488,6 +4862,15 @@ export interface components { displayName?: string; summary?: string; overview?: string; + changelog?: string; + createdBy?: string; + createdByName?: string; + /** Format: date-time */ + createdAt?: string; + /** Format: date-time */ + publishedAt?: string; + /** Format: date-time */ + yankedAt?: string; version?: string; status?: string; /** @enum {string} */ @@ -4498,6 +4881,20 @@ export interface components { available?: boolean; members?: components["schemas"]["SkillSuiteMemberResponse"][]; }; + ApiResponseSkillLabelDto: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["SkillLabelDto"]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; + SkillLabelDto: { + slug?: string; + type?: string; + displayName?: string; + }; ApiResponseVoid: { /** Format: int32 */ code?: number; @@ -4562,20 +4959,6 @@ export interface components { /** Format: date-time */ createdAt?: string; }; - ApiResponseSkillLabelDto: { - /** Format: int32 */ - code?: number; - msg?: string; - data?: components["schemas"]["SkillLabelDto"]; - /** Format: date-time */ - timestamp?: string; - requestId?: string; - }; - SkillLabelDto: { - slug?: string; - type?: string; - displayName?: string; - }; ApiResponseMapStringInteger: { /** Format: int32 */ code?: number; @@ -4810,6 +5193,90 @@ export interface components { SkillSuiteReviewRequest: { comment?: string; }; + SkillSuiteBundleConfirmRequest: { + warningDigest: string; + }; + ApiResponseSkillSuiteBundleOperationResponse: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["SkillSuiteBundleOperationResponse"]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; + SkillSuiteBundleOperationResponse: { + operationId?: string; + status?: string; + replayed?: boolean; + }; + ApiResponseSkillSuiteBundlePreviewResponse: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["SkillSuiteBundlePreviewResponse"]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; + PreviewMember: { + coordinate?: string; + /** @enum {string} */ + sourceType?: "PACKAGE" | "REFERENCE"; + packagePath?: string; + /** @enum {string} */ + relationship?: "ADDED" | "UPDATED" | "UNCHANGED" | "REMOVED"; + /** @enum {string} */ + publishAction?: "CREATE_SKILL" | "CREATE_VERSION" | "REUSE_VERSION" | "REFERENCE_VERSION" | "NONE"; + /** Format: int64 */ + skillId?: number; + /** Format: int64 */ + skillVersionId?: number; + /** @enum {string} */ + finalVisibility?: "PUBLIC" | "NAMESPACE_ONLY" | "PRIVATE"; + resolvedVersion?: string; + fingerprint?: string; + errors?: string[]; + warnings?: string[]; + }; + RemovedMember: { + coordinate?: string; + /** Format: int64 */ + skillId?: number; + /** Format: int64 */ + skillVersionId?: number; + version?: string; + entry?: boolean; + }; + SkillSuiteBundlePreviewResponse: { + previewToken?: string; + /** Format: date-time */ + expiresAt?: string; + confirmable?: boolean; + target?: components["schemas"]["Target"]; + members?: components["schemas"]["PreviewMember"][]; + removedMembers?: components["schemas"]["RemovedMember"][]; + errors?: string[]; + warnings?: string[]; + warningDigest?: string; + }; + Target: { + /** @enum {string} */ + mode?: "CREATE" | "UPDATE"; + coordinate?: string; + /** Format: int64 */ + namespaceId?: number; + /** Format: int64 */ + suiteId?: number; + /** Format: int64 */ + baseSuiteVersionId?: number; + targetVersion?: string; + displayName?: string; + summary?: string; + overview?: string; + /** @enum {string} */ + visibility?: "PUBLIC" | "NAMESPACE_ONLY" | "PRIVATE"; + }; ApiResponseSkillLifecycleMutationResponse: { /** Format: int32 */ code?: number; @@ -5374,6 +5841,9 @@ export interface components { status?: string; /** @enum {string} */ visibility?: "PUBLIC" | "NAMESPACE_ONLY" | "PRIVATE"; + changelog?: string; + createdBy?: string; + createdByName?: string; /** Format: date-time */ publishedAt?: string; /** Format: date-time */ @@ -5381,6 +5851,15 @@ export interface components { /** Format: date-time */ createdAt?: string; }; + ApiResponseListSkillLabelDto: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["SkillLabelDto"][]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; ApiResponseListSkillSuiteMemberCandidateResponse: { /** Format: int32 */ code?: number; @@ -5403,6 +5882,121 @@ export interface components { visibility?: "PUBLIC" | "NAMESPACE_ONLY" | "PRIVATE"; recommended?: boolean; }; + ApiResponseSkillSuiteBundleOperationDetailResponse: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["SkillSuiteBundleOperationDetailResponse"]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; + OperationMember: { + /** Format: int32 */ + position?: number; + redacted?: boolean; + coordinate?: string; + /** @enum {string} */ + sourceType?: "PACKAGE" | "REFERENCE"; + packagePath?: string; + /** @enum {string} */ + relationship?: "ADDED" | "UPDATED" | "UNCHANGED" | "REMOVED"; + /** @enum {string} */ + publishAction?: "CREATE_SKILL" | "CREATE_VERSION" | "REUSE_VERSION" | "REFERENCE_VERSION" | "NONE"; + /** @enum {string} */ + status?: "PLANNED" | "RUNNING" | "WAITING_FOR_MEMBER" | "COMPLETED" | "BLOCKED_RETRYABLE" | "REPREVIEW_REQUIRED" | "CANCELLED"; + /** @enum {string} */ + visibility?: "PUBLIC" | "NAMESPACE_ONLY" | "PRIVATE"; + version?: string; + /** Format: int64 */ + skillId?: number; + /** Format: int64 */ + skillVersionId?: number; + errors?: string[]; + warnings?: string[]; + }; + SkillSuiteBundleOperationDetailResponse: { + operationId?: string; + /** @enum {string} */ + status?: "RUNNING" | "WAITING_FOR_MEMBERS" | "BLOCKED_RETRYABLE" | "REPREVIEW_REQUIRED" | "SUITE_DRAFT_CREATED" | "CANCELLED"; + /** @enum {string} */ + mode?: "CREATE" | "UPDATE"; + targetCoordinate?: string; + /** Format: int64 */ + targetNamespaceId?: number; + /** Format: int64 */ + targetSuiteId?: number; + targetVersion?: string; + baseVersion?: string; + failureCode?: string; + /** Format: int64 */ + resultSuiteId?: number; + /** Format: int64 */ + resultSuiteVersionId?: number; + /** Format: date-time */ + createdAt?: string; + /** Format: date-time */ + updatedAt?: string; + /** Format: date-time */ + completedAt?: string; + members?: components["schemas"]["OperationMember"][]; + }; + ApiResponseSkillSuiteBundleOperationPageResponse: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["SkillSuiteBundleOperationPageResponse"]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; + SkillSuiteBundleOperationPageResponse: { + items?: components["schemas"]["SkillSuiteBundleOperationSummaryResponse"][]; + /** Format: int64 */ + total?: number; + /** Format: int32 */ + page?: number; + /** Format: int32 */ + size?: number; + hasChangingOperations?: boolean; + }; + SkillSuiteBundleOperationSummaryResponse: { + operationId?: string; + /** @enum {string} */ + mode?: "CREATE" | "UPDATE"; + targetCoordinate?: string; + targetVersion?: string; + /** @enum {string} */ + status?: "RUNNING" | "WAITING_FOR_MEMBERS" | "BLOCKED_RETRYABLE" | "REPREVIEW_REQUIRED" | "SUITE_DRAFT_CREATED" | "CANCELLED"; + failureCode?: string; + baseVersion?: string; + /** Format: int32 */ + totalMembers?: number; + /** Format: int32 */ + completedMembers?: number; + /** Format: int32 */ + waitingMembers?: number; + /** Format: date-time */ + updatedAt?: string; + }; + ApiResponsePageResponseSkillSuiteBundleOperationSummaryResponse: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["PageResponseSkillSuiteBundleOperationSummaryResponse"]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; + PageResponseSkillSuiteBundleOperationSummaryResponse: { + items?: components["schemas"]["SkillSuiteBundleOperationSummaryResponse"][]; + /** Format: int64 */ + total?: number; + /** Format: int32 */ + page?: number; + /** Format: int32 */ + size?: number; + }; ApiResponseSearchResponse: { /** Format: int32 */ code?: number; @@ -5658,6 +6252,50 @@ export interface components { timestamp?: string; requestId?: string; }; + ApiResponsePageResponseSkillSuiteReferenceResponse: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["PageResponseSkillSuiteReferenceResponse"]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; + PageResponseSkillSuiteReferenceResponse: { + items?: components["schemas"]["SkillSuiteReferenceResponse"][]; + /** Format: int64 */ + total?: number; + /** Format: int32 */ + page?: number; + /** Format: int32 */ + size?: number; + }; + SkillSuiteReferenceResponse: { + /** Format: int64 */ + suiteId?: number; + namespace?: string; + slug?: string; + displayName?: string; + version?: string; + /** Format: int32 */ + memberCount?: number; + currentSkillEntry?: boolean; + visibleSiblingMembers?: components["schemas"]["SkillSuiteSiblingMemberResponse"][]; + /** Format: int32 */ + restrictedMemberCount?: number; + /** Format: int32 */ + omittedVisibleMemberCount?: number; + }; + SkillSuiteSiblingMemberResponse: { + /** Format: int64 */ + skillId?: number; + namespace?: string; + slug?: string; + displayName?: string; + version?: string; + entry?: boolean; + available?: boolean; + }; ApiResponseResolveVersionResponse: { /** Format: int32 */ code?: number; @@ -5679,15 +6317,6 @@ export interface components { matched?: boolean; downloadUrl?: string; }; - ApiResponseListSkillLabelDto: { - /** Format: int32 */ - code?: number; - msg?: string; - data?: components["schemas"]["SkillLabelDto"][]; - /** Format: date-time */ - timestamp?: string; - requestId?: string; - }; ApiResponseSkillDetailResponse: { /** Format: int32 */ code?: number; @@ -5729,16 +6358,7 @@ export interface components { ownerPreviewReviewComment?: string; resolutionMode?: string; entryForSuites?: components["schemas"]["SkillSuiteReferenceResponse"][]; - }; - SkillSuiteReferenceResponse: { - /** Format: int64 */ - suiteId?: number; - namespace?: string; - slug?: string; - displayName?: string; - version?: string; - /** Format: int32 */ - memberCount?: number; + memberOfSuites?: components["schemas"]["PageResponseSkillSuiteReferenceResponse"]; }; ApiResponseReviewSkillDetailResponse: { /** Format: int32 */ @@ -5869,6 +6489,7 @@ export interface components { available?: boolean; /** Format: date-time */ updatedAt?: string; + labels?: components["schemas"]["SkillLabelDto"][]; }; ApiResponsePageResponsePromotionResponseDto: { /** Format: int32 */ @@ -5993,6 +6614,43 @@ export interface components { /** Format: int32 */ size?: number; }; + ApiResponseMySkillSuiteWorkspaceResponse: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["MySkillSuiteWorkspaceResponse"]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; + Item: { + /** Format: int64 */ + suiteId?: number; + namespace?: string; + slug?: string; + displayName?: string; + summary?: string; + version?: string; + suiteVersion?: string; + state?: string; + /** Format: date-time */ + updatedAt?: string; + operationId?: string; + operationStatus?: string; + failureCode?: string; + }; + MySkillSuiteWorkspaceResponse: { + items?: components["schemas"]["Item"][]; + /** Format: int64 */ + total?: number; + /** Format: int32 */ + page?: number; + /** Format: int32 */ + size?: number; + /** Format: int64 */ + attentionCount?: number; + hasChangingOperations?: boolean; + }; ApiResponsePageResponseMySkillSuiteSummaryResponse: { /** Format: int32 */ code?: number; @@ -6762,6 +7420,102 @@ export interface operations { }; }; }; + attachSkillSuiteLabel: { + parameters: { + query?: never; + header?: never; + path: { + namespace: string; + slug: string; + labelSlug: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseSkillLabelDto"]; + }; + }; + }; + }; + detachSkillSuiteLabel: { + parameters: { + query?: never; + header?: never; + path: { + namespace: string; + slug: string; + labelSlug: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseMessageResponse"]; + }; + }; + }; + }; + attachSkillSuiteLabel_1: { + parameters: { + query?: never; + header?: never; + path: { + namespace: string; + slug: string; + labelSlug: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseSkillLabelDto"]; + }; + }; + }; + }; + detachSkillSuiteLabel_1: { + parameters: { + query?: never; + header?: never; + path: { + namespace: string; + slug: string; + labelSlug: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseMessageResponse"]; + }; + }; + }; + }; checkSubscribed: { parameters: { query?: never; @@ -8631,6 +9385,204 @@ export interface operations { }; }; }; + confirmSkillSuiteBundle: { + parameters: { + query?: never; + header: { + "Idempotency-Key": string; + }; + path: { + previewToken: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["SkillSuiteBundleConfirmRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseSkillSuiteBundleOperationResponse"]; + }; + }; + }; + }; + confirmSkillSuiteBundle_1: { + parameters: { + query?: never; + header: { + "Idempotency-Key": string; + }; + path: { + previewToken: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["SkillSuiteBundleConfirmRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseSkillSuiteBundleOperationResponse"]; + }; + }; + }; + }; + previewSkillSuiteBundle: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "multipart/form-data": { + /** Format: binary */ + file: string; + }; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseSkillSuiteBundlePreviewResponse"]; + }; + }; + }; + }; + previewSkillSuiteBundle_1: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "multipart/form-data": { + /** Format: binary */ + file: string; + }; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseSkillSuiteBundlePreviewResponse"]; + }; + }; + }; + }; + retrySkillSuiteBundleOperation: { + parameters: { + query?: never; + header?: never; + path: { + operationId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseSkillSuiteBundleOperationResponse"]; + }; + }; + }; + }; + retrySkillSuiteBundleOperation_1: { + parameters: { + query?: never; + header?: never; + path: { + operationId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseSkillSuiteBundleOperationResponse"]; + }; + }; + }; + }; + cancelSkillSuiteBundleOperation: { + parameters: { + query?: never; + header?: never; + path: { + operationId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseSkillSuiteBundleOperationResponse"]; + }; + }; + }; + }; + cancelSkillSuiteBundleOperation_1: { + parameters: { + query?: never; + header?: never; + path: { + operationId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseSkillSuiteBundleOperationResponse"]; + }; + }; + }; + }; withdrawReview: { parameters: { query?: never; @@ -11292,6 +12244,52 @@ export interface operations { }; }; }; + listSkillSuiteLabels: { + parameters: { + query?: never; + header?: never; + path: { + namespace: string; + slug: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseListSkillLabelDto"]; + }; + }; + }; + }; + listSkillSuiteLabels_1: { + parameters: { + query?: never; + header?: never; + path: { + namespace: string; + slug: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseListSkillLabelDto"]; + }; + }; + }; + }; getSkillSuite: { parameters: { query?: { @@ -11392,6 +12390,142 @@ export interface operations { }; }; }; + getSkillSuiteBundleOperation: { + parameters: { + query?: never; + header?: never; + path: { + operationId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseSkillSuiteBundleOperationDetailResponse"]; + }; + }; + }; + }; + getSkillSuiteBundleOperation_1: { + parameters: { + query?: never; + header?: never; + path: { + operationId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseSkillSuiteBundleOperationDetailResponse"]; + }; + }; + }; + }; + listMySkillSuiteBundleOperations: { + parameters: { + query?: { + page?: number; + size?: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseSkillSuiteBundleOperationPageResponse"]; + }; + }; + }; + }; + listMySkillSuiteBundleOperations_1: { + parameters: { + query?: { + page?: number; + size?: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseSkillSuiteBundleOperationPageResponse"]; + }; + }; + }; + }; + listActiveSkillSuiteBundleOperations: { + parameters: { + query?: { + page?: number; + size?: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponsePageResponseSkillSuiteBundleOperationSummaryResponse"]; + }; + }; + }; + }; + listActiveSkillSuiteBundleOperations_1: { + parameters: { + query?: { + page?: number; + size?: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponsePageResponseSkillSuiteBundleOperationSummaryResponse"]; + }; + }; + }; + }; search: { parameters: { query?: { @@ -12013,6 +13147,58 @@ export interface operations { }; }; }; + listSuiteMemberships: { + parameters: { + query?: { + page?: number; + size?: number; + }; + header?: never; + path: { + namespace: string; + slug: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponsePageResponseSkillSuiteReferenceResponse"]; + }; + }; + }; + }; + listSuiteMemberships_1: { + parameters: { + query?: { + page?: number; + size?: number; + }; + header?: never; + path: { + namespace: string; + slug: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponsePageResponseSkillSuiteReferenceResponse"]; + }; + }; + }; + }; resolveVersion: { parameters: { query?: { @@ -12620,6 +13806,7 @@ export interface operations { listMyProgress: { parameters: { query?: { + subjectType?: string; status?: string; q?: string; page?: number; @@ -12645,6 +13832,7 @@ export interface operations { listMyProgress_1: { parameters: { query?: { + subjectType?: string; status?: string; q?: string; page?: number; @@ -12676,6 +13864,7 @@ export interface operations { sort?: string; page?: number; size?: number; + label?: string[]; }; header?: never; path?: never; @@ -12703,6 +13892,7 @@ export interface operations { sort?: string; page?: number; size?: number; + label?: string[]; }; header?: never; path?: never; @@ -12949,6 +14139,56 @@ export interface operations { }; }; }; + listMySkillSuiteWorkspace: { + parameters: { + query?: { + q?: string; + state?: string; + page?: number; + size?: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseMySkillSuiteWorkspaceResponse"]; + }; + }; + }; + }; + listMySkillSuiteWorkspace_1: { + parameters: { + query?: { + q?: string; + state?: string; + page?: number; + size?: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseMySkillSuiteWorkspaceResponse"]; + }; + }; + }; + }; listMySkillSuites: { parameters: { query?: { diff --git a/web/src/api/types.ts b/web/src/api/types.ts index fa63b0b8..bbc37549 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -275,41 +275,46 @@ export interface ComplianceSnapshot { digest?: string } -export interface SkillSuiteReference { - suiteId: number - namespace: string - slug: string - displayName: string - version: string - memberCount: number +type GeneratedSkillSuiteSiblingMember = components['schemas']['SkillSuiteSiblingMemberResponse'] +export type SkillSuiteSiblingMember = Required + +type GeneratedSkillSuiteReference = components['schemas']['SkillSuiteReferenceResponse'] +export type SkillSuiteReference = Omit< + RequiredGenerated< + GeneratedSkillSuiteReference, + 'currentSkillEntry' | 'visibleSiblingMembers' | 'restrictedMemberCount' | 'omittedVisibleMemberCount' + >, + 'visibleSiblingMembers' +> & { + visibleSiblingMembers?: SkillSuiteSiblingMember[] } -export interface SkillDetail { - id: number - slug: string - displayName: string - ownerId?: string - ownerDisplayName?: string - summary?: string - visibility: string - status: string - downloadCount: number - starCount: number - ratingAvg?: number - ratingCount: number - hidden: boolean - namespace: string +type GeneratedSkillDetail = components['schemas']['SkillDetailResponse'] +export type SkillDetail = Omit< + RequiredGenerated< + GeneratedSkillDetail, + | 'ownerId' + | 'ownerDisplayName' + | 'summary' + | 'subscriptionCount' + | 'ratingAvg' + | 'labels' + | 'headlineVersion' + | 'publishedVersion' + | 'ownerPreviewVersion' + | 'ownerPreviewReviewComment' + | 'resolutionMode' + | 'entryForSuites' + | 'memberOfSuites' + >, + 'labels' | 'headlineVersion' | 'publishedVersion' | 'ownerPreviewVersion' | 'entryForSuites' | 'memberOfSuites' +> & { labels?: LabelItem[] - canManageLifecycle: boolean - canSubmitPromotion: boolean - canInteract: boolean - canReport: boolean headlineVersion?: SkillLifecycleVersion publishedVersion?: SkillLifecycleVersion ownerPreviewVersion?: SkillLifecycleVersion - ownerPreviewReviewComment?: string - resolutionMode?: string entryForSuites?: SkillSuiteReference[] + memberOfSuites?: PagedResponse } export interface SubmitPromotionRequest { @@ -423,7 +428,7 @@ type RequiredGenerated = Required> & Pick type GeneratedResourceSummary = components['schemas']['ResourceSummaryResponse'] -export type ResourceSummary = Omit, 'resourceType'> & { +export type ResourceSummary = Omit, 'resourceType'> & { resourceType: ResourceType } @@ -431,6 +436,7 @@ export interface ResourceSearchParams { q?: string namespace?: string resourceType?: ResourceType + labels?: string[] sort?: string page?: number size?: number @@ -443,12 +449,21 @@ export type SkillSuiteMember = RequiredGenerated< > type GeneratedSuite = components['schemas']['SkillSuiteResponse'] -export type SkillSuite = Omit, 'members'> & { +export type SkillSuite = Omit< + RequiredGenerated< + GeneratedSuite, + 'summary' | 'overview' | 'changelog' | 'createdByName' | 'publishedAt' | 'yankedAt' + >, + 'members' +> & { members: SkillSuiteMember[] } type GeneratedSuiteVersion = components['schemas']['SkillSuiteVersionSummaryResponse'] -export type SkillSuiteVersion = RequiredGenerated +export type SkillSuiteVersion = RequiredGenerated< + GeneratedSuiteVersion, + 'changelog' | 'createdByName' | 'publishedAt' | 'yankedAt' +> export type SkillSuiteMemberCandidate = RequiredGenerated< components['schemas']['SkillSuiteMemberCandidateResponse'] @@ -462,6 +477,29 @@ export type MySkillSuiteSummary = RequiredGenerated< 'summary' > +export type MySkillSuiteWorkspaceItem = RequiredGenerated< + components['schemas']['Item'], + 'suiteId' | 'summary' | 'suiteVersion' | 'operationId' | 'operationStatus' | 'failureCode' +> +export type MySkillSuiteWorkspace = Omit< + RequiredGenerated, 'items' +> & { items: MySkillSuiteWorkspaceItem[] } + +export type SkillSuiteBundlePreview = components['schemas']['SkillSuiteBundlePreviewResponse'] +export type SkillSuiteBundlePreviewMember = components['schemas']['PreviewMember'] +export type SkillSuiteBundleRemovedMember = components['schemas']['RemovedMember'] +export type SkillSuiteBundleOperation = components['schemas']['SkillSuiteBundleOperationDetailResponse'] +export type SkillSuiteBundleOperationResult = components['schemas']['SkillSuiteBundleOperationResponse'] +export type SkillSuiteBundleOperationSummary = RequiredGenerated< + components['schemas']['SkillSuiteBundleOperationSummaryResponse'], + 'failureCode' | 'baseVersion' +> +type GeneratedSkillSuiteBundleOperationPage = components['schemas']['SkillSuiteBundleOperationPageResponse'] +export type SkillSuiteBundleOperationPage = Omit< + RequiredGenerated, + 'items' +> & { items: SkillSuiteBundleOperationSummary[] } + // Publish export interface PublishResult { skillId: number @@ -500,11 +538,12 @@ export interface ReviewTask { subjectSlug?: string | null } -export interface ReviewProgress { +export interface ReviewProgress extends Pick { latestReviewTaskId: number - skillId: number + skillId?: number namespace: string - skillSlug: string + skillSlug?: string skillVersion: string latestStatus: 'PENDING' | 'APPROVED' | 'REJECTED' latestReviewComment?: string diff --git a/web/src/app/router.tsx b/web/src/app/router.tsx index 5e01de1b..65ee019e 100644 --- a/web/src/app/router.tsx +++ b/web/src/app/router.tsx @@ -121,6 +121,16 @@ const MySuitesPage = createLazyRouteComponent( 'MySuitesPage', dashboardRouteOptions, ) +const SuitePublishingTaskPage = createLazyRouteComponent( + () => import('@/pages/dashboard/suite-publishing-task'), + 'SuitePublishingTaskPage', + dashboardRouteOptions, +) +const SuiteManagementPage = createLazyRouteComponent( + () => import('@/pages/dashboard/suite-management'), + 'SuiteManagementPage', + dashboardRouteOptions, +) const MyNamespacesPage = createLazyRouteComponent( () => import('@/pages/dashboard/my-namespaces'), 'MyNamespacesPage', @@ -308,8 +318,9 @@ const namespaceRoute = createRoute({ const skillDetailRoute = createRoute({ getParentRoute: () => rootRoute, path: '/space/$namespace/$slug', - validateSearch: (search: Record): { returnTo?: string } => ({ + validateSearch: (search: Record): { returnTo?: string; version?: string } => ({ returnTo: typeof search.returnTo === 'string' && search.returnTo.startsWith('/') ? search.returnTo : undefined, + version: typeof search.version === 'string' && search.version ? search.version : undefined, }), component: SkillDetailPage, }) @@ -377,9 +388,46 @@ const dashboardSuitesRoute = createRoute({ getParentRoute: () => rootRoute, path: 'dashboard/suites', beforeLoad: requireAuth, + validateSearch: (search: Record): { tab?: 'suites' | 'publishing' } => ({ + tab: search.tab === 'publishing' ? 'publishing' : undefined, + }), component: MySuitesPage, }) +const dashboardSuiteManagementRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'dashboard/suites/$namespace/$slug', + beforeLoad: requireAuth, + validateSearch: (search: Record): { + version?: string + tab?: 'members' | 'versions' | 'publishing' + } => ({ + version: typeof search.version === 'string' && search.version ? search.version : undefined, + tab: search.tab === 'members' || search.tab === 'versions' || search.tab === 'publishing' + ? search.tab + : undefined, + }), + component: SuiteManagementPage, +}) + +const dashboardSuitePublishingTaskRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'dashboard/suites/publishing/$operationId', + beforeLoad: requireAuth, + validateSearch: (search: Record): { + suiteNamespace?: string + suiteSlug?: string + suiteVersion?: string + } => ({ + suiteNamespace: typeof search.suiteNamespace === 'string' && search.suiteNamespace + ? search.suiteNamespace + : undefined, + suiteSlug: typeof search.suiteSlug === 'string' && search.suiteSlug ? search.suiteSlug : undefined, + suiteVersion: typeof search.suiteVersion === 'string' && search.suiteVersion ? search.suiteVersion : undefined, + }), + component: SuitePublishingTaskPage, +}) + const dashboardSuiteEditRoute = createRoute({ getParentRoute: () => rootRoute, path: 'dashboard/suites/$namespace/$slug/edit', @@ -446,12 +494,16 @@ const dashboardReviewProgressRoute = createRoute({ beforeLoad: requireAuth, validateSearch: (search: Record): { status?: 'PENDING' | 'APPROVED' | 'REJECTED' + type?: 'SKILL_VERSION' | 'SUITE_VERSION' q?: string page?: number } => ({ status: search.status === 'PENDING' || search.status === 'APPROVED' || search.status === 'REJECTED' ? search.status : undefined, + type: search.type === 'SKILL_VERSION' || search.type === 'SUITE_VERSION' + ? search.type + : undefined, q: typeof search.q === 'string' && search.q.trim() ? search.q.trim() : undefined, page: typeof search.page === 'number' && search.page > 0 ? search.page : undefined, }), @@ -611,6 +663,8 @@ const routeTree = rootRoute.addChildren([ dashboardSkillsRoute, dashboardPublishRoute, dashboardSuitesRoute, + dashboardSuiteManagementRoute, + dashboardSuitePublishingTaskRoute, dashboardSuiteCreateRoute, dashboardSuiteEditRoute, dashboardSuiteVersionCreateRoute, diff --git a/web/src/features/publish/folder-zip.ts b/web/src/features/publish/folder-zip.ts index f10bcfa6..5cffe555 100644 --- a/web/src/features/publish/folder-zip.ts +++ b/web/src/features/publish/folder-zip.ts @@ -138,16 +138,18 @@ export function createZipBlob(entries: ZipEntry[]): Blob { ev.setUint32(16, offset, true) // central dir offset ev.setUint16(20, 0, true) // comment length - // Concatenate into a single buffer so the Blob part is a Uint8Array. + // Keep the ZIP segmented. Concatenating here would allocate another buffer as large as + // the complete archive, doubling peak JavaScript heap usage for large folder uploads. const parts = [...localParts, ...centralParts, eocd] - const total = parts.reduce((n, p) => n + p.length, 0) - const out = new Uint8Array(total) - let pos = 0 - for (const part of parts) { - out.set(part, pos) - pos += part.length - } - return new Blob([out], { type: 'application/zip' }) + const blobParts = parts.map((part) => { + if (part.buffer instanceof ArrayBuffer + && part.byteOffset === 0 + && part.byteLength === part.buffer.byteLength) { + return part.buffer + } + return part.slice().buffer as ArrayBuffer + }) + return new Blob(blobParts, { type: 'application/zip' }) } // --- Folder -> File ------------------------------------------------------------ diff --git a/web/src/features/publish/upload-zone.test.ts b/web/src/features/publish/upload-zone.test.ts index 467eecbe..0db5d6a9 100644 --- a/web/src/features/publish/upload-zone.test.ts +++ b/web/src/features/publish/upload-zone.test.ts @@ -1,6 +1,19 @@ -import { describe, expect, it } from 'vitest' +/** @vitest-environment jsdom */ + +import { cleanup, render, screen, waitFor } from '@testing-library/react' +import { createElement } from 'react' +import { afterEach, describe, expect, it, vi } from 'vitest' import * as mod from './upload-zone' +vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key }) })) +vi.mock('react-dropzone', () => ({ + useDropzone: () => ({ + getRootProps: () => ({}), + getInputProps: () => ({}), + isDragActive: false, + }), +})) + /** * upload-zone.tsx exports the UploadZone component. It is a stateless * dropzone wrapper with no exported constants, validation logic, or @@ -10,8 +23,33 @@ import * as mod from './upload-zone' * the module shape changes. */ describe('upload-zone module exports', () => { + afterEach(() => cleanup()) + it('exports the UploadZone component', () => { expect(mod.UploadZone).toBeDefined() expect(typeof mod.UploadZone).toBe('function') }) + + it('hides directory selection when the browser does not expose a directory picker', () => { + render(createElement(mod.UploadZone, { onFileSelect: vi.fn(), onFolderSelect: vi.fn() })) + expect(screen.queryByRole('button', { name: 'upload.folderHint' })).toBeNull() + }) + + it('shows directory selection only when the browser supports it', async () => { + const previous = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'webkitdirectory') + Object.defineProperty(HTMLInputElement.prototype, 'webkitdirectory', { + configurable: true, + writable: true, + value: false, + }) + try { + render(createElement(mod.UploadZone, { onFileSelect: vi.fn(), onFolderSelect: vi.fn() })) + await waitFor(() => expect( + screen.getByRole('button', { name: 'upload.folderHint' }) + ).not.toBeNull()) + } finally { + if (previous) Object.defineProperty(HTMLInputElement.prototype, 'webkitdirectory', previous) + else delete (HTMLInputElement.prototype as { webkitdirectory?: boolean }).webkitdirectory + } + }) }) diff --git a/web/src/features/publish/upload-zone.tsx b/web/src/features/publish/upload-zone.tsx index e5ab4c72..187ac2dc 100644 --- a/web/src/features/publish/upload-zone.tsx +++ b/web/src/features/publish/upload-zone.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef, type ChangeEvent } from 'react' +import { useCallback, useEffect, useRef, useState, type ChangeEvent } from 'react' import { useTranslation } from 'react-i18next' import { useDropzone } from 'react-dropzone' import { cn } from '@/shared/lib/utils' @@ -10,6 +10,10 @@ interface UploadZoneProps { disabled?: boolean } +export function supportsDirectorySelection(input: HTMLInputElement | null): boolean { + return Boolean(input && ('webkitdirectory' in input || 'directory' in input)) +} + /** * Provides the publish page dropzone for uploading one zip package at a time. * The component is intentionally stateless so packaging validation can remain in @@ -18,15 +22,18 @@ interface UploadZoneProps { export function UploadZone({ onFileSelect, onFolderSelect, disabled }: UploadZoneProps) { const { t } = useTranslation() const folderInputRef = useRef(null) + const [folderSelectionSupported, setFolderSelectionSupported] = useState(false) // `webkitdirectory` / `directory` are not in React's input attribute types; // set them imperatively so the folder picker works without an untyped cast. useEffect(() => { const el = folderInputRef.current - if (el) { + const supported = supportsDirectorySelection(el) + if (el && supported) { el.setAttribute('webkitdirectory', '') el.setAttribute('directory', '') } + setFolderSelectionSupported(supported) }, []) const onDrop = useCallback( @@ -106,14 +113,16 @@ export function UploadZone({ onFileSelect, onFolderSelect, disabled }: UploadZon onChange={handleFolderChange} disabled={disabled} /> - + {folderSelectionSupported ? ( + + ) : null} )} diff --git a/web/src/features/review/use-my-review-progress.ts b/web/src/features/review/use-my-review-progress.ts index 6a7bdfc0..4ade23c5 100644 --- a/web/src/features/review/use-my-review-progress.ts +++ b/web/src/features/review/use-my-review-progress.ts @@ -2,6 +2,7 @@ import { useQuery } from '@tanstack/react-query' import { reviewApi } from '@/api/client' interface MyReviewProgressParams { + subjectType?: string status?: string q?: string page: number diff --git a/web/src/features/search/search-bar.test.tsx b/web/src/features/search/search-bar.test.tsx new file mode 100644 index 00000000..b47de7d8 --- /dev/null +++ b/web/src/features/search/search-bar.test.tsx @@ -0,0 +1,25 @@ +/** @vitest-environment jsdom */ +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { SearchBar } from './search-bar' + +vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key }) })) + +describe('SearchBar explicit submission', () => { + afterEach(cleanup) + + it('does not search when typing or clearing, but submits on confirmation', () => { + const onSearch = vi.fn() + render() + const input = screen.getByRole('textbox') + fireEvent.change(input, { target: { value: 'agent' } }) + expect(onSearch).not.toHaveBeenCalled() + fireEvent.click(screen.getByRole('button', { name: 'searchBar.button' })) + expect(onSearch).toHaveBeenLastCalledWith('agent') + onSearch.mockClear() + fireEvent.click(screen.getByRole('button', { name: 'searchBar.clear' })) + expect(onSearch).not.toHaveBeenCalled() + fireEvent.submit(input.closest('form')!) + expect(onSearch).toHaveBeenCalledWith('') + }) +}) diff --git a/web/src/features/search/search-bar.tsx b/web/src/features/search/search-bar.tsx index 10dc950c..4d615627 100644 --- a/web/src/features/search/search-bar.tsx +++ b/web/src/features/search/search-bar.tsx @@ -48,7 +48,6 @@ export function SearchBar({ defaultValue = '', value, placeholder, isSearching = const handleClear = () => { handleChange('') - onSearch?.('') } return ( diff --git a/web/src/features/skill/skill-label-panel.test.ts b/web/src/features/skill/skill-label-panel.test.ts index 6f7f3bf4..1a55c07b 100644 --- a/web/src/features/skill/skill-label-panel.test.ts +++ b/web/src/features/skill/skill-label-panel.test.ts @@ -14,5 +14,6 @@ describe('skill-label-panel module exports', () => { it('exports the SkillLabelPanel component', () => { expect(mod.SkillLabelPanel).toBeDefined() expect(typeof mod.SkillLabelPanel).toBe('function') + expect(typeof mod.SuiteLabelPanel).toBe('function') }) }) diff --git a/web/src/features/skill/skill-label-panel.tsx b/web/src/features/skill/skill-label-panel.tsx index e29299ad..b9e47d35 100644 --- a/web/src/features/skill/skill-label-panel.tsx +++ b/web/src/features/skill/skill-label-panel.tsx @@ -8,8 +8,11 @@ import { cn } from '@/shared/lib/utils' import { useAdminLabelDefinitions, useAttachSkillLabel, + useAttachSuiteLabel, useDetachSkillLabel, + useDetachSuiteLabel, useSkillLabels, + useSuiteLabels, useVisibleLabels, } from '@/shared/hooks/use-label-queries' @@ -19,6 +22,21 @@ type SkillLabelPanelProps = { initialLabels: LabelItem[] canManage: boolean isSuperAdmin: boolean + compact?: boolean +} + +type MutationCallbacks = { + onSuccess: () => void + onError: (error: unknown) => void +} + +type ResourceLabelPanelProps = SkillLabelPanelProps & { + currentLabels?: LabelItem[] + attachLabel: (labelSlug: string, callbacks: MutationCallbacks) => void + detachLabel: (labelSlug: string, callbacks: MutationCallbacks) => void + attachPending: boolean + detachPending: boolean + translationPrefix: 'skillDetail' | 'suite' } function canManageLabelType(type: string, isSuperAdmin: boolean) { @@ -60,20 +78,30 @@ function sortByPresentation(left: { displayName: string; slug: string; sortOrder || left.slug.localeCompare(right.slug, undefined, { sensitivity: 'base' }) } -export function SkillLabelPanel({ namespace, slug, initialLabels, canManage, isSuperAdmin }: SkillLabelPanelProps) { +function ResourceLabelPanel({ + initialLabels, + canManage, + isSuperAdmin, + currentLabels: queriedLabels, + attachLabel, + detachLabel, + attachPending, + detachPending, + translationPrefix, + compact = false, +}: ResourceLabelPanelProps) { const { t, i18n } = useTranslation() const locale = i18n.resolvedLanguage || i18n.language || 'en' - const { data: skillLabels } = useSkillLabels(namespace, slug, canManage) const { data: visibleLabels, isLoading: visibleLabelsLoading } = useVisibleLabels(canManage && !isSuperAdmin) const { data: adminDefinitions, isLoading: adminDefinitionsLoading } = useAdminLabelDefinitions(canManage && isSuperAdmin) - const attachMutation = useAttachSkillLabel() - const detachMutation = useDetachSkillLabel() if (!canManage) { return null } - const currentLabels = (skillLabels ?? initialLabels).slice().sort(sortByPresentation) + const currentLabels = (queriedLabels ?? initialLabels) + .slice() + .sort(sortByPresentation) const currentLabelSlugs = new Set(currentLabels.map((label) => label.slug)) const candidateLabels = isSuperAdmin ? (adminDefinitions ?? []).map((definition) => toCandidateLabel(definition, locale)) @@ -83,56 +111,50 @@ export function SkillLabelPanel({ namespace, slug, initialLabels, canManage, isS .filter((label) => canManageLabelType(label.type, isSuperAdmin)) .sort(sortByPresentation) const isCatalogLoading = isSuperAdmin ? adminDefinitionsLoading : visibleLabelsLoading - const isMutating = attachMutation.isPending || detachMutation.isPending + const isMutating = attachPending || detachPending const handleAttach = (labelSlug: string) => { - attachMutation.mutate( - { namespace, slug, labelSlug }, - { - onSuccess: () => { - toast.success(t('skillDetail.labelAttachSuccessTitle'), t('skillDetail.labelAttachSuccessDescription')) - }, - onError: (error) => { - toast.error( - t('skillDetail.labelAttachErrorTitle'), - error instanceof Error ? error.message : t('skillDetail.labelActionFallbackError'), - ) - }, + attachLabel(labelSlug, { + onSuccess: () => { + toast.success(t(`${translationPrefix}.labelAttachSuccessTitle`), t(`${translationPrefix}.labelAttachSuccessDescription`)) }, - ) + onError: (error) => { + toast.error( + t(`${translationPrefix}.labelAttachErrorTitle`), + error instanceof Error ? error.message : t(`${translationPrefix}.labelActionFallbackError`), + ) + }, + }) } const handleDetach = (label: LabelItem) => { - detachMutation.mutate( - { namespace, slug, labelSlug: label.slug }, - { - onSuccess: () => { - toast.success(t('skillDetail.labelDetachSuccessTitle'), t('skillDetail.labelDetachSuccessDescription')) - }, - onError: (error) => { - toast.error( - t('skillDetail.labelDetachErrorTitle'), - error instanceof Error ? error.message : t('skillDetail.labelActionFallbackError'), - ) - }, + detachLabel(label.slug, { + onSuccess: () => { + toast.success(t(`${translationPrefix}.labelDetachSuccessTitle`), t(`${translationPrefix}.labelDetachSuccessDescription`)) }, - ) + onError: (error) => { + toast.error( + t(`${translationPrefix}.labelDetachErrorTitle`), + error instanceof Error ? error.message : t(`${translationPrefix}.labelActionFallbackError`), + ) + }, + }) } return ( - +

- {t('skillDetail.labelsSectionTitle')} + {t(`${translationPrefix}.labelsSectionTitle`)}
-

- {isSuperAdmin ? t('skillDetail.labelsSectionDescriptionSuperAdmin') : t('skillDetail.labelsSectionDescription')} +

+ {isSuperAdmin ? t(`${translationPrefix}.labelsSectionDescriptionSuperAdmin`) : t(`${translationPrefix}.labelsSectionDescription`)}

-
-
{t('skillDetail.currentLabelsTitle')}
+
+
{t(`${translationPrefix}.currentLabelsTitle`)}
{currentLabels.length > 0 ? (
{currentLabels.map((label) => { @@ -162,27 +184,27 @@ export function SkillLabelPanel({ namespace, slug, initialLabels, canManage, isS onClick={() => handleDetach(label)} disabled={isMutating} > - {detachMutation.isPending ? t('skillDetail.processing') : t('skillDetail.removeLabel')} + {detachPending ? t(`${translationPrefix}.processing`) : t(`${translationPrefix}.removeLabel`)} ) : ( - {t('skillDetail.labelRestrictedHint')} + {t(`${translationPrefix}.labelRestrictedHint`)} )}
) })}
) : ( -
- {t('skillDetail.noLabelsAssigned')} +
+ {t(`${translationPrefix}.noLabelsAssigned`)}
)}
-
-
{t('skillDetail.availableLabelsTitle')}
+
+
{t(`${translationPrefix}.availableLabelsTitle`)}
{isCatalogLoading ? ( -
- {t('skillDetail.loadingAvailableLabels')} +
+ {t(`${translationPrefix}.loadingAvailableLabels`)}
) : availableLabels.length > 0 ? (
@@ -195,16 +217,58 @@ export function SkillLabelPanel({ namespace, slug, initialLabels, canManage, isS onClick={() => handleAttach(label.slug)} disabled={isMutating} > - {attachMutation.isPending ? t('skillDetail.processing') : t('skillDetail.addLabel', { label: label.displayName })} + {attachPending ? t(`${translationPrefix}.processing`) : t(`${translationPrefix}.addLabel`, { label: label.displayName })} ))}
) : ( -
- {t('skillDetail.noAvailableLabels')} +
+ {t(`${translationPrefix}.noAvailableLabels`)}
)}
) } + +export function SkillLabelPanel(props: SkillLabelPanelProps) { + const { data: labels } = useSkillLabels(props.namespace, props.slug, props.canManage) + const attachMutation = useAttachSkillLabel() + const detachMutation = useDetachSkillLabel() + return ( + attachMutation.mutate( + { namespace: props.namespace, slug: props.slug, labelSlug }, callbacks, + )} + detachLabel={(labelSlug, callbacks) => detachMutation.mutate( + { namespace: props.namespace, slug: props.slug, labelSlug }, callbacks, + )} + attachPending={attachMutation.isPending} + detachPending={detachMutation.isPending} + translationPrefix="skillDetail" + /> + ) +} + +export function SuiteLabelPanel(props: SkillLabelPanelProps) { + const { data: labels } = useSuiteLabels(props.namespace, props.slug, props.canManage) + const attachMutation = useAttachSuiteLabel() + const detachMutation = useDetachSuiteLabel() + return ( + attachMutation.mutate( + { namespace: props.namespace, slug: props.slug, labelSlug }, callbacks, + )} + detachLabel={(labelSlug, callbacks) => detachMutation.mutate( + { namespace: props.namespace, slug: props.slug, labelSlug }, callbacks, + )} + attachPending={attachMutation.isPending} + detachPending={detachMutation.isPending} + translationPrefix="suite" + /> + ) +} diff --git a/web/src/features/suite/resource-card.test.tsx b/web/src/features/suite/resource-card.test.tsx index bf579793..a8f8705e 100644 --- a/web/src/features/suite/resource-card.test.tsx +++ b/web/src/features/suite/resource-card.test.tsx @@ -26,6 +26,7 @@ describe('ResourceCard', () => { installCount: 3, available: true, updatedAt: '2026-09-07T10:00:00Z', + labels: [{ slug: 'healthcare', type: 'RECOMMENDED', displayName: '医疗健康' }], }} onClick={() => undefined} />, @@ -35,6 +36,7 @@ describe('ResourceCard', () => { expect(html).toContain('Starter Suite') expect(html).toContain('@team-ai') expect(html).toContain('v2.0.0') + expect(html).toContain('医疗健康') }) it('shows the computed unavailable state', () => { diff --git a/web/src/features/suite/resource-card.tsx b/web/src/features/suite/resource-card.tsx index d6b242f7..e0eaccbf 100644 --- a/web/src/features/suite/resource-card.tsx +++ b/web/src/features/suite/resource-card.tsx @@ -40,6 +40,19 @@ export function ResourceCard({ resource, onClick }: { resource: ResourceSummary;

{resource.summary || t('suite.noSummary')}

+ {isSuite && resource.labels?.length ? ( +
+ {resource.labels.map(label => ( + + {label.displayName} + + ))} +
+ ) : null}
{ + it('accepts one Bundle root with a manifest and member package', () => { + expect(validateSuiteBundleFolder([ + fileAt('bundle/SUITE.yaml'), + fileAt('bundle/skills/member/SKILL.md'), + ])).toBeNull() + }) + + it('rejects missing and ambiguous roots before reading file bytes', () => { + expect(validateSuiteBundleFolder([fileAt('bundle/skills/member/SKILL.md')])) + .toBe('missing-suite-manifest') + expect(validateSuiteBundleFolder([ + fileAt('bundle-a/SUITE.yaml'), + fileAt('bundle-b/SUITE.yaml'), + ])).toBe('mixed-folder-roots') + }) + + it('does not count ignored build and VCS files against Bundle limits', () => { + expect(validateSuiteBundleFolder([ + fileAt('bundle/SUITE.yaml'), + fileAt('bundle/.git/objects/large', 20 * 1024 * 1024), + ])).toBeNull() + }) + + it('rejects oversized files and aggregate selections', () => { + expect(validateSuiteBundleFolder([ + fileAt('bundle/SUITE.yaml'), + fileAt('bundle/large.bin', 10 * 1024 * 1024 + 1), + ])).toBe('file-too-large') + const files = Array.from({ length: 11 }, (_, index) => + fileAt(index === 0 ? 'bundle/SUITE.yaml' : `bundle/chunk-${index}`, 10 * 1024 * 1024)) + expect(validateSuiteBundleFolder(files)).toBe('bundle-too-large') + }) + + it('accepts exact byte limits and rejects the first byte above them', () => { + expect(validateSuiteBundleFolder([ + fileAt('bundle/SUITE.yaml', 1), + ...Array.from({ length: 10 }, (_, index) => + fileAt(`bundle/chunk-${index}`, 10 * 1024 * 1024 - (index === 0 ? 1 : 0))), + ])).toBeNull() + expect(validateSuiteBundleFolder([ + fileAt('bundle/SUITE.yaml'), + fileAt('bundle/large.bin', 10 * 1024 * 1024 + 1), + ])).toBe('file-too-large') + }) + + it('rejects more than 50,000 included files using metadata only', () => { + const files = [fileAt('bundle/SUITE.yaml'), ...Array.from( + { length: 50_000 }, + (_, index) => fileAt(`bundle/member-${index}.md`, 0), + )] + expect(validateSuiteBundleFolder(files)).toBe('too-many-files') + }) +}) + +describe('validateSuiteBundleZip', () => { + it('requires a non-empty zip within the archive limit', () => { + expect(validateSuiteBundleZip(new File(['zip'], 'bundle.zip'))).toBeNull() + expect(validateSuiteBundleZip(new File(['text'], 'bundle.txt'))).toBe('invalid-zip') + expect(validateSuiteBundleZip(new File([], 'bundle.zip'))).toBe('empty-folder') + }) + + it('accepts an archive at 100 MB and rejects one byte above it', () => { + const atLimit = new File(['x'], 'bundle.zip') + Object.defineProperty(atLimit, 'size', { value: 100 * 1024 * 1024 }) + const aboveLimit = new File(['x'], 'bundle.zip') + Object.defineProperty(aboveLimit, 'size', { value: 100 * 1024 * 1024 + 1 }) + expect(validateSuiteBundleZip(atLimit)).toBeNull() + expect(validateSuiteBundleZip(aboveLimit)).toBe('bundle-too-large') + }) +}) diff --git a/web/src/features/suite/suite-bundle-folder.ts b/web/src/features/suite/suite-bundle-folder.ts new file mode 100644 index 00000000..e45396fa --- /dev/null +++ b/web/src/features/suite/suite-bundle-folder.ts @@ -0,0 +1,54 @@ +import { isIgnoredPath } from '@/features/publish/folder-zip' + +const MAX_BUNDLE_BYTES = 100 * 1024 * 1024 +const MAX_FILE_BYTES = 10 * 1024 * 1024 +const MAX_FILE_COUNT = 50_000 + +export type SuiteBundleFolderError = + | 'empty-folder' + | 'mixed-folder-roots' + | 'missing-suite-manifest' + | 'duplicate-suite-manifest' + | 'too-many-files' + | 'file-too-large' + | 'bundle-too-large' + +function relativePath(file: File): string { + return (file as File & { webkitRelativePath?: string }).webkitRelativePath || file.name +} + +function withoutRoot(path: string): string { + const separator = path.indexOf('/') + return separator < 0 ? path : path.slice(separator + 1) +} + +/** Fast metadata-only checks before any selected file is read into the browser ZIP. */ +export function validateSuiteBundleFolder(files: File[]): SuiteBundleFolderError | null { + const included = files.filter((file) => !isIgnoredPath(relativePath(file))) + if (included.length === 0) return 'empty-folder' + if (included.length > MAX_FILE_COUNT) return 'too-many-files' + + const roots = new Set() + let total = 0 + let manifests = 0 + for (const file of included) { + const path = relativePath(file) + const separator = path.indexOf('/') + if (separator > 0) roots.add(path.slice(0, separator)) + if (file.size > MAX_FILE_BYTES) return 'file-too-large' + total += file.size + if (total > MAX_BUNDLE_BYTES) return 'bundle-too-large' + if (withoutRoot(path) === 'SUITE.yaml') manifests += 1 + } + if (roots.size > 1) return 'mixed-folder-roots' + if (manifests === 0) return 'missing-suite-manifest' + if (manifests > 1) return 'duplicate-suite-manifest' + return null +} + +export function validateSuiteBundleZip(file: File): SuiteBundleFolderError | 'invalid-zip' | null { + if (!file.name.toLowerCase().endsWith('.zip')) return 'invalid-zip' + if (file.size === 0) return 'empty-folder' + if (file.size > MAX_BUNDLE_BYTES) return 'bundle-too-large' + return null +} diff --git a/web/src/features/suite/suite-bundle-import.test.tsx b/web/src/features/suite/suite-bundle-import.test.tsx new file mode 100644 index 00000000..6e300de7 --- /dev/null +++ b/web/src/features/suite/suite-bundle-import.test.tsx @@ -0,0 +1,412 @@ +/** @vitest-environment jsdom */ + +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { SuiteBundleImport } from './suite-bundle-import' + +const mocks = vi.hoisted(() => ({ + preview: { mutateAsync: vi.fn(), isPending: false }, + confirm: { mutateAsync: vi.fn(), isPending: false }, + cancel: { mutate: vi.fn(), isPending: false }, + retry: { mutate: vi.fn(), isPending: false }, + operation: { + data: undefined as Record | undefined, + isLoading: false, + error: null as Error | null, + refetch: vi.fn(), + }, + packageFolder: vi.fn(), + toast: { error: vi.fn() }, + navigate: vi.fn(), + requestedOperationIds: [] as Array, +})) + +vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key }) })) +vi.mock('@/shared/lib/toast', () => ({ toast: mocks.toast })) +vi.mock('@tanstack/react-router', () => ({ useNavigate: () => mocks.navigate })) +vi.mock('@/features/publish/folder-zip', async (importOriginal) => ({ + ...(await importOriginal()), + packageFolderAsZip: mocks.packageFolder, +})) +vi.mock('@/features/publish/upload-zone', () => ({ + UploadZone: ({ onFileSelect, onFolderSelect }: { + onFileSelect: (file: File) => void + onFolderSelect: (files: File[]) => void + }) => ( +
+ + + +
+ ), +})) +vi.mock('@/shared/hooks/use-suite-queries', () => ({ + usePreviewSuiteBundle: () => mocks.preview, + useConfirmSuiteBundle: () => mocks.confirm, + useCancelSuiteBundleOperation: () => mocks.cancel, + useRetrySuiteBundleOperation: () => mocks.retry, + useSuiteBundleOperation: (operationId?: string) => { + mocks.requestedOperationIds.push(operationId) + return mocks.operation + }, +})) + +function folderFile(path: string): File { + const file = new File(['content'], path.split('/').pop() || path) + Object.defineProperty(file, 'webkitRelativePath', { value: path }) + return file +} + +function preview(overrides: Record = {}) { + return { + previewToken: 'preview-1', + warningDigest: 'digest-1', + expiresAt: new Date(Date.now() + 60_000).toISOString(), + confirmable: true, + target: { mode: 'CREATE', coordinate: '@global/suite', targetVersion: '1.0.0' }, + members: [{ + coordinate: '@global/member', sourceType: 'PACKAGE', packagePath: 'members/member', relationship: 'ADDED', + publishAction: 'CREATE_SKILL', finalVisibility: 'PUBLIC', resolvedVersion: '1.0.0', + errors: [], warnings: ['review visibility'], + }], + removedMembers: [], errors: [], warnings: [], + ...overrides, + } +} + +describe('SuiteBundleImport', () => { + beforeEach(() => { + vi.stubGlobal('crypto', { randomUUID: () => 'request-1' }) + window.sessionStorage.clear() + }) + + afterEach(() => { + cleanup() + vi.clearAllMocks() + vi.unstubAllGlobals() + mocks.operation.data = undefined + mocks.operation.error = null + mocks.preview.isPending = false + mocks.requestedOperationIds = [] + }) + + it('uploads one archive, requires explicit warning acceptance and confirms the exact preview', async () => { + mocks.preview.mutateAsync.mockResolvedValue(preview()) + mocks.confirm.mutateAsync.mockResolvedValue({ operationId: 'operation-1', status: 'RUNNING' }) + render() + + fireEvent.click(screen.getByRole('button', { name: 'pick-zip' })) + await waitFor(() => expect(screen.getByText('@global/member')).not.toBeNull()) + expect(screen.getByText('suite.bundle.memberDirectory')).not.toBeNull() + expect(mocks.preview.mutateAsync).toHaveBeenCalledTimes(1) + expect(screen.getByRole('button', { name: 'suite.bundle.confirm' }).hasAttribute('disabled')).toBe(true) + + fireEvent.click(screen.getByRole('checkbox')) + fireEvent.click(screen.getByRole('button', { name: 'suite.bundle.confirm' })) + + await waitFor(() => expect(mocks.confirm.mutateAsync).toHaveBeenCalledWith({ + previewToken: 'preview-1', + warningDigest: 'digest-1', + idempotencyKey: 'request-1', + })) + }) + + it('requires warning acceptance for every affected member', async () => { + mocks.preview.mutateAsync.mockResolvedValue(preview({ + members: [ + { + coordinate: '@global/first', sourceType: 'PACKAGE', packagePath: 'members/first', + relationship: 'ADDED', publishAction: 'CREATE_SKILL', finalVisibility: 'PUBLIC', + resolvedVersion: '1.0.0', errors: [], warnings: ['first warning'], + }, + { + coordinate: '@global/second', sourceType: 'PACKAGE', packagePath: 'members/second', + relationship: 'ADDED', publishAction: 'CREATE_SKILL', finalVisibility: 'PUBLIC', + resolvedVersion: '1.0.0', errors: [], warnings: ['second warning'], + }, + ], + warnings: ['@global/first: first warning', '@global/second: second warning'], + })) + render() + + fireEvent.click(screen.getByRole('button', { name: 'pick-zip' })) + await waitFor(() => expect(screen.getAllByRole('checkbox')).toHaveLength(2)) + const [first, second] = screen.getAllByRole('checkbox') + const confirmButton = screen.getByRole('button', { name: 'suite.bundle.confirm' }) + + fireEvent.click(first) + expect(confirmButton.hasAttribute('disabled')).toBe(true) + fireEvent.click(second) + expect(confirmButton.hasAttribute('disabled')).toBe(false) + expect(screen.getAllByText('first warning')).toHaveLength(1) + expect(screen.getAllByText('second warning')).toHaveLength(1) + }) + + it('does not report no changes for a confirmable presentation-only update', async () => { + mocks.preview.mutateAsync.mockResolvedValue(preview({ + confirmable: true, + target: { + mode: 'UPDATE', coordinate: '@global/suite', targetVersion: '2.0.0', + displayName: 'Updated suite', summary: 'Updated summary', overview: 'Updated overview', + visibility: 'PUBLIC', + }, + members: [{ + coordinate: '@global/member', sourceType: 'REFERENCE', relationship: 'UNCHANGED', + publishAction: 'REFERENCE_VERSION', finalVisibility: 'PUBLIC', resolvedVersion: '1.0.0', + errors: [], warnings: [], + }], + })) + render() + + fireEvent.click(screen.getByRole('button', { name: 'pick-zip' })) + await waitFor(() => expect(screen.getByRole('button', { name: 'suite.bundle.confirm' })).toBeTruthy()) + + expect(screen.queryByText('suite.bundle.noChanges')).toBeNull() + }) + + it('blocks a Bundle targeting a different workflow entry', async () => { + mocks.preview.mutateAsync.mockResolvedValue(preview({ + target: { mode: 'UPDATE', coordinate: '@global/other', targetVersion: '2.0.0' }, + members: [], + })) + render() + + fireEvent.click(screen.getByRole('button', { name: 'pick-zip' })) + + await waitFor(() => expect(screen.getByText('suite.bundle.targetMismatch')).not.toBeNull()) + expect(screen.getByRole('button', { name: 'suite.bundle.confirm' }).hasAttribute('disabled')).toBe(true) + }) + + it('blocks confirmation after the preview expires', async () => { + mocks.preview.mutateAsync.mockResolvedValue(preview({ + expiresAt: '2000-01-01T00:00:00Z', + members: [], + })) + render() + + fireEvent.click(screen.getByRole('button', { name: 'pick-zip' })) + + await waitFor(() => expect(screen.getByText('suite.bundle.previewExpired')).not.toBeNull()) + expect(screen.getByRole('button', { name: 'suite.bundle.confirm' }).hasAttribute('disabled')).toBe(true) + }) + + it('requires explicit acknowledgement for member removals even without warnings', async () => { + mocks.preview.mutateAsync.mockResolvedValue(preview({ + members: [], + removedMembers: [{ coordinate: '@global/entry', version: '1.0.0', entry: true }], + })) + render() + + fireEvent.click(screen.getByRole('button', { name: 'pick-zip' })) + + await waitFor(() => expect(screen.getByText(/suite.bundle.removedEntryMember/)).not.toBeNull()) + const confirmButton = screen.getByRole('button', { name: 'suite.bundle.confirm' }) + expect(confirmButton.hasAttribute('disabled')).toBe(true) + fireEvent.click(screen.getByRole('checkbox')) + expect(confirmButton.hasAttribute('disabled')).toBe(false) + }) + + it('reuses the same confirmation key after a lost or failed response', async () => { + mocks.preview.mutateAsync.mockResolvedValue(preview({ members: [] })) + mocks.confirm.mutateAsync.mockRejectedValue(new Error('response lost')) + render() + fireEvent.click(screen.getByRole('button', { name: 'pick-zip' })) + await waitFor(() => expect( + screen.getByRole('button', { name: 'suite.bundle.confirm' }).hasAttribute('disabled') + ).toBe(false)) + + fireEvent.click(screen.getByRole('button', { name: 'suite.bundle.confirm' })) + await waitFor(() => expect(mocks.confirm.mutateAsync).toHaveBeenCalledTimes(1)) + fireEvent.click(screen.getByRole('button', { name: 'suite.bundle.confirm' })) + await waitFor(() => expect(mocks.confirm.mutateAsync).toHaveBeenCalledTimes(2)) + + expect(mocks.confirm.mutateAsync.mock.calls[0][0].idempotencyKey).toBe('request-1') + expect(mocks.confirm.mutateAsync.mock.calls[1][0].idempotencyKey).toBe('request-1') + }) + + it('rejects a folder without SUITE.yaml before packaging or upload', () => { + render() + + fireEvent.click(screen.getByRole('button', { name: 'pick-invalid-folder' })) + + expect(mocks.toast.error).toHaveBeenCalledWith('suite.bundle.errors.missing-suite-manifest') + expect(mocks.packageFolder).not.toHaveBeenCalled() + expect(mocks.preview.mutateAsync).not.toHaveBeenCalled() + }) + + it('uploads a packaged folder once without recursively starting a new selection', async () => { + mocks.packageFolder.mockResolvedValue(new File(['zip'], 'bundle.zip')) + mocks.preview.mutateAsync.mockResolvedValue(preview({ members: [] })) + render() + + fireEvent.click(screen.getByRole('button', { name: 'pick-folder' })) + + await waitFor(() => expect(mocks.preview.mutateAsync).toHaveBeenCalledTimes(1)) + expect(mocks.packageFolder).toHaveBeenCalledTimes(1) + }) + + it('prevents an older folder packaging result from replacing a newer ZIP selection', async () => { + let finishFolder!: (file: File) => void + mocks.packageFolder.mockImplementation(() => new Promise((resolve) => { finishFolder = resolve })) + mocks.preview.mutateAsync.mockResolvedValue(preview({ members: [] })) + render() + + fireEvent.click(screen.getByRole('button', { name: 'pick-folder' })) + fireEvent.click(screen.getByRole('button', { name: 'pick-zip' })) + await waitFor(() => expect(mocks.preview.mutateAsync).toHaveBeenCalledTimes(1)) + finishFolder(new File(['old'], 'old-folder.zip')) + + await waitFor(() => expect(mocks.preview.mutateAsync).toHaveBeenCalledTimes(1)) + expect(mocks.preview.mutateAsync.mock.calls[0][0].file.name).toBe('bundle.zip') + }) + + it('aborts the previous preview request when a newer archive is selected', async () => { + let firstSignal: AbortSignal | undefined + mocks.preview.mutateAsync + .mockImplementationOnce(({ signal }: { signal?: AbortSignal }) => { + firstSignal = signal + return new Promise(() => {}) + }) + .mockResolvedValueOnce(preview({ members: [] })) + render() + + fireEvent.click(screen.getByRole('button', { name: 'pick-zip' })) + fireEvent.click(screen.getByRole('button', { name: 'pick-zip' })) + + await waitFor(() => expect(mocks.preview.mutateAsync).toHaveBeenCalledTimes(2)) + expect(firstSignal?.aborted).toBe(true) + }) + + it('opens the dedicated publishing task after confirmation', async () => { + mocks.preview.mutateAsync.mockResolvedValue(preview({ members: [] })) + mocks.confirm.mutateAsync.mockResolvedValue({ operationId: 'operation-1', status: 'RUNNING' }) + render() + fireEvent.click(screen.getByRole('button', { name: 'pick-zip' })) + await waitFor(() => expect( + screen.getByRole('button', { name: 'suite.bundle.confirm' }).hasAttribute('disabled') + ).toBe(false)) + fireEvent.click(screen.getByRole('button', { name: 'suite.bundle.confirm' })) + + await waitFor(() => expect(mocks.navigate).toHaveBeenCalledWith({ + to: '/dashboard/suites/publishing/operation-1', + replace: true, + })) + expect(window.sessionStorage.getItem( + 'skillhub:suite-bundle-operation:CREATE:new' + )).toBeNull() + }) + + it('preserves the Suite return target for an update publishing task', async () => { + mocks.preview.mutateAsync.mockResolvedValue(preview({ + members: [], + target: { mode: 'UPDATE', coordinate: '@global/suite', targetVersion: '2.0.0' }, + })) + mocks.confirm.mutateAsync.mockResolvedValue({ operationId: 'operation-update', status: 'RUNNING' }) + render( + , + ) + fireEvent.click(screen.getByRole('button', { name: 'pick-zip' })) + await waitFor(() => expect( + screen.getByRole('button', { name: 'suite.bundle.confirm' }).hasAttribute('disabled') + ).toBe(false)) + fireEvent.click(screen.getByRole('button', { name: 'suite.bundle.confirm' })) + + await waitFor(() => expect(mocks.navigate).toHaveBeenCalledWith({ + to: '/dashboard/suites/publishing/operation-update', + search: { + suiteNamespace: 'global', + suiteSlug: 'suite', + suiteVersion: '1.0.0', + }, + replace: true, + })) + }) + + it('opens the dedicated publishing task when a stored operation is restored', async () => { + const key = 'skillhub:suite-bundle-operation:CREATE:new' + window.sessionStorage.setItem(key, 'operation-restored') + mocks.operation.data = { + operationId: 'operation-restored', + status: 'RUNNING', + mode: 'CREATE', + targetCoordinate: '@global/suite', + members: [], + } + + render() + + await waitFor(() => expect(mocks.navigate).toHaveBeenCalledWith({ + to: '/dashboard/suites/publishing/operation-restored', + replace: true, + })) + expect(window.sessionStorage.getItem(key)).toBeNull() + }) + + it('opens the dedicated task when restoring its status fails instead of staying stuck', async () => { + const key = 'skillhub:suite-bundle-operation:CREATE:new' + window.sessionStorage.setItem(key, 'operation-offline') + mocks.operation.error = new Error('offline') + + render() + + await waitFor(() => expect(mocks.navigate).toHaveBeenCalledWith({ + to: '/dashboard/suites/publishing/operation-offline', + replace: true, + })) + expect(window.sessionStorage.getItem(key)).toBeNull() + }) + + it('reads the new Suite recovery key when an UPDATE route changes without a full page reload', async () => { + window.sessionStorage.setItem( + 'skillhub:suite-bundle-operation:UPDATE:@global/suite-a', + 'operation-a', + ) + window.sessionStorage.setItem( + 'skillhub:suite-bundle-operation:UPDATE:@global/suite-b', + 'operation-b', + ) + const Harness = ({ coordinate }: { coordinate: string }) => ( + + ) + const { rerender } = render() + expect(mocks.requestedOperationIds[mocks.requestedOperationIds.length - 1]).toBe('operation-a') + + rerender() + + await waitFor(() => expect( + mocks.requestedOperationIds[mocks.requestedOperationIds.length - 1] + ).toBe('operation-b')) + expect(window.sessionStorage.getItem( + 'skillhub:suite-bundle-operation:UPDATE:@global/suite-b' + )).toBe('operation-b') + }) + + it('clears a restored UPDATE operation that belongs to another Suite without exposing actions', async () => { + const key = 'skillhub:suite-bundle-operation:UPDATE:@global/suite-b' + window.sessionStorage.setItem(key, 'operation-a') + mocks.operation.data = { + operationId: 'operation-a', + status: 'BLOCKED_RETRYABLE', + mode: 'UPDATE', + targetCoordinate: '@global/suite-a', + members: [{ position: 0, status: 'FAILED_RETRYABLE', redacted: true }], + } + + render() + + expect(screen.queryByText('suite.bundle.redactedMember')).toBeNull() + expect(screen.queryByRole('button', { name: /suite.bundle.retry/ })).toBeNull() + expect(screen.queryByRole('button', { name: 'suite.bundle.cancelOperation' })).toBeNull() + await waitFor(() => expect(window.sessionStorage.getItem(key)).toBeNull()) + expect(screen.getByText('suite.bundle.uploadTitle')).not.toBeNull() + expect(mocks.toast.error).toHaveBeenCalledWith('suite.bundle.operationTargetMismatch') + }) +}) diff --git a/web/src/features/suite/suite-bundle-import.tsx b/web/src/features/suite/suite-bundle-import.tsx new file mode 100644 index 00000000..bf124ca0 --- /dev/null +++ b/web/src/features/suite/suite-bundle-import.tsx @@ -0,0 +1,376 @@ +import { useEffect, useRef, useState } from 'react' +import { AlertTriangle, CheckCircle2, FileArchive, ShieldAlert, XCircle } from 'lucide-react' +import { useTranslation } from 'react-i18next' +import { useNavigate } from '@tanstack/react-router' +import type { SkillSuiteBundlePreview } from '@/api/types' +import { packageFolderAsZip } from '@/features/publish/folder-zip' +import { UploadZone } from '@/features/publish/upload-zone' +import { + useConfirmSuiteBundle, + usePreviewSuiteBundle, + useSuiteBundleOperation, +} from '@/shared/hooks/use-suite-queries' +import { Button } from '@/shared/ui/button' +import { Card } from '@/shared/ui/card' +import { toast } from '@/shared/lib/toast' +import { validateSuiteBundleFolder, validateSuiteBundleZip } from './suite-bundle-folder' + +type BundleMode = 'CREATE' | 'UPDATE' + +function operationStorageKey(mode: BundleMode, coordinate?: string): string { + return `skillhub:suite-bundle-operation:${mode}:${coordinate ?? 'new'}` +} + +function readStoredOperation(key: string): string | undefined { + try { + return window.sessionStorage.getItem(key) || undefined + } catch { + return undefined + } +} + +function writeStoredOperation(key: string, operationId?: string): void { + try { + if (operationId) window.sessionStorage.setItem(key, operationId) + else window.sessionStorage.removeItem(key) + } catch { + // Browsers can disable session storage. The active page still remains usable. + } +} + +export function hasStoredSuiteBundleOperation(mode: BundleMode, coordinate?: string): boolean { + return Boolean(readStoredOperation(operationStorageKey(mode, coordinate))) +} + +export function rememberSuiteBundleOperation( + mode: BundleMode, + coordinate: string, + operationId: string, +): void { + writeStoredOperation(operationStorageKey(mode, mode === 'UPDATE' ? coordinate : undefined), operationId) +} + +export function SuiteBundleImport({ expectedMode, expectedCoordinate, returnToSuite }: { + expectedMode: BundleMode + expectedCoordinate?: string + returnToSuite?: { namespace: string; slug: string; version: string } +}) { + const { t } = useTranslation() + const navigate = useNavigate() + const storageKey = operationStorageKey(expectedMode, expectedCoordinate) + const previewMutation = usePreviewSuiteBundle() + const confirmMutation = useConfirmSuiteBundle() + const [preview, setPreview] = useState(null) + const [fileName, setFileName] = useState('') + const [acceptedWarningMembers, setAcceptedWarningMembers] = useState>(() => new Set()) + const [removalsAccepted, setRemovalsAccepted] = useState(false) + const [operationId, setOperationId] = useState(() => readStoredOperation(storageKey)) + const restoredOperationIdRef = useRef(operationId) + const [packaging, setPackaging] = useState(false) + const [now, setNow] = useState(() => Date.now()) + const requestRef = useRef(null) + const selectionVersionRef = useRef(0) + const idempotencyKeyRef = useRef(null) + const operationQuery = useSuiteBundleOperation(operationId) + const operation = operationQuery.data + const operationMatchesTarget = !operation + || (operation.mode === expectedMode + && (expectedCoordinate === undefined || operation.targetCoordinate === expectedCoordinate)) + + useEffect(() => () => { + selectionVersionRef.current += 1 + requestRef.current?.abort() + }, []) + useEffect(() => writeStoredOperation(storageKey, operationId), [operationId, storageKey]) + useEffect(() => { + if (!operationId || !operation || operationMatchesTarget) return + writeStoredOperation(storageKey) + setOperationId(undefined) + toast.error(t('suite.bundle.operationTargetMismatch')) + }, [operation, operationId, operationMatchesTarget, storageKey, t]) + useEffect(() => { + if (!operationId || !operationMatchesTarget) return + if (restoredOperationIdRef.current === operationId && !operation && !operationQuery.error) return + writeStoredOperation(storageKey) + restoredOperationIdRef.current = undefined + if (returnToSuite) { + void navigate({ + to: `/dashboard/suites/publishing/${operationId}`, + search: { + suiteNamespace: returnToSuite.namespace, + suiteSlug: returnToSuite.slug, + suiteVersion: returnToSuite.version, + }, + replace: true, + }) + } else { + void navigate({ to: `/dashboard/suites/publishing/${operationId}`, replace: true }) + } + }, [navigate, operation, operationId, operationMatchesTarget, operationQuery.error, returnToSuite, storageKey]) + useEffect(() => { + if (!preview) return undefined + const timer = window.setInterval(() => setNow(Date.now()), 1_000) + return () => window.clearInterval(timer) + }, [preview]) + + const beginSelection = () => { + const selectionVersion = ++selectionVersionRef.current + requestRef.current?.abort() + requestRef.current = null + setPackaging(false) + setPreview(null) + setOperationId(undefined) + setAcceptedWarningMembers(new Set()) + setRemovalsAccepted(false) + idempotencyKeyRef.current = null + return selectionVersion + } + + const uploadArchive = async (file: File, selectionVersion: number) => { + if (selectionVersion !== selectionVersionRef.current) return + const validationError = validateSuiteBundleZip(file) + if (validationError) { + toast.error(t(`suite.bundle.errors.${validationError}`)) + return + } + const controller = new AbortController() + requestRef.current = controller + setFileName(file.name) + try { + const result = await previewMutation.mutateAsync({ file, signal: controller.signal }) + if (!controller.signal.aborted && selectionVersion === selectionVersionRef.current) { + idempotencyKeyRef.current = crypto.randomUUID() + setNow(Date.now()) + setPreview(result) + } + } catch (error) { + if (!controller.signal.aborted && selectionVersion === selectionVersionRef.current) { + toast.error(t('suite.bundle.previewFailed'), error instanceof Error ? error.message : '') + } + } finally { + if (requestRef.current === controller) requestRef.current = null + } + } + + const previewFile = async (file: File) => { + const selectionVersion = beginSelection() + await uploadArchive(file, selectionVersion) + } + + const previewFolder = async (files: File[]) => { + const selectionVersion = beginSelection() + const validationError = validateSuiteBundleFolder(files) + if (validationError) { + toast.error(t(`suite.bundle.errors.${validationError}`)) + return + } + setPackaging(true) + try { + const archive = await packageFolderAsZip(files) + if (selectionVersion === selectionVersionRef.current) await uploadArchive(archive, selectionVersion) + } catch (error) { + if (selectionVersion === selectionVersionRef.current) { + toast.error(t('suite.bundle.packageFailed'), error instanceof Error ? error.message : '') + } + } finally { + if (selectionVersion === selectionVersionRef.current) setPackaging(false) + } + } + + const targetMatches = preview?.target?.mode === expectedMode + && (expectedCoordinate === undefined || preview.target.coordinate === expectedCoordinate) + const targetMismatch = preview !== null && !targetMatches + const previewExpired = Boolean(preview?.expiresAt && Date.parse(preview.expiresAt) <= now) + const warningMembers = (preview?.members ?? []) + .filter((member) => (member.warnings?.length ?? 0) > 0) + const removalCount = preview?.removedMembers?.length ?? 0 + const allWarningMembersAccepted = warningMembers + .every((member) => Boolean( + member.coordinate && acceptedWarningMembers.has(member.coordinate) + )) + const canConfirm = Boolean( + preview?.confirmable + && preview.previewToken + && preview.warningDigest + && targetMatches + && !previewExpired + && allWarningMembersAccepted + && (removalCount === 0 || removalsAccepted) + ) + + const confirm = async () => { + if (!preview?.previewToken || !preview.warningDigest || !canConfirm) return + const idempotencyKey = idempotencyKeyRef.current ?? crypto.randomUUID() + idempotencyKeyRef.current = idempotencyKey + try { + const result = await confirmMutation.mutateAsync({ + previewToken: preview.previewToken, + warningDigest: preview.warningDigest, + idempotencyKey, + }) + if (result.operationId) setOperationId(result.operationId) + } catch (error) { + toast.error(t('suite.bundle.confirmFailed'), error instanceof Error ? error.message : '') + } + } + + if (operationId) { + if (!operationMatchesTarget) { + return {t('suite.bundle.operationTargetMismatch')} + } + return ( + + {t('suite.bundle.openingTask')} + + ) + } + + return ( +
+ +
+
+ + {fileName ?

{fileName}

: null} + {previewMutation.isPending || packaging ? ( +
+ ) : null} + + + {preview ? ( + +
+
+

{t('suite.bundle.previewTitle')}

+

+ {preview.target?.coordinate} · v{preview.target?.targetVersion} +

+
+ {preview.confirmable && !targetMismatch + ? + : } +
+ + {targetMismatch ? ( +
+ {t('suite.bundle.targetMismatch')} +
+ ) : null} + {previewExpired ? ( +
+ {t('suite.bundle.previewExpired')} +
+ ) : null} + + {preview.target ? ( +
+

{preview.target.displayName}

+

{preview.target.summary}

+

{preview.target.overview}

+
+ ) : null} + + {(preview.errors ?? []).map((error) => ( +

{error}

+ ))} +
+ {(preview.members ?? []).map((member) => ( +
+
+ {member.coordinate} + + {t(`suite.bundle.relationship.${member.relationship}`)} · {t(`suite.bundle.action.${member.publishAction}`)} + +
+

+ {member.sourceType ? t(`suite.bundle.source.${member.sourceType}`) : null} + {' · '}{member.finalVisibility} · v{member.resolvedVersion} +

+ {member.packagePath ? ( +

+ {t('suite.bundle.memberDirectory', { path: member.packagePath })} +

+ ) : null} +

+ {member.sourceType === 'REFERENCE' || member.publishAction === 'REUSE_VERSION' + ? t('suite.bundle.path.noWrite') + : member.finalVisibility === 'PRIVATE' + ? t('suite.bundle.path.private') + : t('suite.bundle.path.publish')} +

+ {(member.errors ?? []).map((error) =>

{error}

)} + {(member.warnings ?? []).map((warning) =>

{warning}

)} + {(member.warnings?.length ?? 0) > 0 ? ( + + ) : null} +
+ ))} + {(preview.removedMembers ?? []).map((member) => ( +
+ + + {t('suite.bundle.removedMember', { coordinate: member.coordinate, version: member.version })} + {member.entry ? ` · ${t('suite.bundle.removedEntryMember')}` : null} + +
+ ))} + {(preview.members ?? []).every((member) => member.relationship === 'UNCHANGED') + && (preview.removedMembers?.length ?? 0) === 0 + && !preview.confirmable ? ( +

+ {t('suite.bundle.noChanges')} +

+ ) : null} +
+ + {removalCount > 0 ? ( + + ) : null} + +
+ + +
+
+ ) : null} +
+ ) +} diff --git a/web/src/features/suite/suite-bundle-operation-detail.test.tsx b/web/src/features/suite/suite-bundle-operation-detail.test.tsx new file mode 100644 index 00000000..7823be0f --- /dev/null +++ b/web/src/features/suite/suite-bundle-operation-detail.test.tsx @@ -0,0 +1,309 @@ +/** @vitest-environment jsdom */ + +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { SuiteBundleOperationDetail } from './suite-bundle-operation-detail' + +const mocks = vi.hoisted(() => ({ + navigate: vi.fn(), + cancel: { mutate: vi.fn(), isPending: false }, + retry: { mutate: vi.fn(), isPending: false }, + operation: { + data: undefined as Record | undefined, + isLoading: false, + error: null as Error | null, + refetch: vi.fn(), + }, +})) + +vi.mock('@tanstack/react-router', () => ({ useNavigate: () => mocks.navigate })) +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ t: (key: string) => key, i18n: { language: 'zh-CN' } }), +})) +vi.mock('@/shared/hooks/use-suite-queries', () => ({ + useSuiteBundleOperation: () => mocks.operation, + useCancelSuiteBundleOperation: () => mocks.cancel, + useRetrySuiteBundleOperation: () => mocks.retry, +})) + +describe('SuiteBundleOperationDetail', () => { + afterEach(() => { + cleanup() + vi.clearAllMocks() + mocks.operation.data = undefined + mocks.operation.error = null + }) + + it('explains cancellation before stopping Suite creation', () => { + mocks.operation.data = { + operationId: 'operation-1', + status: 'WAITING_FOR_MEMBERS', + mode: 'CREATE', + targetCoordinate: '@global/care-suite', + targetVersion: '1.0.0', + updatedAt: '2026-09-14T04:00:00Z', + members: [], + } + + render() + + expect(screen.queryByText('suite.bundle.nextStep.WAITING_FOR_MEMBERS')).toBeNull() + fireEvent.click(screen.getByRole('button', { name: 'suite.bundle.cancelOperation' })) + + expect(screen.getByText('suite.bundle.cancelConfirmDescription')).not.toBeNull() + fireEvent.click(screen.getByRole('button', { name: 'suite.bundle.cancelConfirmAction' })) + expect(mocks.cancel.mutate).toHaveBeenCalledWith( + 'operation-1', + expect.objectContaining({ onSuccess: expect.any(Function), onError: expect.any(Function) }), + ) + }) + + it('keeps the review link available for a created member version after cancellation', () => { + mocks.operation.data = { + operationId: 'operation-1', + status: 'CANCELLED', + mode: 'CREATE', + targetCoordinate: '@global/care-suite', + targetVersion: '1.0.0', + updatedAt: '2026-09-14T04:00:00Z', + members: [{ + position: 0, + redacted: false, + coordinate: '@global/member-under-review', + status: 'CANCELLED', + version: '1.4.0', + skillId: 41, + skillVersionId: 42, + sourceType: 'PACKAGE', + relationship: 'ADDED', + publishAction: 'CREATE_VERSION', + visibility: 'PUBLIC', + errors: [], + warnings: [], + }], + } + + render() + + expect(screen.getByText('suite.bundle.cancelledDescription')).not.toBeNull() + fireEvent.click(screen.getByRole('button', { name: 'suite.bundle.viewMemberSkill' })) + expect(mocks.navigate).toHaveBeenCalledWith({ + to: '/space/global/member-under-review', + search: { returnTo: '/dashboard/suites/publishing/operation-1', version: '1.4.0' }, + }) + }) + + it('explains when a new member Skill does not exist yet', () => { + mocks.operation.data = { + operationId: 'operation-planned', + status: 'RUNNING', + mode: 'CREATE', + targetCoordinate: '@global/care-suite', + targetVersion: '1.0.0', + updatedAt: '2026-09-14T04:00:00Z', + members: [{ + position: 0, + redacted: false, + coordinate: '@global/new-member', + status: 'PLANNED', + version: '1.0.0', + skillId: null, + skillVersionId: null, + errors: [], + warnings: [], + }], + } + + render() + + expect(screen.getByText('suite.bundle.memberSkillNotCreated')).not.toBeNull() + expect(screen.queryByRole('button', { name: 'suite.bundle.viewMemberSkill' })).toBeNull() + }) + + it('opens the generated Suite draft when publishing completes', () => { + mocks.operation.data = { + operationId: 'operation-2', + status: 'SUITE_DRAFT_CREATED', + mode: 'CREATE', + targetCoordinate: '@global/generated-suite', + targetVersion: '1.2.0', + resultSuiteId: 8, + resultSuiteVersionId: 9, + updatedAt: '2026-09-14T04:00:00Z', + members: [], + } + + render() + expect(screen.queryByText('suite.bundle.nextStep.SUITE_DRAFT_CREATED')).toBeNull() + fireEvent.click(screen.getByRole('button', { name: 'suite.bundle.openDraft' })) + expect(mocks.navigate).toHaveBeenCalledWith({ + to: '/dashboard/suites/global/generated-suite', + search: { version: '1.2.0' }, + }) + }) + + it('does not offer to open a draft when a completed legacy operation has no Suite result', () => { + mocks.operation.data = { + operationId: 'operation-missing-draft', + status: 'SUITE_DRAFT_CREATED', + mode: 'CREATE', + targetCoordinate: '@global/browser-suite', + targetVersion: '1.0.0', + resultSuiteId: null, + resultSuiteVersionId: null, + updatedAt: '2026-09-14T04:00:00Z', + members: [{ + position: 0, + redacted: false, + coordinate: '@global/browser-entry', + status: 'COMPLETED', + version: '1.0.0', + skillId: null, + skillVersionId: null, + sourceType: 'PACKAGE', + packagePath: 'skills/browser-entry', + relationship: 'ADDED', + publishAction: 'CREATE_SKILL', + visibility: 'PUBLIC', + errors: [], + warnings: ['Disallowed file extension: tool.exe'], + }], + } + + render() + + expect(screen.getByText('suite.bundle.problem.draftMissing.title')).not.toBeNull() + expect(screen.getByText('suite.bundle.problem.draftMissing.description')).not.toBeNull() + expect(screen.getByText('suite.bundle.memberStatus.ATTENTION')).not.toBeNull() + expect(screen.getByText('Disallowed file extension: tool.exe')).not.toBeNull() + expect(screen.getByText('suite.bundle.memberSkillNotCreated')).not.toBeNull() + expect(screen.queryByRole('button', { name: 'suite.bundle.openDraft' })).toBeNull() + expect(screen.getByRole('button', { name: 'suite.bundle.startAgain' })).not.toBeNull() + }) + + it('restarts an update from the original Suite version after re-preview is required', () => { + mocks.operation.data = { + operationId: 'operation-3', + status: 'REPREVIEW_REQUIRED', + mode: 'UPDATE', + targetCoordinate: '@team-a/care-suite', + targetVersion: '1.2.0', + baseVersion: '1.1.0', + failureCode: 'BUNDLE_PLAN_CHANGED', + updatedAt: '2026-09-14T04:00:00Z', + members: [], + } + + render() + + expect(screen.getByText('suite.bundle.problem.planChanged.title')).not.toBeNull() + expect(screen.getByText('suite.bundle.problem.planChanged.description')).not.toBeNull() + expect(screen.queryByText('BUNDLE_PLAN_CHANGED')).toBeNull() + fireEvent.click(screen.getByRole('button', { name: 'suite.bundle.startAgain' })) + expect(mocks.navigate).toHaveBeenCalledWith({ + to: '/dashboard/suites/team-a/care-suite/new-version', + search: { sourceVersion: '1.1.0' }, + }) + }) + + it('replaces an internal execution code with a useful explanation and affected member', () => { + mocks.operation.data = { + operationId: 'operation-5', + status: 'BLOCKED_RETRYABLE', + mode: 'CREATE', + targetCoordinate: '@global/care-suite', + targetVersion: '1.0.0', + failureCode: 'MEMBER_EXECUTION_FAILED', + updatedAt: '2026-09-14T04:00:00Z', + members: [{ + position: 0, + redacted: false, + coordinate: '@global/member-one', + status: 'RUNNING', + version: '1.0.0', + errors: [], + warnings: [], + }], + } + + render() + + expect(screen.getByText('suite.bundle.problem.memberExecutionFailed.title')).not.toBeNull() + expect(screen.getByText('suite.bundle.problem.memberExecutionFailed.description')).not.toBeNull() + expect(screen.getByText('suite.bundle.problem.affectedMembersLabel')).not.toBeNull() + expect(screen.getAllByText('@global/member-one')).toHaveLength(2) + expect(screen.getByRole('button', { name: 'suite.bundle.retryMemberPublish' })).not.toBeNull() + expect(screen.getByText('suite.bundle.memberStatus.ATTENTION')).not.toBeNull() + expect(screen.queryByText('suite.bundle.memberStatus.RUNNING')).toBeNull() + expect(screen.queryByText('MEMBER_EXECUTION_FAILED')).toBeNull() + expect(screen.queryByText('suite.bundle.nextStep.BLOCKED_RETRYABLE')).toBeNull() + }) + + it('uses a scan-specific retry action for a failed member scan', () => { + mocks.operation.data = { + operationId: 'operation-scan', + status: 'BLOCKED_RETRYABLE', + mode: 'CREATE', + targetCoordinate: '@global/care-suite', + targetVersion: '1.0.0', + failureCode: 'MEMBER_SCAN_FAILED', + updatedAt: '2026-09-14T04:00:00Z', + members: [], + } + + render() + + expect(screen.getByRole('button', { name: 'suite.bundle.retryScan' })).not.toBeNull() + expect(screen.queryByRole('button', { name: 'suite.bundle.retryMemberPublish' })).toBeNull() + }) + + it('keeps demo-only blocked rows readable without exposing retry or internal codes', () => { + mocks.operation.data = { + operationId: 'operation-demo', + status: 'BLOCKED_RETRYABLE', + mode: 'CREATE', + targetCoordinate: '@global/care-suite', + targetVersion: '1.0.0', + failureCode: 'MEMBER_EXECUTION_FAILED', + updatedAt: '2026-09-14T04:00:00Z', + members: [{ + position: 0, + redacted: false, + coordinate: '@global/demo-member', + status: 'RUNNING', + version: '1.0.0', + errors: ['演示数据:仅展示可重试阻塞状态,不具备真实执行计划,请勿点击重试', 'MEMBER_EXECUTION_FAILED'], + warnings: [], + }], + } + + render() + + expect(screen.getByText('suite.bundle.problem.memberExecutionFailed.title')).not.toBeNull() + expect(screen.getByText('suite.bundle.memberStatus.ATTENTION')).not.toBeNull() + expect(screen.queryByText('演示数据:仅展示可重试阻塞状态,不具备真实执行计划,请勿点击重试')).toBeNull() + expect(screen.queryByText('MEMBER_EXECUTION_FAILED')).toBeNull() + expect(screen.queryByRole('button', { name: 'suite.bundle.retryMemberPublish' })).toBeNull() + }) + + it('keeps update semantics when the original Suite version no longer exists', () => { + mocks.operation.data = { + operationId: 'operation-4', + status: 'REPREVIEW_REQUIRED', + mode: 'UPDATE', + targetCoordinate: '@team-a/care-suite', + targetVersion: '1.2.0', + baseVersion: null, + updatedAt: '2026-09-14T04:00:00Z', + members: [], + } + + render() + + fireEvent.click(screen.getByRole('button', { name: 'suite.bundle.startAgain' })) + expect(mocks.navigate).toHaveBeenCalledWith({ + to: '/dashboard/suites/team-a/care-suite/new-version', + search: { sourceVersion: undefined }, + }) + }) +}) diff --git a/web/src/features/suite/suite-bundle-operation-detail.tsx b/web/src/features/suite/suite-bundle-operation-detail.tsx new file mode 100644 index 00000000..a781d3a2 --- /dev/null +++ b/web/src/features/suite/suite-bundle-operation-detail.tsx @@ -0,0 +1,352 @@ +/* Hallmark · component: publishing task detail · genre: modern-minimal · tone: utilitarian · pre-emit critique: P5 H5 E5 S5 R5 V4 */ +import { useState } from 'react' +import { useNavigate } from '@tanstack/react-router' +import { AlertTriangle, Check, Circle, Clock3, RefreshCw, Square } from 'lucide-react' +import { useTranslation } from 'react-i18next' +import { ConfirmDialog } from '@/shared/components/confirm-dialog' +import { formatLocalDateTime } from '@/shared/lib/date-time' +import { toast } from '@/shared/lib/toast' +import { cn } from '@/shared/lib/utils' +import { + useCancelSuiteBundleOperation, + useRetrySuiteBundleOperation, + useSuiteBundleOperation, +} from '@/shared/hooks/use-suite-queries' +import { Button } from '@/shared/ui/button' +import { Card } from '@/shared/ui/card' +import { suiteBundleProblemKind } from './suite-bundle-problem' + +const TERMINAL_STATUSES = new Set(['CANCELLED', 'REPREVIEW_REQUIRED', 'SUITE_DRAFT_CREATED']) +const INTERNAL_FAILURE_CODE_PATTERN = /^[A-Z][A-Z0-9_]+$/ + +function isDemoOnlyMessage(message: string): boolean { + return message.includes('演示数据') || message.includes('不具备真实执行计划') || message.includes('请勿点击重试') +} + +function userVisibleMessages(messages: string[] | undefined): string[] { + return (messages ?? []).filter((message) => ( + !INTERNAL_FAILURE_CODE_PATTERN.test(message.trim()) && !isDemoOnlyMessage(message) + )) +} + +function splitCoordinate(coordinate?: string): { namespace: string; slug: string } | null { + const match = coordinate?.match(/^@([^/]+)\/(.+)$/) + return match ? { namespace: match[1], slug: match[2] } : null +} + +export function SuiteBundleOperationDetail({ operationId }: { operationId: string }) { + const { t, i18n } = useTranslation() + const navigate = useNavigate() + const operationQuery = useSuiteBundleOperation(operationId) + const cancelMutation = useCancelSuiteBundleOperation() + const retryMutation = useRetrySuiteBundleOperation() + const [cancelOpen, setCancelOpen] = useState(false) + const operation = operationQuery.data + const status = operation?.status + const draftCoordinate = splitCoordinate(operation?.targetCoordinate) + const draftMissing = status === 'SUITE_DRAFT_CREATED' + && (!operation?.resultSuiteId || !operation.resultSuiteVersionId) + const restartOperation = () => { + if (operation?.mode === 'UPDATE' && draftCoordinate) { + void navigate({ + to: `/dashboard/suites/${draftCoordinate.namespace}/${encodeURIComponent(draftCoordinate.slug)}/new-version`, + search: { sourceVersion: operation.baseVersion ?? undefined }, + }) + return + } + void navigate({ to: '/dashboard/suites/new' }) + } + const canCancel = Boolean(status && !TERMINAL_STATUSES.has(status)) + const memberStageComplete = status === 'SUITE_DRAFT_CREATED' + const memberStageActive = status === 'RUNNING' || status === 'WAITING_FOR_MEMBERS' + || status === 'BLOCKED_RETRYABLE' + const problemKind = draftMissing ? 'draftMissing' : suiteBundleProblemKind(status, operation?.failureCode) + const retryLabel = retryMutation.isPending + ? t('suite.bundle.retrying') + : problemKind === 'memberScanFailed' + ? t('suite.bundle.retryScan') + : t('suite.bundle.retryMemberPublish') + const affectedMembers = operation?.members?.filter((member) => { + const missingBoundVersion = member.status === 'COMPLETED' && (!member.skillId || !member.skillVersionId) + return missingBoundVersion + || member.status === 'BLOCKED_RETRYABLE' + || member.status === 'REPREVIEW_REQUIRED' + || (operation.failureCode === 'MEMBER_EXECUTION_FAILED' + && member.status !== 'COMPLETED' + && member.status !== 'CANCELLED') + }) ?? [] + const affectedMemberNames = affectedMembers.slice(0, 3).map((member) => ( + member.redacted ? t('suite.bundle.redactedMember') : member.coordinate + )) + const affectedMemberPositions = new Set(affectedMembers.map((member) => member.position)) + const demoOnlyOperation = (operation?.members ?? []).some((member) => ( + [...(member.errors ?? []), ...(member.warnings ?? [])].some(isDemoOnlyMessage) + )) + + return ( +
+ +
+
+

+ {operation?.targetCoordinate ?? t('suite.bundle.loadingOperation')} + {operation?.targetVersion ? `@${operation.targetVersion}` : ''} +

+ {!status ?

{t('suite.bundle.loadingOperation')}

: null} +
+ {status ? ( +
+ + {t(`suite.bundle.statusLabel.${status}`)} + + {status === 'BLOCKED_RETRYABLE' && !demoOnlyOperation ? ( + + ) : null} + {canCancel ? ( + + ) : null} + {status === 'REPREVIEW_REQUIRED' || status === 'CANCELLED' ? ( + + ) : null} + {status === 'SUITE_DRAFT_CREATED' && !draftMissing && draftCoordinate && operation?.targetVersion ? ( + + ) : null} + {draftMissing ? ( + + ) : null} +
+ ) : null} +
+ +
    + } + title={t('suite.bundle.lifecycle.preview')} + description={t('suite.bundle.lifecycle.previewDescription')} + state="complete" + /> + : } + title={t('suite.bundle.lifecycle.members')} + description={t('suite.bundle.lifecycle.membersDescription')} + state={memberStageComplete ? 'complete' : memberStageActive ? 'active' : 'inactive'} + /> + : } + title={t('suite.bundle.lifecycle.draft')} + description={t('suite.bundle.lifecycle.draftDescription')} + state={memberStageComplete ? 'complete' : 'inactive'} + /> +
+ +
+
+

{t('suite.bundle.operationId')}

+

{operationId}

+
+
+

{t('suite.bundle.lastUpdated')}

+

+ {operation?.updatedAt ? formatLocalDateTime(operation.updatedAt, i18n.language) : '—'} +

+
+
+
+ + {operationQuery.error ? ( + + {t('suite.bundle.operationLoadFailed')} + + + ) : null} + + {status === 'CANCELLED' ? ( +
+
+ ) : null} + + {problemKind ? ( +
+
+ ) : null} + + +
+

{t('suite.bundle.memberTitle')}

+

{t('suite.bundle.memberDescription')}

+
+
+ {(operation?.members?.length ?? 0) > 0 ? ( +
+ {t('suite.management.member')} + {t('suite.bundle.memberAction')} + {t('suite.version')} + {t('suite.status')} +
+ ) : null} + {(operation?.members ?? []).map((member) => { + const memberCoordinate = splitCoordinate(member.coordinate) + const canViewSkill = !member.redacted && memberCoordinate && member.skillId + const missingBoundVersion = member.status === 'COMPLETED' && (!member.skillId || !member.skillVersionId) + const needsAttention = affectedMemberPositions.has(member.position) || missingBoundVersion + const errors = userVisibleMessages(member.errors) + const warnings = userVisibleMessages(member.warnings) + return ( +
+
+

+ {member.redacted ? t('suite.bundle.redactedMember') : member.coordinate} +

+ {!member.redacted && member.packagePath ? ( +

+ {t('suite.bundle.memberDirectory', { path: member.packagePath })} +

+ ) : null} +
+ {!member.redacted ? ( +

+ {member.sourceType ? t(`suite.bundle.source.${member.sourceType}`) : null} + {member.relationship ? ` · ${t(`suite.bundle.relationship.${member.relationship}`)}` : null} + {member.publishAction ? ` · ${t(`suite.bundle.action.${member.publishAction}`)}` : null} +

+ ) : } +

+ {member.visibility ?? '—'}
v{member.version ?? '—'} +

+
+ + {needsAttention ? t('suite.bundle.memberStatus.ATTENTION') : t(`suite.bundle.memberStatus.${member.status}`)} + + {canViewSkill ? ( + + ) : !member.redacted ? ( +

{t('suite.bundle.memberSkillNotCreated')}

+ ) : null} +
+ {!member.redacted && (errors.length > 0 || warnings.length > 0) ? ( +
+ {errors.map((error) =>

{error}

)} + {warnings.map((warning) =>

{warning}

)} +
+ ) : null} +
+ ) + })} + {!operationQuery.isLoading && (operation?.members?.length ?? 0) === 0 ? ( +

{t('suite.bundle.noMembers')}

+ ) : null} +
+
+ + cancelMutation.mutate(operationId, { + onSuccess: () => setCancelOpen(false), + onError: (error) => toast.error(t('suite.bundle.cancelFailed'), error.message), + })} + /> +
+ ) +} + +function LifecycleStep({ icon, title, description, state }: { + icon: React.ReactNode + title: string + description: string + state: 'complete' | 'active' | 'inactive' +}) { + return ( +
  • + svg]:h-3.5 [&>svg]:w-3.5', + state === 'complete' && 'border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300', + state === 'active' && 'border-primary/30 bg-primary/10 text-primary', + )}> + {icon} + +
    +

    {title}

    +

    {description}

    +
    +
  • + ) +} diff --git a/web/src/features/suite/suite-bundle-problem.test.ts b/web/src/features/suite/suite-bundle-problem.test.ts new file mode 100644 index 00000000..b4111ad2 --- /dev/null +++ b/web/src/features/suite/suite-bundle-problem.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from 'vitest' +import { suiteBundleProblemKind } from './suite-bundle-problem' + +describe('suiteBundleProblemKind', () => { + it.each([ + ['BLOCKED_RETRYABLE', 'MEMBER_SCAN_FAILED', 'memberScanFailed'], + ['BLOCKED_RETRYABLE', 'MEMBER_EXECUTION_FAILED', 'memberExecutionFailed'], + ['BLOCKED_RETRYABLE', 'AUTHORIZATION_OR_NAMESPACE_BLOCKED', 'authorizationBlocked'], + ['REPREVIEW_REQUIRED', 'BUNDLE_PLAN_CHANGED', 'planChanged'], + ['BLOCKED_RETRYABLE', 'UNKNOWN_INTERNAL_CODE', 'blocked'], + ['REPREVIEW_REQUIRED', undefined, 'planChanged'], + ])('maps %s / %s to %s', (status, failureCode, expected) => { + expect(suiteBundleProblemKind(status, failureCode)).toBe(expected) + }) + + it.each(['RUNNING', 'WAITING_FOR_MEMBERS', 'SUITE_DRAFT_CREATED', 'CANCELLED']) ( + 'does not show a problem for %s', + (status) => expect(suiteBundleProblemKind(status, 'MEMBER_EXECUTION_FAILED')).toBeNull(), + ) +}) diff --git a/web/src/features/suite/suite-bundle-problem.ts b/web/src/features/suite/suite-bundle-problem.ts new file mode 100644 index 00000000..25754737 --- /dev/null +++ b/web/src/features/suite/suite-bundle-problem.ts @@ -0,0 +1,27 @@ +export type SuiteBundleProblemKind = + | 'memberScanFailed' + | 'memberExecutionFailed' + | 'authorizationBlocked' + | 'planChanged' + | 'draftMissing' + | 'blocked' + +export function suiteBundleProblemKind( + status: string | undefined, + failureCode: string | undefined, +): SuiteBundleProblemKind | null { + if (status !== 'BLOCKED_RETRYABLE' && status !== 'REPREVIEW_REQUIRED') return null + + switch (failureCode) { + case 'MEMBER_SCAN_FAILED': + return 'memberScanFailed' + case 'MEMBER_EXECUTION_FAILED': + return 'memberExecutionFailed' + case 'AUTHORIZATION_OR_NAMESPACE_BLOCKED': + return 'authorizationBlocked' + case 'BUNDLE_PLAN_CHANGED': + return 'planChanged' + default: + return status === 'REPREVIEW_REQUIRED' ? 'planChanged' : 'blocked' + } +} diff --git a/web/src/features/suite/suite-labels.ts b/web/src/features/suite/suite-labels.ts index b4ec774b..26d42c5d 100644 --- a/web/src/features/suite/suite-labels.ts +++ b/web/src/features/suite/suite-labels.ts @@ -1,11 +1,11 @@ import type { TFunction } from 'i18next' const STATUS_KEYS: Record = { - DRAFT: 'skillDetail.versionStatusDraft', - PENDING_REVIEW: 'skillDetail.versionStatusPendingReview', - PUBLISHED: 'skillDetail.versionStatusPublished', - REJECTED: 'skillDetail.versionStatusRejected', - YANKED: 'skillDetail.versionStatusYanked', + DRAFT: 'suite.statusLabel.DRAFT', + PENDING_REVIEW: 'suite.statusLabel.PENDING_REVIEW', + PUBLISHED: 'suite.statusLabel.PUBLISHED', + REJECTED: 'suite.statusLabel.REJECTED', + YANKED: 'suite.statusLabel.YANKED', } const VISIBILITY_KEYS: Record = { diff --git a/web/src/features/suite/suite-management-actions.test.tsx b/web/src/features/suite/suite-management-actions.test.tsx index ad2344b2..b9433332 100644 --- a/web/src/features/suite/suite-management-actions.test.tsx +++ b/web/src/features/suite/suite-management-actions.test.tsx @@ -51,6 +51,8 @@ function suite(overrides: Partial = {}): SkillSuite { namespace: 'global', slug: 'starter', displayName: 'Starter suite', + createdBy: 'owner-1', + createdAt: '2026-09-15T10:00:00Z', version: '1.0.0', status: 'PUBLISHED', visibility: 'PUBLIC', diff --git a/web/src/features/suite/suite-management-actions.tsx b/web/src/features/suite/suite-management-actions.tsx index 6ec97d13..00bf4b0e 100644 --- a/web/src/features/suite/suite-management-actions.tsx +++ b/web/src/features/suite/suite-management-actions.tsx @@ -25,7 +25,7 @@ import { toast } from '@/shared/lib/toast' type ConfirmAction = 'hide' | 'restore' | 'archive' | 'unarchive' | 'delete' -export function SuiteManagementActions({ suite }: { suite: SkillSuite }) { +export function SuiteManagementActions({ suite, compact = false }: { suite: SkillSuite; compact?: boolean }) { const { t } = useTranslation() const navigate = useNavigate() const [confirmAction, setConfirmAction] = useState(null) @@ -107,7 +107,7 @@ export function SuiteManagementActions({ suite }: { suite: SkillSuite }) { 'UNARCHIVE', 'DELETE', ] - if (!managementActions.some((action) => allowed.has(action))) return null + if (!managementActions.some((action) => allowed.has(action) && !(compact && action === 'CREATE_VERSION'))) return null const confirmTitle = confirmAction ? t(`suite.${confirmAction}ConfirmTitle`) : '' const confirmDescription = confirmAction @@ -115,29 +115,31 @@ export function SuiteManagementActions({ suite }: { suite: SkillSuite }) { : '' return ( - -

    {t('suite.managementTitle')}

    -

    {t('suite.managementDescription')}

    -
    + +

    {t('suite.managementTitle')}

    +

    {t('suite.managementDescription')}

    +
    {allowed.has('REOPEN') ? ( - ) : null} - {allowed.has('CREATE_VERSION') ? ( - + {allowed.has('CREATE_VERSION') && !compact ? ( + ) : null} {allowed.has('YANK') ? ( - + ) : null} {allowed.has('HIDE') || allowed.has('RESTORE') ? ( - ) : null} {allowed.has('ARCHIVE') || allowed.has('UNARCHIVE') ? ( + ) : null} +
    + +
    + {t('suite.management.member')} + {t('suite.pinnedVersionColumn')} + {t('suite.memberPurpose')} + {t('suite.availability')} + {t('suite.memberAction')} +
    + +
    + {suite.members.map((member) => { + const coordinate = `@${member.namespace}/${member.slug}` + const memberName = member.displayName || coordinate + const navigable = Boolean( + member.browsable && !member.blockingReason && member.skillId && member.skillVersionId, + ) + const identity = ( +
    +
    + {memberName} + {member.entry ? ( + + {t('suite.entrySkill')} + + ) : null} +
    +

    {coordinate}

    + {member.summary ? ( +

    {member.summary}

    + ) : null} +
    + ) + + return ( +
    + {navigable ? ( + + {identity} + + ) : identity} + v{member.version} +

    + {member.summary || t(member.entry ? 'suite.entryMemberPurpose' : 'suite.memberPurposeFallback')} +

    + + {member.blockingReason + ? +
    + {navigable ? ( + + {t('suite.viewPinnedVersion')} +
    +
    + ) + })} +
    + +
    +
    + + ) +} diff --git a/web/src/features/suite/suite-overview.test.ts b/web/src/features/suite/suite-overview.test.ts new file mode 100644 index 00000000..7e69b6ae --- /dev/null +++ b/web/src/features/suite/suite-overview.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from 'vitest' +import { withoutDuplicateSuiteTitle } from './suite-overview' + +describe('withoutDuplicateSuiteTitle', () => { + it('removes a leading H1 that repeats the Suite name', () => { + expect(withoutDuplicateSuiteTitle('# Care Workflow\n\nUse the entry skill.', 'Care Workflow')) + .toBe('Use the entry skill.') + }) + + it('keeps a different heading and headings that are not first', () => { + expect(withoutDuplicateSuiteTitle('# Usage\n\nInstructions', 'Care Workflow')) + .toBe('# Usage\n\nInstructions') + expect(withoutDuplicateSuiteTitle('Introduction\n\n# Care Workflow', 'Care Workflow')) + .toBe('Introduction\n\n# Care Workflow') + }) +}) diff --git a/web/src/features/suite/suite-overview.ts b/web/src/features/suite/suite-overview.ts new file mode 100644 index 00000000..c04a7f7a --- /dev/null +++ b/web/src/features/suite/suite-overview.ts @@ -0,0 +1,13 @@ +/** Removes only a leading Markdown H1 that duplicates the page-level Suite title. */ +export function withoutDuplicateSuiteTitle(content: string, displayName: string): string { + const lines = content.split(/\r?\n/) + const firstContentLine = lines.findIndex(line => line.trim().length > 0) + if (firstContentLine < 0) return content + + const heading = lines[firstContentLine].trim().match(/^#\s+(.+?)\s*#*$/) + if (!heading || heading[1].trim() !== displayName.trim()) return content + + lines.splice(firstContentLine, 1) + while (lines[firstContentLine]?.trim() === '') lines.splice(firstContentLine, 1) + return lines.join('\n') +} diff --git a/web/src/features/suite/suite-version-ledger.tsx b/web/src/features/suite/suite-version-ledger.tsx new file mode 100644 index 00000000..33c1495e --- /dev/null +++ b/web/src/features/suite/suite-version-ledger.tsx @@ -0,0 +1,249 @@ +import { useState } from 'react' +import { ChevronDown, ChevronRight } from 'lucide-react' +import { Link } from '@tanstack/react-router' +import { useTranslation } from 'react-i18next' +import type { SkillSuite, SkillSuiteMember, SkillSuiteVersion } from '@/api/types' +import { suiteStatusLabel } from '@/features/suite/suite-labels' +import { useSuiteDetail } from '@/shared/hooks/use-suite-queries' +import { formatLocalDateTime } from '@/shared/lib/date-time' +import { cn } from '@/shared/lib/utils' +import { Button } from '@/shared/ui/button' + +interface SuiteVersionLedgerProps { + namespace: string + slug: string + versions: SkillSuiteVersion[] + currentSuite: SkillSuite + returnTo: string + management?: boolean + onSelectVersion: (version: string) => void + onEditVersion?: (suite: SkillSuite) => void + onSubmitVersion?: (suite: SkillSuite) => void +} + +type MemberChange = { + member: SkillSuiteMember + kind: 'ADDED' | 'UPDATED' | 'UNCHANGED' | 'REMOVED' + previousVersion?: string +} + +function memberKey(member: SkillSuiteMember) { + return `${member.namespace}/${member.slug}` +} + +function versionStatusTone(status: string) { + switch (status) { + case 'PUBLISHED': + return 'border-emerald-500/20 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300' + case 'YANKED': + case 'PENDING_REVIEW': + case 'SCANNING': + return 'border-amber-500/20 bg-amber-500/10 text-amber-700 dark:text-amber-300' + case 'REJECTED': + return 'border-red-500/20 bg-red-500/10 text-red-700 dark:text-red-300' + default: + return 'border-blue-500/20 bg-blue-500/10 text-blue-700 dark:text-blue-300' + } +} + +function compareMembers(current: SkillSuiteMember[], previous: SkillSuiteMember[]): MemberChange[] { + const previousByKey = new Map(previous.map(member => [memberKey(member), member])) + const currentKeys = new Set(current.map(memberKey)) + const changes: MemberChange[] = current.map((member) => { + const prior = previousByKey.get(memberKey(member)) + if (!prior) return { member, kind: 'ADDED' as const } + return prior.version === member.version + ? { member, kind: 'UNCHANGED' as const } + : { member, kind: 'UPDATED' as const, previousVersion: prior.version } + }) + for (const member of previous) { + if (!currentKeys.has(memberKey(member))) changes.push({ member, kind: 'REMOVED' }) + } + return changes +} + +export function SuiteVersionLedger({ + namespace, + slug, + versions, + currentSuite, + returnTo, + management = false, + onSelectVersion, + onEditVersion, + onSubmitVersion, +}: SuiteVersionLedgerProps) { + const { t, i18n } = useTranslation() + const [expandedVersion, setExpandedVersion] = useState(currentSuite.version) + + return ( +
    +
    + {t('suite.version')} + {t('suite.lifecycleStatus')} + {t('suite.createdBy')} + {t('suite.updatedAt')} + {t('suite.memberAction')} +
    +
    + {versions.map((version, index) => { + const expanded = expandedVersion === version.version + const previousVersion = versions[index + 1]?.version + return ( +
    +
    + + + {suiteStatusLabel(t, version.status)} + + + {version.createdByName || version.createdBy} + + + {formatLocalDateTime(version.publishedAt || version.createdAt, i18n.language)} + + +
    + {expanded ? ( + + ) : null} +
    + ) + })} +
    +
    + ) +} + +function ExpandedVersion({ + namespace, + slug, + version, + previousVersion, + currentSuite, + returnTo, + management, + onEditVersion, + onSubmitVersion, +}: { + namespace: string + slug: string + version: SkillSuiteVersion + previousVersion?: string + currentSuite: SkillSuite + returnTo: string + management: boolean + onEditVersion?: (suite: SkillSuite) => void + onSubmitVersion?: (suite: SkillSuite) => void +}) { + const { t } = useTranslation() + const isCurrent = currentSuite.version === version.version + const { data: loadedSuite, isLoading } = useSuiteDetail(namespace, slug, version.version, !isCurrent) + const detail = isCurrent ? currentSuite : loadedSuite + const { data: previous } = useSuiteDetail(namespace, slug, previousVersion, Boolean(previousVersion && detail)) + const changes = detail ? compareMembers(detail.members, previous?.members ?? []) : [] + + return ( +
    + {isLoading || !detail ? ( +
    + ) : ( +
    +
    +

    {t('suite.changelog')}

    +

    + {version.changelog || t('suite.noChangelog')} +

    + {management ? ( +
    + {detail.allowedActions.includes('EDIT') && onEditVersion ? ( + + ) : null} + {(detail.allowedActions.includes('SUBMIT') || detail.allowedActions.includes('PUBLISH_PRIVATE')) + && onSubmitVersion ? ( + + ) : null} +
    + ) : null} +
    +
    +

    + {t('suite.memberInformation', { count: detail.members.length })} +

    +
    +
    + {t('suite.management.member')} + {t('suite.pinnedVersionColumn')} + {t('suite.management.role')} + {t('suite.memberChange')} +
    +
    + {changes.map(({ member, kind, previousVersion: prior }) => { + const navigable = Boolean(member.browsable && !member.blockingReason && member.skillId && member.skillVersionId) + return ( +
    + {navigable ? ( + + @{member.namespace}/{member.slug} + + ) : @{member.namespace}/{member.slug}} + v{member.version} + {member.entry ? t('suite.entrySkill') : t('suite.management.memberRole')} + + {t(`suite.memberChangeKinds.${kind}`, { version: prior })} + +
    + ) + })} +
    +
    +
    +
    + )} +
    + ) +} diff --git a/web/src/features/suite/suite-workspace-header.tsx b/web/src/features/suite/suite-workspace-header.tsx new file mode 100644 index 00000000..6a85be02 --- /dev/null +++ b/web/src/features/suite/suite-workspace-header.tsx @@ -0,0 +1,47 @@ +import { ArrowLeft } from 'lucide-react' +import type { ReactNode } from 'react' +import { Button } from '@/shared/ui/button' + +interface SuiteWorkspaceHeaderProps { + title: string + description?: string + eyebrow?: string + backLabel?: string + onBack?: () => void + actions?: ReactNode +} + +/** Compact, consistent page framing for the dashboard Suite workflow. */ +export function SuiteWorkspaceHeader({ + title, + description, + eyebrow, + backLabel, + onBack, + actions, +}: SuiteWorkspaceHeaderProps) { + return ( +
    + {backLabel && onBack ? ( + + ) : null} +
    +
    + {eyebrow ?

    {eyebrow}

    : null} +

    {title}

    + {description ?

    {description}

    : null} +
    + {actions ?
    {actions}
    : null} +
    +
    + ) +} diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index dd1facca..3e4333f4 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -442,6 +442,7 @@ "subtitle": "Manage your skills, account, and preferences", "overview": "Overview", "backToDashboard": "Back to Dashboard", + "navigation": "Dashboard navigation", "userInfo": "Account Information", "userInfoDesc": "Basic account details and platform roles", "loginVia": "Logged in via {{provider}}", @@ -652,10 +653,14 @@ }, "reviewProgress": { "title": "My Review Progress", - "subtitle": "Track the current decision, reviewer feedback, and every resubmission of your skills", - "searchLabel": "Search skills or namespaces", - "searchPlaceholder": "Search skill or namespace", + "subtitle": "Track the current decision, reviewer feedback, and every resubmission of your skills and suites", + "searchLabel": "Search skills, suites, or namespaces", + "searchPlaceholder": "Search skill, suite, or namespace", "searchAction": "Search", + "typeFilter": "Filter by resource type", + "typeAll": "All types", + "typeSkill": "Skills", + "typeSuite": "Suites", "statusFilter": "Filter by review status", "statusAll": "All statuses", "statusPending": "In review", @@ -675,7 +680,7 @@ "error": "Review progress could not be loaded. Try again later.", "historyError": "Submission history could not be loaded. Try again later.", "emptyTitle": "No review history yet", - "emptyDescription": "Reviews for public or team skills will appear here after submission." + "emptyDescription": "Reviews for public or team skills and suites will appear here after submission." }, "reviews": { "title": "Review Center", @@ -1031,6 +1036,16 @@ "suiteEntryTitle": "Used as a suite entry", "suiteEntryDescription": "This skill is the entry point for the following skill suites. Install a suite below to use all of its included skills.", "suiteEntryMemberCount": "View complete suite ({{count}} skills)", + "suiteMembershipTitle": "Included in suites", + "suiteMembershipDescription": "Suites whose current version still includes this skill and that you can access.", + "suiteMembershipEmpty": "This skill is not currently included in a suite you can access.", + "suiteMembershipEntryRole": "Entry skill", + "suiteMembershipMemberRole": "Member", + "suiteMembershipSiblingTitle": "Other skills in this suite", + "suiteMembershipRestricted": "{{count}} restricted or deleted members", + "suiteMembershipOmitted": "{{count}} more visible members not expanded", + "suiteMembershipMore": "Showing {{shown}} of {{total}} suite memberships. Remaining results are available through the paginated API.", + "suiteMembershipUnavailable": "Currently unavailable", "installMethodClawhub": "ClawHub CLI", "installMethodSkillhub": "SkillHub CLI", "installCommandUnsafeVersion": "This version cannot be represented safely in a cross-platform command. Ask the publisher to correct it.", @@ -1906,6 +1921,43 @@ "restoreErrorTitle": "Failed to restore namespace" }, "suite": { + "workspace": { + "description": "Manage suites, versions and creation progress.", + "searchLabel": "Search my suites", + "searchPlaceholder": "Search name, namespace or slug", + "clearSearch": "Clear search", + "stateFilter": "Suite status", + "attention": "Needs attention", + "filter": { + "ALL": "All statuses", + "ATTENTION": "Needs attention", + "DRAFT": "Editing", + "PENDING_REVIEW": "In review", + "PUBLISHED": "Published", + "OTHER": "Other statuses" + }, + "columns": { + "suite": "Suite", + "version": "Target version", + "state": "Current status", + "updated": "Updated", + "action": "Action" + }, + "reviewProgress": "Review progress", + "resourceSuite": "Suite", + "resourceSkill": "Skill", + "total": "{{count}} total, {{size}} per page", + "noMatches": "No matching suites", + "error": "Unable to load suites", + "reload": "Reload" + }, + "statusLabel": { + "DRAFT": "Editing", + "PENDING_REVIEW": "In review", + "PUBLISHED": "Published", + "REJECTED": "Not approved", + "YANKED": "Taken down" + }, "listTitle": "Skill Suites", "listDescription": "Install a version-pinned collection of skills in one operation.", "create": "Create suite", @@ -1913,6 +1965,36 @@ "emptyTitle": "No suites available", "emptyDescription": "Create the first suite to package a reusable skill workflow.", "noSummary": "No description", + "assignedLabels": "Suite labels", + "labelFilterTitle": "Filter by Suite label", + "allLabels": "All labels", + "loadingLabels": "Loading labels…", + "labelsSectionTitle": "Suite label management", + "labelsSectionDescription": "Labels apply only to this Suite and do not change member Skills.", + "labelsSectionDescriptionSuperAdmin": "Manage regular or privileged labels; they apply only to this Suite.", + "currentLabelsTitle": "Current labels", + "availableLabelsTitle": "Available labels", + "loadingAvailableLabels": "Loading available labels…", + "noLabelsAssigned": "No labels are assigned to this Suite.", + "noAvailableLabels": "No more labels can be attached right now.", + "addLabel": "Add {{label}}", + "removeLabel": "Remove", + "labelRestrictedHint": "Only a super administrator can remove this", + "labelAttachSuccessTitle": "Suite label attached", + "labelAttachSuccessDescription": "Suite discovery now uses the directly associated label.", + "labelAttachErrorTitle": "Could not attach Suite label", + "labelDetachSuccessTitle": "Suite label removed", + "labelDetachSuccessDescription": "Member Skills and their labels were not changed.", + "labelDetachErrorTitle": "Could not remove Suite label", + "labelActionFallbackError": "Please try again.", + "processing": "Processing…", + "requiredForPublish": "required to publish", + "publishReadinessTitle": "Publishing readiness", + "publishReadinessDescription": "A draft may be incomplete, but summary and overview are required before review or direct publishing.", + "summaryComplete": "Summary complete", + "summaryIncomplete": "Summary needs content", + "overviewComplete": "Overview complete", + "overviewIncomplete": "Overview needs content", "unavailable": "Unavailable", "notFound": "The suite does not exist or your account cannot access it.", "published": "Suite published", @@ -1944,7 +2026,49 @@ "startWithEntryDescription": "Installing this suite gives you {{count}} version-pinned skills. Open the entry skill first to understand the primary way to use them together.", "viewEntrySkill": "View entry skill", "versionHistory": "Version history", - "validationRequired": "Complete the required fields and select at least one skill", + "installSuite": "Install suite", + "currentVersion": "Current version", + "membersTabShort": "Members {{count}}", + "versionsTab": "Versions {{count}}", + "overviewSupport": "Current Suite version details", + "entrySnapshotDescription": "The entry skill is pinned to the exact version referenced by this Suite.", + "thisVersionContains": "This version contains", + "memberCount": "Skills", + "membersSnapshotDescription": "This Suite pins the following exact skill versions and never upgrades them automatically.", + "pinnedVersionColumn": "Pinned version", + "memberPurpose": "Purpose in Suite", + "availability": "Availability", + "memberAction": "Action", + "entryMemberPurpose": "The first skill to use after installing the Suite.", + "memberPurposeFallback": "Provides an independent capability in the Suite workflow.", + "availableForSuite": "Available for Suite", + "viewPinnedVersion": "View pinned version", + "memberUnavailable": "Unavailable to view", + "availableMemberCount": "{{available}}/{{total}} member versions available", + "memberSnapshotHint": "These references are pinned in the current Suite version.", + "lifecycleStatus": "Lifecycle status", + "createdBy": "Created by", + "createdAt": "Created at", + "updatedAt": "Updated at", + "noChangelog": "No release notes", + "viewVersion": "View version", + "versionDetails": "Version details", + "memberSnapshot": "Member snapshot ({{count}} skills)", + "memberInformation": "Members ({{count}} skills)", + "memberChange": "Member change", + "loadingVersionDetails": "Loading version details", + "memberChangeKinds": { + "ADDED": "Added", + "UPDATED": "Upgraded from v{{version}}", + "UNCHANGED": "Unchanged", + "REMOVED": "Removed" + }, + "validationRequired": "Complete the required fields", + "namespaceRequired": "Select a namespace", + "slugRequired": "Enter a suite slug", + "nameRequired": "Enter a suite name", + "versionRequired": "Enter a new version number", + "membersRequired": "Select at least one skill", "draftUpdated": "Suite draft updated", "draftCreated": "Suite draft created", "saveFailed": "Save failed", @@ -1955,6 +2079,193 @@ "sourceLoadFailed": "Unable to load the source suite version. Return to its detail page and try again.", "editorAccessDenied": "Your account cannot edit this Suite version.", "editorDescription": "Select exact published skill versions. Published suites never drift to newer versions automatically.", + "authoringMode": "Suite authoring method", + "manualAuthoring": "Compose manually", + "localImport": "Import locally", + "tabs": { + "suites": "Skill Suites", + "publishing": "Publishing tasks" + }, + "bundle": { + "uploadTitle": "Upload a Suite Bundle", + "uploadDescription": "Choose one ZIP or a root folder containing SUITE.yaml and multiple Skill folders. The browser creates and uploads one ZIP only.", + "processing": "Packaging and validating the Bundle", + "previewTitle": "Member change preview", + "previewFailed": "Bundle preview failed", + "packageFailed": "The selected folder could not be packaged", + "confirmFailed": "Bundle confirmation failed", + "confirmable": "Ready to confirm", + "notConfirmable": "Cannot confirm", + "targetMismatch": "The Bundle mode or target Suite does not match this page. Select the correct Bundle.", + "previewExpired": "This preview expired. Select and upload the Bundle again.", + "noChanges": "All members and pinned versions are unchanged.", + "removedMember": "Remove {{coordinate}}@{{version}}", + "removedEntryMember": "current Entry Skill", + "memberDirectory": "Member directory: {{path}}", + "acceptMemberWarnings": "I reviewed and accept {{count}} warnings for {{coordinate}}", + "acceptRemovals": "I reviewed and accept {{removalCount}} member removals", + "confirm": "Confirm and start publishing", + "chooseAgain": "Choose again", + "progressTitle": "Bundle publishing progress", + "activeTitle": "Suites being published", + "activeDescription": "These Bundles are waiting for member Skills before their Suite drafts can be created.", + "memberProgress": "{{completed}} of {{total}} members completed; {{waiting}} waiting for scan or review", + "continueOperation": "View publishing progress", + "loadingOperation": "Loading operation status", + "operationLoadFailed": "Unable to load the Bundle operation. Check your connection and try again.", + "operationTargetMismatch": "The restored Bundle operation does not match this Suite. Its recovery entry was cleared.", + "reloadOperation": "Reload", + "redactedMember": "Member hidden by access policy", + "viewMemberSkill": "View member Skill", + "memberSkillNotCreated": "Skill not created yet", + "forgetOperation": "Stop restoring this operation", + "importAnother": "Import another Bundle", + "openDraft": "Open Suite draft", + "retryMemberPublish": "Continue", + "retryScan": "Check again", + "retrying": "Retrying…", + "retryFailed": "Failed to retry the Bundle operation", + "cancelOperation": "Stop creating Suite", + "cancelFailed": "Failed to cancel the Bundle operation", + "openingTask": "Opening the publishing task…", + "taskListTitle": "Suite publishing tasks", + "taskListDescription": "Handle tasks that need your input first; SkillHub advances the rest automatically.", + "taskDetailTitle": "Suite publishing task", + "taskDetailDescription": "Track member publishing and Suite draft creation outside the authoring page.", + "backToTasks": "Back to publishing tasks", + "backToSuite": "Back to Suite details", + "taskEmpty": "No publishing tasks", + "taskEmptyDescription": "Publishing progress appears here after you import and confirm a Suite Bundle.", + "groups": { + "attention": "Handle these first", + "attentionDescription": "Blocked or changed plans need your input before they can continue", + "attentionEmpty": "No tasks currently need attention.", + "progress": "Processing automatically", + "progressDescription": "No action needed while member Skills and reviews continue", + "progressEmpty": "No tasks are currently in progress.", + "recent": "Finished", + "recentDescription": "Drafts created or tasks stopped; open a record when needed", + "recentEmpty": "No recently finished tasks." + }, + "taskCount": "{{count}} publishing tasks", + "priorityHint": "Tasks that need action are listed first", + "taskAction": { + "RUNNING": "View progress", + "WAITING_FOR_MEMBERS": "View progress", + "BLOCKED_RETRYABLE": "Continue", + "REPREVIEW_REQUIRED": "Confirm again", + "SUITE_DRAFT_CREATED": "Open draft", + "CANCELLED": "View record" + }, + "taskHint": { + "RUNNING": "Publishing member Skills", + "WAITING_FOR_MEMBERS": "Waiting for member reviews", + "BLOCKED_RETRYABLE": "Open and retry unfinished members", + "REPREVIEW_REQUIRED": "Preview and confirm the latest diff", + "SUITE_DRAFT_CREATED": "Continue editing or submit for review", + "CANCELLED": "Stopped; record remains available" + }, + "statusLabel": { + "RUNNING": "Processing", + "WAITING_FOR_MEMBERS": "Waiting for members", + "BLOCKED_RETRYABLE": "Members incomplete", + "REPREVIEW_REQUIRED": "Setup changed", + "SUITE_DRAFT_CREATED": "Draft generated", + "CANCELLED": "Stopped" + }, + "problem": { + "memberScanFailed": { + "title": "A member security scan failed", + "description": "The existing member version is preserved. Once scanning is available, retry that member without uploading the Bundle again." + }, + "memberExecutionFailed": { + "title": "A member failed to publish", + "description": "Completed members will not run again. Resolve the unfinished member below, then continue." + }, + "authorizationBlocked": { + "title": "Permissions or namespace access changed", + "description": "The current account may no longer be allowed to publish a member Skill or manage the Suite, or a related namespace may be frozen. Restore access or unfreeze it, then retry." + }, + "planChanged": { + "title": "Members or access changed", + "description": "A member version, permission, or Suite state no longer matches the confirmed plan. Import the Bundle again and confirm the latest changes." + }, + "draftMissing": { + "title": "Suite draft was not generated", + "description": "Member Skills did not produce versions the Suite can reference. Fix the member packages, then import the Bundle again." + }, + "blocked": { + "title": "Member Skill publishing cannot continue yet", + "description": "The unfinished items are marked in the member list below. Retry processes only those members; if it still fails, give the task ID to an administrator." + }, + "affectedMembers": "Members to process: {{members}}", + "affectedMembersLabel": "Needs attention", + "affectedMembersMore": "{{count}} more" + }, + "lifecycleTitle": "Suite publishing stages", + "lifecycle": { + "preview": "Confirm changes", + "previewDescription": "The upload and member changes are locked", + "members": "Publish members", + "membersDescription": "Create or reuse Skill versions and wait for review", + "draft": "Create Suite draft", + "draftDescription": "Runs only after every member is ready" + }, + "operationId": "Task ID", + "lastUpdated": "Last updated", + "memberTitle": "Member Skills", + "memberDescription": "Members publish as independent Skills; focus on members marked incomplete.", + "memberAction": "Member action", + "memberStatus": { + "PLANNED": "Waiting to start", + "RUNNING": "Creating version", + "WAITING_FOR_MEMBER": "Publishing", + "COMPLETED": "Available to Suite", + "BLOCKED_RETRYABLE": "Action needed", + "REPREVIEW_REQUIRED": "Confirmation needed", + "CANCELLED": "Further work stopped", + "ATTENTION": "Incomplete" + }, + "noMembers": "No member progress yet.", + "cancelConfirmTitle": "Stop creating this Suite?", + "cancelConfirmDescription": "SkillHub will stop waiting for members and will not create the Suite draft. Skill versions and review tasks already created remain available and are not withdrawn.", + "cancelConfirmAction": "Stop creating Suite", + "cancelledTitle": "Suite creation stopped", + "cancelledDescription": "This task remains in history. Existing Skill versions and review tasks are not withdrawn or deleted.", + "startAgain": "Import Bundle again", + "relationship": { + "ADDED": "Add member", + "UPDATED": "Change pinned version", + "UNCHANGED": "Unchanged", + "REMOVED": "Remove member" + }, + "action": { + "CREATE_SKILL": "Create Skill", + "CREATE_VERSION": "Create version", + "REUSE_VERSION": "Reuse existing version", + "REFERENCE_VERSION": "Reference exact version", + "NONE": "No publication write" + }, + "source": { + "PACKAGE": "Included member package", + "REFERENCE": "Existing version reference" + }, + "path": { + "noWrite": "Reuse or reference an existing version without a Skill publishing write.", + "private": "Create a PRIVATE Skill version and keep it private without automatic publication.", + "publish": "Create a Skill version and enter the existing scan, publish, or review flow. Follow progress for the final state." + }, + "errors": { + "empty-folder": "The selected folder or ZIP is empty.", + "mixed-folder-roots": "Select only one Bundle root folder.", + "missing-suite-manifest": "The root folder does not contain SUITE.yaml.", + "duplicate-suite-manifest": "A Bundle can contain only one SUITE.yaml.", + "too-many-files": "The Bundle contains too many files.", + "file-too-large": "A Bundle file exceeds 10 MB.", + "bundle-too-large": "The Bundle exceeds 100 MB in total.", + "invalid-zip": "Choose a .zip file." + } + }, "namespace": "Namespace", "slug": "Slug", "selectNamespace": "Select a namespace", @@ -1962,15 +2273,43 @@ "namePlaceholder": "Marketing event workflow", "overview": "Overview (Markdown)", "overviewHint": "Explain the use case, member sequence, inputs, outputs, and important constraints.", + "overviewPromptScenario": "Use case: what work is this collection designed for?", + "overviewPromptPreparation": "Preparation: what information, files, or environment are required?", + "overviewPromptSequence": "Member roles: what does each Skill do, and in what order?", + "overviewPromptInputsOutputs": "Inputs and outputs: what does the user provide and receive?", + "overviewPromptBoundaries": "Boundaries: what permissions, risks, or unsuitable cases matter?", + "entryGuideTitle": "Pinned Entry Skill instructions", + "entryGuideSource": "Content comes from {{coordinate}}@{{version}} SKILL.md and will not switch to a newer version.", + "entryGuideLoading": "Loading pinned Entry Skill instructions", + "entryGuideUnavailable": "Your account cannot read this pinned Entry Skill version.", "visibilityPublic": "Public", "visibilityNamespace": "Namespace only", "visibilityPrivate": "Private", "summary": "Description", + "summaryLabel": "Summary", "changelog": "Release notes", "selectSkills": "Select skills", "searchSkills": "Search eligible skills", + "memberSearchDescription": "Choose a search scope first, then add members by keyword or filters.", + "memberSearchScope": "Member skill search scope", + "scopeMarket": "Skill marketplace", + "scopeNamespace": "Current namespace", + "scopeCoordinate": "Exact coordinate", + "searchOnSubmitHint": "Search runs only after confirmation; all skills are not loaded by default", + "memberSearchEmptyTitle": "Choose a scope to search", + "memberSearchEmptyDescription": "Enter a keyword, choose visibility, or use an exact coordinate, then search to avoid loading every skill.", + "search": "Search", + "memberSkillColumn": "Skill", + "memberDescriptionColumn": "Description", + "memberVersionColumn": "Version", + "memberActionColumn": "Action", + "recommendedCandidate": "Recommended", + "addMember": "Add", "noCandidates": "No published versions satisfy the selected visibility.", "selectedMembers": "Suite members ({{count}}/100)", + "selectedMembersDescription": "Order members by use and choose one entry skill.", + "expandMembers": "Expand member settings", + "collapseMembers": "Collapse member settings", "setEntry": "Set as entry skill", "entryRequired": "Select an entry skill", "confirmVersionUpdateTitle": "Confirm pinned version change", @@ -1999,6 +2338,47 @@ "resourceTypeSkill": "Skill", "managementTitle": "Suite management", "managementDescription": "Manage versions, publication state, and the suite container. Member skills are never changed.", + "management": { + "backToSuites": "Back to my suites", + "yankedTitle": "v{{version}} is yanked", + "yankedDescription": "New users cannot install this version. Member skills are unaffected.", + "degradedTitle": "v{{version}} cannot be installed", + "availableTitle": "v{{version}} is installable", + "availableDescription": "This version and all member skills are available.", + "tabs": { + "overview": "Suite information", + "members": "Members {{count}}", + "versions": "Versions {{count}}", + "publishing": "Publishing history {{count}}" + }, + "previewMarket": "Preview marketplace page", + "draftDescription": "Member versions are pinned. Complete the publishing details to submit for review.", + "reviewDescription": "This version is under review. No additional submission is needed.", + "memberCountValue": "{{count}} members", + "membersSnapshotDescription": "This version pins the following skills. Editing the Suite does not modify member skills.", + "editMembers": "Edit members", + "memberLifecycleHint": "Member skills have independent lifecycles; access or availability changes can block publishing.", + "memberVersions": "Member versions", + "publishChecklist": "Publishing checklist", + "publishChecklistDescription": "Complete these items before submitting this version.", + "ready": "Complete", + "incomplete": "Incomplete", + "marketContent": "Marketplace content", + "marketContentDescription": "This is a preview. The full content appears on the marketplace page.", + "editContent": "Edit content", + "recentProgress": "Recent progress", + "basicInfo": "Basic information", + "memberCount": "Members", + "membersTitle": "Member skills ({{count}})", + "member": "Skill", + "role": "Role", + "memberRole": "Member skill", + "normal": "Available", + "recentPublishing": "Recent publishing history", + "viewAll": "View all", + "openPublishingHint": "Open publishing history to review task status", + "publishingDescription": "Review this Suite's Bundle publishing tasks and member progress." + }, "reopenDraft": "Reopen and edit draft", "reopened": "Suite version reopened as a draft", "createVersion": "Create new version", diff --git a/web/src/i18n/locales/ru.json b/web/src/i18n/locales/ru.json index c7a1c8c9..f8b28c2f 100644 --- a/web/src/i18n/locales/ru.json +++ b/web/src/i18n/locales/ru.json @@ -442,6 +442,7 @@ "subtitle": "Управляйте скиллами, аккаунтом и настройками", "overview": "Обзор", "backToDashboard": "Назад к панели", + "navigation": "Навигация панели", "userInfo": "Сведения об аккаунте", "userInfoDesc": "Основные данные аккаунта и роли на платформе", "loginVia": "Вход через {{provider}}", @@ -652,10 +653,14 @@ }, "reviewProgress": { "title": "Мои проверки", - "subtitle": "Текущий статус, комментарии проверяющего и история повторных отправок", - "searchLabel": "Поиск навыков или пространств имён", - "searchPlaceholder": "Навык или пространство имён", + "subtitle": "Текущий статус, комментарии проверяющего и история повторных отправок навыков и наборов", + "searchLabel": "Поиск навыков, наборов или пространств имён", + "searchPlaceholder": "Навык, набор или пространство имён", "searchAction": "Найти", + "typeFilter": "Фильтр по типу ресурса", + "typeAll": "Все типы", + "typeSkill": "Навыки", + "typeSuite": "Наборы", "statusFilter": "Фильтр по статусу проверки", "statusAll": "Все статусы", "statusPending": "На проверке", @@ -675,7 +680,7 @@ "error": "Не удалось загрузить статус проверки. Повторите попытку позже.", "historyError": "Не удалось загрузить историю. Повторите попытку позже.", "emptyTitle": "Истории проверок пока нет", - "emptyDescription": "После отправки публичного или командного навыка его статус появится здесь." + "emptyDescription": "После отправки публичного или командного навыка либо набора его статус появится здесь." }, "reviews": { "title": "Центр рецензирования", @@ -1096,6 +1101,16 @@ "suiteEntryTitle": "Используется как вход набора", "suiteEntryDescription": "Этот навык является входным для указанных ниже наборов. Установите соответствующий набор, чтобы использовать все входящие в него навыки.", "suiteEntryMemberCount": "Открыть полный набор (навыков: {{count}})", + "suiteMembershipTitle": "Входит в наборы", + "suiteMembershipDescription": "Доступные вам наборы, текущая версия которых всё ещё содержит этот скилл.", + "suiteMembershipEmpty": "Этот скилл сейчас не входит ни в один доступный вам набор.", + "suiteMembershipEntryRole": "Входной скилл", + "suiteMembershipMemberRole": "Участник", + "suiteMembershipSiblingTitle": "Другие скиллы в наборе", + "suiteMembershipRestricted": "Ограниченных или удалённых участников: {{count}}", + "suiteMembershipOmitted": "Ещё доступных участников: {{count}}", + "suiteMembershipMore": "Показано {{shown}} из {{total}} наборов. Остальные результаты доступны через API с пагинацией.", + "suiteMembershipUnavailable": "Сейчас недоступно", "installMethodClawhub": "ClawHub CLI", "installMethodSkillhub": "SkillHub CLI", "installCommandUnsafeVersion": "Номер этой версии нельзя безопасно использовать в кроссплатформенной команде. Попросите автора исправить его.", @@ -1903,6 +1918,43 @@ } }, "suite": { + "workspace": { + "description": "Управление наборами, версиями и созданием.", + "searchLabel": "Поиск моих наборов", + "searchPlaceholder": "Название, пространство или идентификатор", + "clearSearch": "Очистить поиск", + "stateFilter": "Статус набора", + "attention": "Требуют внимания", + "filter": { + "ALL": "Все статусы", + "ATTENTION": "Требуют внимания", + "DRAFT": "Редактируется", + "PENDING_REVIEW": "На проверке", + "PUBLISHED": "Опубликован", + "OTHER": "Другие статусы" + }, + "columns": { + "suite": "Набор", + "version": "Целевая версия", + "state": "Статус", + "updated": "Обновлён", + "action": "Действие" + }, + "reviewProgress": "Ход проверки", + "resourceSuite": "Набор", + "resourceSkill": "Навык", + "total": "Всего {{count}}, по {{size}} на странице", + "noMatches": "Наборы не найдены", + "error": "Не удалось загрузить наборы", + "reload": "Перезагрузить" + }, + "statusLabel": { + "DRAFT": "Редактируется", + "PENDING_REVIEW": "На проверке", + "PUBLISHED": "Опубликован", + "REJECTED": "Не одобрен", + "YANKED": "Снят с публикации" + }, "listTitle": "Наборы навыков", "listDescription": "Установите набор навыков с зафиксированными версиями одной операцией.", "create": "Создать набор", @@ -1910,6 +1962,36 @@ "emptyTitle": "Нет доступных наборов", "emptyDescription": "Создайте первый набор для повторно используемого рабочего процесса.", "noSummary": "Описание отсутствует", + "assignedLabels": "Метки набора", + "labelFilterTitle": "Фильтр по метке набора", + "allLabels": "Все метки", + "loadingLabels": "Загрузка меток…", + "labelsSectionTitle": "Управление метками набора", + "labelsSectionDescription": "Метки относятся только к этому набору и не изменяют входящие навыки.", + "labelsSectionDescriptionSuperAdmin": "Можно управлять обычными и привилегированными метками только этого набора.", + "currentLabelsTitle": "Текущие метки", + "availableLabelsTitle": "Доступные метки", + "loadingAvailableLabels": "Загрузка доступных меток…", + "noLabelsAssigned": "Для этого набора метки не назначены.", + "noAvailableLabels": "Сейчас нельзя прикрепить больше меток.", + "addLabel": "Добавить {{label}}", + "removeLabel": "Удалить", + "labelRestrictedHint": "Удалить может только суперадминистратор", + "labelAttachSuccessTitle": "Метка набора прикреплена", + "labelAttachSuccessDescription": "В каталоге используется прямая метка набора.", + "labelAttachErrorTitle": "Не удалось прикрепить метку набора", + "labelDetachSuccessTitle": "Метка набора удалена", + "labelDetachSuccessDescription": "Навыки и их метки не изменены.", + "labelDetachErrorTitle": "Не удалось удалить метку набора", + "labelActionFallbackError": "Повторите попытку позже.", + "processing": "Обработка…", + "requiredForPublish": "обязательно для публикации", + "publishReadinessTitle": "Готовность к публикации", + "publishReadinessDescription": "Черновик может быть неполным, но перед проверкой или публикацией нужны краткое и полное описания.", + "summaryComplete": "Краткое описание заполнено", + "summaryIncomplete": "Нужно краткое описание", + "overviewComplete": "Полное описание заполнено", + "overviewIncomplete": "Нужно полное описание", "unavailable": "Недоступно", "notFound": "Набор не существует или недоступен вашей учётной записи.", "published": "Набор опубликован", @@ -1941,7 +2023,49 @@ "startWithEntryDescription": "После установки набора вы получите {{count}} навыков с зафиксированными версиями. Сначала откройте входной навык, чтобы понять основной сценарий их совместного использования.", "viewEntrySkill": "Открыть входной навык", "versionHistory": "История версий", - "validationRequired": "Заполните обязательные поля и выберите хотя бы один навык", + "installSuite": "Установить набор", + "currentVersion": "Текущая версия", + "membersTabShort": "Участники {{count}}", + "versionsTab": "Версии {{count}}", + "overviewSupport": "Сведения о текущей версии набора", + "entrySnapshotDescription": "Входной навык зафиксирован на точной версии из этого набора.", + "thisVersionContains": "В этой версии", + "memberCount": "Навыки", + "membersSnapshotDescription": "Набор фиксирует точные версии навыков и не обновляет их автоматически.", + "pinnedVersionColumn": "Версия", + "memberPurpose": "Роль в наборе", + "availability": "Доступность", + "memberAction": "Действие", + "entryMemberPurpose": "Первый навык после установки набора.", + "memberPurposeFallback": "Отдельная возможность в процессе набора.", + "availableForSuite": "Доступно для набора", + "viewPinnedVersion": "Открыть версию", + "memberUnavailable": "Просмотр недоступен", + "availableMemberCount": "Доступно версий: {{available}}/{{total}}", + "memberSnapshotHint": "Ссылки зафиксированы в текущей версии набора.", + "lifecycleStatus": "Состояние жизненного цикла", + "createdBy": "Автор", + "createdAt": "Создано", + "updatedAt": "Обновлено", + "noChangelog": "Описание версии не заполнено", + "viewVersion": "Открыть версию", + "versionDetails": "Сведения о версии", + "memberSnapshot": "Снимок участников ({{count}} навыков)", + "memberInformation": "Участники ({{count}} навыков)", + "memberChange": "Изменение", + "loadingVersionDetails": "Загрузка сведений о версии", + "memberChangeKinds": { + "ADDED": "Добавлен", + "UPDATED": "Обновлён с v{{version}}", + "UNCHANGED": "Без изменений", + "REMOVED": "Удалён" + }, + "validationRequired": "Заполните обязательные поля", + "namespaceRequired": "Выберите пространство имён", + "slugRequired": "Укажите slug набора", + "nameRequired": "Укажите имя набора", + "versionRequired": "Введите номер новой версии", + "membersRequired": "Выберите хотя бы один навык", "draftUpdated": "Черновик набора обновлён", "draftCreated": "Черновик набора создан", "saveFailed": "Не удалось сохранить", @@ -1952,6 +2076,193 @@ "sourceLoadFailed": "Не удалось загрузить исходную версию набора. Вернитесь на страницу набора и повторите попытку.", "editorAccessDenied": "Эта учётная запись не может редактировать данную версию набора.", "editorDescription": "Выберите точные опубликованные версии. Опубликованный набор не обновляется автоматически.", + "authoringMode": "Способ создания набора", + "manualAuthoring": "Собрать вручную", + "localImport": "Импортировать локально", + "tabs": { + "suites": "Наборы навыков", + "publishing": "Задачи публикации" + }, + "bundle": { + "uploadTitle": "Загрузить Suite Bundle", + "uploadDescription": "Выберите ZIP или корневую папку с SUITE.yaml и папками Skill. Браузер создаст и загрузит только один ZIP.", + "processing": "Упаковка и проверка Bundle", + "previewTitle": "Предпросмотр изменений участников", + "previewFailed": "Не удалось подготовить предпросмотр Bundle", + "packageFailed": "Не удалось упаковать выбранную папку", + "confirmFailed": "Не удалось подтвердить Bundle", + "confirmable": "Можно подтвердить", + "notConfirmable": "Подтверждение недоступно", + "targetMismatch": "Режим или целевой набор Bundle не соответствует этой странице.", + "previewExpired": "Предпросмотр устарел. Выберите и загрузите Bundle снова.", + "noChanges": "Все участники и закреплённые версии остались без изменений.", + "removedMember": "Будет удалён {{coordinate}}@{{version}}", + "removedEntryMember": "текущий входной Skill", + "memberDirectory": "Каталог участника: {{path}}", + "acceptMemberWarnings": "Я проверил и принимаю предупреждения ({{count}}) для {{coordinate}}", + "acceptRemovals": "Я проверил и принимаю удаления участников ({{removalCount}})", + "confirm": "Подтвердить и начать публикацию", + "chooseAgain": "Выбрать снова", + "progressTitle": "Ход публикации Bundle", + "activeTitle": "Публикуемые наборы", + "activeDescription": "Эти Bundle ожидают публикации навыков-участников до создания черновика набора.", + "memberProgress": "Завершено {{completed}} из {{total}} участников; {{waiting}} ожидают проверки", + "continueOperation": "Посмотреть ход публикации", + "loadingOperation": "Загрузка состояния операции", + "operationLoadFailed": "Не удалось загрузить операцию Bundle. Проверьте подключение и повторите попытку.", + "operationTargetMismatch": "Восстановленная операция Bundle не соответствует этому набору. Запись восстановления удалена.", + "reloadOperation": "Загрузить снова", + "redactedMember": "Участник скрыт политикой доступа", + "viewMemberSkill": "Открыть навык-участник", + "memberSkillNotCreated": "Навык ещё не создан", + "forgetOperation": "Не восстанавливать эту операцию", + "importAnother": "Импортировать другой Bundle", + "openDraft": "Открыть черновик набора", + "retryMemberPublish": "Продолжить", + "retryScan": "Проверить снова", + "retrying": "Повторная попытка…", + "retryFailed": "Не удалось повторить операцию Bundle", + "cancelOperation": "Остановить создание набора", + "cancelFailed": "Не удалось отменить операцию Bundle", + "openingTask": "Открывается задача публикации…", + "taskListTitle": "Задачи публикации наборов", + "taskListDescription": "Сначала выполните задачи, требующие вашего участия; остальные продолжатся автоматически.", + "taskDetailTitle": "Задача публикации набора", + "taskDetailDescription": "Отслеживайте публикацию участников и создание черновика отдельно от страницы создания.", + "backToTasks": "К задачам публикации", + "backToSuite": "Назад к сведениям о наборе", + "taskEmpty": "Нет задач публикации", + "taskEmptyDescription": "Прогресс появится здесь после импорта и подтверждения Suite Bundle.", + "groups": { + "attention": "Сначала обработайте эти", + "attentionDescription": "Блокировка или изменение плана требует вашего действия", + "attentionEmpty": "Нет задач, требующих внимания.", + "progress": "Обрабатываются автоматически", + "progressDescription": "Действия не требуются: навыки и проверки продолжаются", + "progressEmpty": "Нет выполняемых задач.", + "recent": "Завершены", + "recentDescription": "Черновик создан или задача остановлена; запись можно открыть при необходимости", + "recentEmpty": "Нет недавно завершённых задач." + }, + "taskCount": "Задач публикации: {{count}}", + "priorityHint": "Задачи, требующие действий, показаны первыми", + "taskAction": { + "RUNNING": "Смотреть прогресс", + "WAITING_FOR_MEMBERS": "Смотреть прогресс", + "BLOCKED_RETRYABLE": "Продолжить", + "REPREVIEW_REQUIRED": "Подтвердить снова", + "SUITE_DRAFT_CREATED": "Открыть черновик", + "CANCELLED": "Открыть запись" + }, + "taskHint": { + "RUNNING": "Публикуются навыки-участники", + "WAITING_FOR_MEMBERS": "Ожидание проверки участников", + "BLOCKED_RETRYABLE": "Откройте и повторите незавершённых", + "REPREVIEW_REQUIRED": "Проверьте и подтвердите изменения", + "SUITE_DRAFT_CREATED": "Продолжите редактирование или отправьте на проверку", + "CANCELLED": "Остановлено; запись доступна" + }, + "statusLabel": { + "RUNNING": "Обработка", + "WAITING_FOR_MEMBERS": "Ожидание участников", + "BLOCKED_RETRYABLE": "Участники не готовы", + "REPREVIEW_REQUIRED": "Настройки изменились", + "SUITE_DRAFT_CREATED": "Черновик подготовлен", + "CANCELLED": "Остановлено" + }, + "problem": { + "memberScanFailed": { + "title": "Проверка безопасности участника не пройдена", + "description": "Существующая версия участника сохранена. Когда служба проверки станет доступна, повторите обработку без повторной загрузки Bundle." + }, + "memberExecutionFailed": { + "title": "Участник не опубликован", + "description": "Готовые участники не запускаются повторно. Исправьте незавершённого участника ниже и продолжите." + }, + "authorizationBlocked": { + "title": "Изменились права или доступ к пространству имён", + "description": "Текущая учётная запись могла потерять право публиковать навык или управлять набором, либо пространство имён заморожено. Восстановите доступ или разморозьте его и повторите." + }, + "planChanged": { + "title": "Участники или доступ изменились", + "description": "Версия участника, права или состояние набора больше не соответствуют подтверждённому плану. Импортируйте Bundle снова и подтвердите актуальные изменения." + }, + "draftMissing": { + "title": "Черновик набора не создан", + "description": "Навыки-участники не создали версии, на которые может ссылаться набор. Исправьте пакеты участников и импортируйте Bundle снова." + }, + "blocked": { + "title": "Публикацию навыка пока нельзя продолжить", + "description": "Незавершённые элементы отмечены в списке ниже. Повтор обрабатывает только их; если ошибка сохранится, передайте ID задачи администратору." + }, + "affectedMembers": "Требуют обработки: {{members}}", + "affectedMembersLabel": "Требует внимания", + "affectedMembersMore": "ещё {{count}}" + }, + "lifecycleTitle": "Этапы публикации набора", + "lifecycle": { + "preview": "Подтвердить изменения", + "previewDescription": "Загрузка и изменения участников зафиксированы", + "members": "Опубликовать участников", + "membersDescription": "Создать или использовать версии навыков и дождаться проверки", + "draft": "Создать черновик набора", + "draftDescription": "Выполняется после готовности всех участников" + }, + "operationId": "ID задачи", + "lastUpdated": "Последнее обновление", + "memberTitle": "Навыки-участники", + "memberDescription": "Участники публикуются как отдельные навыки; смотрите отмеченные незавершённые строки.", + "memberAction": "Обработка участника", + "memberStatus": { + "PLANNED": "Ожидает запуска", + "RUNNING": "Создание версии", + "WAITING_FOR_MEMBER": "Публикация", + "COMPLETED": "Можно добавить в набор", + "BLOCKED_RETRYABLE": "Требуется действие", + "REPREVIEW_REQUIRED": "Нужно подтверждение", + "CANCELLED": "Дальнейшая работа остановлена", + "ATTENTION": "Не завершён" + }, + "noMembers": "Нет прогресса участников.", + "cancelConfirmTitle": "Остановить создание набора?", + "cancelConfirmDescription": "SkillHub перестанет ждать участников и не создаст черновик набора. Уже созданные версии навыков и задачи проверки останутся и не будут отозваны.", + "cancelConfirmAction": "Остановить создание набора", + "cancelledTitle": "Создание набора остановлено", + "cancelledDescription": "Задача сохранена в истории. Созданные версии навыков и проверки не отзываются и не удаляются.", + "startAgain": "Импортировать Bundle снова", + "relationship": { + "ADDED": "Добавить участника", + "UPDATED": "Изменить закреплённую версию", + "UNCHANGED": "Без изменений", + "REMOVED": "Удалить участника" + }, + "action": { + "CREATE_SKILL": "Создать Skill", + "CREATE_VERSION": "Создать версию", + "REUSE_VERSION": "Использовать существующую версию", + "REFERENCE_VERSION": "Сослаться на точную версию", + "NONE": "Без записи публикации" + }, + "source": { + "PACKAGE": "Пакет участника в Bundle", + "REFERENCE": "Ссылка на существующую версию" + }, + "path": { + "noWrite": "Повторное использование или ссылка на существующую версию без публикации Skill.", + "private": "Создание PRIVATE-версии Skill без автоматической публикации.", + "publish": "Создание версии Skill через существующий процесс сканирования, публикации или проверки; итог смотрите на странице прогресса." + }, + "errors": { + "empty-folder": "Выбранная папка или ZIP пусты.", + "mixed-folder-roots": "Выберите только одну корневую папку Bundle.", + "missing-suite-manifest": "В корневой папке нет SUITE.yaml.", + "duplicate-suite-manifest": "Bundle может содержать только один SUITE.yaml.", + "too-many-files": "В Bundle слишком много файлов.", + "file-too-large": "Один из файлов Bundle превышает 10 МБ.", + "bundle-too-large": "Общий размер Bundle превышает 100 МБ.", + "invalid-zip": "Выберите файл .zip." + } + }, "namespace": "Пространство имён", "slug": "Идентификатор", "selectNamespace": "Выберите пространство имён", @@ -1959,15 +2270,43 @@ "namePlaceholder": "Рабочий процесс маркетингового события", "overview": "Обзор (Markdown)", "overviewHint": "Опишите сценарий использования, порядок навыков, входные и выходные данные и важные ограничения.", + "overviewPromptScenario": "Сценарий: для какой работы предназначен этот набор?", + "overviewPromptPreparation": "Подготовка: какие данные, файлы или окружение нужны?", + "overviewPromptSequence": "Роли: что делает каждый навык и в каком порядке?", + "overviewPromptInputsOutputs": "Вход и результат: что предоставляет и получает пользователь?", + "overviewPromptBoundaries": "Ограничения: какие права, риски или неподходящие случаи важны?", + "entryGuideTitle": "Инструкции закреплённой версии входного навыка", + "entryGuideSource": "Содержимое взято из SKILL.md {{coordinate}}@{{version}} и не переключается на новую версию.", + "entryGuideLoading": "Загрузка инструкций входного навыка", + "entryGuideUnavailable": "Ваша учётная запись не может читать эту версию входного навыка.", "visibilityPublic": "Публичный", "visibilityNamespace": "Только пространство имён", "visibilityPrivate": "Приватный", "summary": "Описание", + "summaryLabel": "Краткое описание", "changelog": "Примечания к версии", "selectSkills": "Выберите навыки", "searchSkills": "Поиск доступных навыков", + "memberSearchDescription": "Сначала выберите область поиска, затем добавляйте участников по ключевым словам или фильтрам.", + "memberSearchScope": "Область поиска навыков", + "scopeMarket": "Маркет навыков", + "scopeNamespace": "Текущее пространство имён", + "scopeCoordinate": "Точная координата", + "searchOnSubmitHint": "Поиск выполняется только после подтверждения; все навыки по умолчанию не загружаются", + "memberSearchEmptyTitle": "Выберите область и начните поиск", + "memberSearchEmptyDescription": "Введите ключевое слово, выберите видимость или используйте точную координату, затем нажмите поиск.", + "search": "Поиск", + "memberSkillColumn": "Навык", + "memberDescriptionColumn": "Описание", + "memberVersionColumn": "Версия", + "memberActionColumn": "Действие", + "recommendedCandidate": "Рекомендуемый", + "addMember": "Добавить", "noCandidates": "Нет опубликованных версий с выбранной видимостью.", "selectedMembers": "Участники набора ({{count}}/100)", + "selectedMembersDescription": "Упорядочьте участников и выберите входной навык.", + "expandMembers": "Развернуть настройки участников", + "collapseMembers": "Свернуть настройки участников", "setEntry": "Назначить входным навыком", "entryRequired": "Выберите входной навык", "confirmVersionUpdateTitle": "Подтвердите изменение закреплённой версии", @@ -1996,6 +2335,47 @@ "resourceTypeSkill": "Навык", "managementTitle": "Управление набором", "managementDescription": "Управляйте версиями, публикацией и контейнером набора. Навыки-участники не изменяются.", + "management": { + "backToSuites": "Назад к моим наборам", + "yankedTitle": "Версия v{{version}} снята", + "yankedDescription": "Новые пользователи не могут установить эту версию. Навыки-участники не затронуты.", + "degradedTitle": "Версия v{{version}} недоступна для установки", + "availableTitle": "Версия v{{version}} доступна для установки", + "availableDescription": "Эта версия и все навыки-участники доступны.", + "tabs": { + "overview": "Информация о наборе", + "members": "Участники {{count}}", + "versions": "Версии {{count}}", + "publishing": "История публикаций {{count}}" + }, + "previewMarket": "Предпросмотр в каталоге", + "draftDescription": "Версии участников зафиксированы. Заполните данные и отправьте на проверку.", + "reviewDescription": "Версия находится на проверке; повторная отправка не требуется.", + "memberCountValue": "Участников: {{count}}", + "membersSnapshotDescription": "Версия фиксирует следующие навыки; изменение набора не меняет сами навыки.", + "editMembers": "Изменить участников", + "memberLifecycleHint": "У навыков независимый жизненный цикл; изменение доступа может заблокировать публикацию.", + "memberVersions": "Версии участников", + "publishChecklist": "Проверка перед публикацией", + "publishChecklistDescription": "Заполните эти пункты перед отправкой версии.", + "ready": "Готово", + "incomplete": "Не заполнено", + "marketContent": "Содержимое каталога", + "marketContentDescription": "Здесь показан анонс; полное содержимое доступно в каталоге.", + "editContent": "Изменить содержимое", + "recentProgress": "Последние события", + "basicInfo": "Основная информация", + "memberCount": "Участники", + "membersTitle": "Навыки-участники ({{count}})", + "member": "Навык", + "role": "Роль", + "memberRole": "Навык-участник", + "normal": "Доступен", + "recentPublishing": "Последние публикации", + "viewAll": "Показать все", + "openPublishingHint": "Открыть историю публикаций и проверить задачи", + "publishingDescription": "Просмотр задач публикации Bundle и состояния участников этого набора." + }, "reopenDraft": "Вернуть в черновик и изменить", "reopened": "Версия возвращена в черновик", "createVersion": "Создать новую версию", diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index 681e3545..d0021770 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -442,6 +442,7 @@ "subtitle": "管理你的技能、账户与偏好设置", "overview": "概览", "backToDashboard": "返回控制台", + "navigation": "控制台导航", "userInfo": "用户信息", "userInfoDesc": "查看当前账户的基础信息与平台角色", "loginVia": "通过 {{provider}} 登录", @@ -652,10 +653,14 @@ }, "reviewProgress": { "title": "我的审核进度", - "subtitle": "查看技能提交的当前状态、审核意见和每次重提记录", - "searchLabel": "搜索技能或命名空间", - "searchPlaceholder": "搜索 Skill 或命名空间", + "subtitle": "查看技能和套件提交的当前状态、审核意见和每次重提记录", + "searchLabel": "搜索技能、套件或命名空间", + "searchPlaceholder": "搜索技能、套件或命名空间", "searchAction": "搜索", + "typeFilter": "按资源类型筛选", + "typeAll": "全部类型", + "typeSkill": "技能", + "typeSuite": "套件", "statusFilter": "按审核状态筛选", "statusAll": "全部状态", "statusPending": "审核中", @@ -675,7 +680,7 @@ "error": "审核进度加载失败,请稍后重试。", "historyError": "提交历史加载失败,请稍后重试。", "emptyTitle": "还没有审核记录", - "emptyDescription": "提交公开或团队 Skill 后,审核进度会显示在这里。" + "emptyDescription": "提交公开或团队技能、套件后,审核进度会显示在这里。" }, "reviews": { "title": "审核中心", @@ -1031,6 +1036,16 @@ "suiteEntryTitle": "被套件用作入口", "suiteEntryDescription": "此技能是以下技能套件的入口。如需使用完整套件,请安装对应技能套件。", "suiteEntryMemberCount": "查看完整套件({{count}} 个技能)", + "suiteMembershipTitle": "所属套件", + "suiteMembershipDescription": "展示当前版本仍包含此技能、且你有权查看的套件。", + "suiteMembershipEmpty": "此技能当前未加入你可见的套件。", + "suiteMembershipEntryRole": "入口技能", + "suiteMembershipMemberRole": "普通成员", + "suiteMembershipSiblingTitle": "同套件中的其他技能", + "suiteMembershipRestricted": "{{count}} 个成员受限或已删除", + "suiteMembershipOmitted": "另有 {{count}} 个可见成员未展开", + "suiteMembershipMore": "已显示 {{shown}} 个,共 {{total}} 个所属套件;其余结果可通过分页接口读取。", + "suiteMembershipUnavailable": "当前不可安装", "installMethodClawhub": "ClawHub CLI", "installMethodSkillhub": "SkillHub CLI", "installCommandUnsafeVersion": "该版本号无法安全地生成跨平台命令,请联系发布者修正版本号。", @@ -1905,6 +1920,43 @@ "restoreErrorTitle": "恢复命名空间失败" }, "suite": { + "workspace": { + "description": "管理套件、查看版本与创建进度。", + "searchLabel": "搜索我的套件", + "searchPlaceholder": "搜索名称、命名空间或标识", + "clearSearch": "清空搜索", + "stateFilter": "套件状态", + "attention": "需处理", + "filter": { + "ALL": "全部状态", + "ATTENTION": "需处理", + "DRAFT": "编辑中", + "PENDING_REVIEW": "审核中", + "PUBLISHED": "已发布", + "OTHER": "其他状态" + }, + "columns": { + "suite": "套件", + "version": "目标版本", + "state": "当前状态", + "updated": "最近更新", + "action": "操作" + }, + "reviewProgress": "查看审核进度", + "resourceSuite": "套件", + "resourceSkill": "技能", + "total": "共 {{count}} 个,每页 {{size}} 条", + "noMatches": "没有符合条件的套件", + "error": "套件加载失败", + "reload": "重新加载" + }, + "statusLabel": { + "DRAFT": "编辑中", + "PENDING_REVIEW": "审核中", + "PUBLISHED": "已发布", + "REJECTED": "未通过", + "YANKED": "已下架" + }, "listTitle": "技能套件", "listDescription": "一次安装一组经过版本锁定的技能。", "create": "创建套件", @@ -1912,6 +1964,36 @@ "emptyTitle": "暂无可用套件", "emptyDescription": "创建第一个套件,组合可复用的技能工作流。", "noSummary": "暂无简介", + "assignedLabels": "套件标签", + "labelFilterTitle": "按套件标签筛选", + "allLabels": "全部标签", + "loadingLabels": "正在加载标签…", + "labelsSectionTitle": "套件标签管理", + "labelsSectionDescription": "标签只作用于当前套件,不会修改成员技能。", + "labelsSectionDescriptionSuperAdmin": "可配置普通或特权标签;标签只作用于当前套件。", + "currentLabelsTitle": "当前标签", + "availableLabelsTitle": "可添加标签", + "loadingAvailableLabels": "正在加载可用标签…", + "noLabelsAssigned": "当前套件尚未配置标签。", + "noAvailableLabels": "当前没有可继续添加的标签。", + "addLabel": "添加 {{label}}", + "removeLabel": "移除", + "labelRestrictedHint": "仅超级管理员可移除", + "labelAttachSuccessTitle": "套件标签已添加", + "labelAttachSuccessDescription": "套件专区将使用新的直接关联标签。", + "labelAttachErrorTitle": "无法添加套件标签", + "labelDetachSuccessTitle": "套件标签已移除", + "labelDetachSuccessDescription": "成员技能及其标签未被修改。", + "labelDetachErrorTitle": "无法移除套件标签", + "labelActionFallbackError": "请稍后重试。", + "processing": "处理中…", + "requiredForPublish": "发布必填", + "publishReadinessTitle": "发布信息完整度", + "publishReadinessDescription": "草稿可以暂时不完整;提交审核或直接发布前必须补齐简介和概述。", + "summaryComplete": "简介已填写", + "summaryIncomplete": "简介待补充", + "overviewComplete": "概述已填写", + "overviewIncomplete": "概述待补充", "unavailable": "当前不可安装", "notFound": "套件不存在或当前账号无权查看。", "published": "套件已发布", @@ -1943,7 +2025,49 @@ "startWithEntryDescription": "安装套件后将获得 {{count}} 个固定版本的技能。先打开入口技能,了解这组技能的主要使用方式。", "viewEntrySkill": "查看入口技能", "versionHistory": "版本历史", - "validationRequired": "请填写必填项并至少选择一个技能", + "installSuite": "安装套件", + "currentVersion": "当前版本", + "membersTabShort": "成员 {{count}}", + "versionsTab": "版本 {{count}}", + "overviewSupport": "当前套件版本辅助信息", + "entrySnapshotDescription": "入口技能固定为当前套件引用的精确版本。", + "thisVersionContains": "本版本包含", + "memberCount": "技能数量", + "membersSnapshotDescription": "当前套件固定引用以下技能版本,安装后不会自动升级。", + "pinnedVersionColumn": "固定版本", + "memberPurpose": "在套件中的作用", + "availability": "可用性", + "memberAction": "操作", + "entryMemberPurpose": "套件安装后的首个使用入口。", + "memberPurposeFallback": "为套件工作流提供独立能力。", + "availableForSuite": "可用于套件", + "viewPinnedVersion": "查看固定版本", + "memberUnavailable": "当前不可查看", + "availableMemberCount": "{{available}}/{{total}} 个成员版本可用", + "memberSnapshotHint": "这些引用固定在当前套件版本中。", + "lifecycleStatus": "生命周期状态", + "createdBy": "创建人", + "createdAt": "创建时间", + "updatedAt": "更新时间", + "noChangelog": "未填写版本说明", + "viewVersion": "查看版本", + "versionDetails": "版本详情", + "memberSnapshot": "成员快照({{count}} 个技能)", + "memberInformation": "成员信息({{count}} 个技能)", + "memberChange": "成员变化", + "loadingVersionDetails": "正在加载版本详情", + "memberChangeKinds": { + "ADDED": "新增", + "UPDATED": "由 v{{version}} 升级", + "UNCHANGED": "未变化", + "REMOVED": "已移除" + }, + "validationRequired": "请填写必填项", + "namespaceRequired": "请选择命名空间", + "slugRequired": "请填写套件标识", + "nameRequired": "请填写套件名称", + "versionRequired": "请输入新版本号", + "membersRequired": "请至少选择一个技能", "draftUpdated": "套件草稿已更新", "draftCreated": "套件草稿已创建", "saveFailed": "保存失败", @@ -1954,6 +2078,193 @@ "sourceLoadFailed": "无法读取套件源版本,请返回详情页后重试。", "editorAccessDenied": "当前账号不能编辑此套件版本。", "editorDescription": "选择已发布的精确技能版本;套件发布后不会自动漂移到最新版本。", + "authoringMode": "套件创建方式", + "manualAuthoring": "手工组合", + "localImport": "本地导入", + "tabs": { + "suites": "技能套件", + "publishing": "发布任务" + }, + "bundle": { + "uploadTitle": "上传 Suite Bundle", + "uploadDescription": "选择一个 ZIP,或选择包含 SUITE.yaml 和多个 Skill 文件夹的根目录。浏览器只会生成并上传一个 ZIP。", + "processing": "正在整理并校验 Bundle", + "previewTitle": "成员差异预览", + "previewFailed": "Bundle 预览失败", + "packageFailed": "无法将所选目录打包", + "confirmFailed": "Bundle 确认失败", + "confirmable": "可以确认", + "notConfirmable": "当前不能确认", + "targetMismatch": "Bundle 的创建模式或目标套件与当前页面不一致,请选择正确的 Bundle。", + "previewExpired": "预览已过期,请重新选择并上传 Bundle。", + "noChanges": "所有成员和固定版本都没有变化。", + "removedMember": "将移除 {{coordinate}}@{{version}}", + "removedEntryMember": "当前入口技能", + "memberDirectory": "成员目录:{{path}}", + "acceptMemberWarnings": "我已检查并接受 {{coordinate}} 的 {{count}} 条警告", + "acceptRemovals": "我已逐项检查并接受 {{removalCount}} 个成员移除项", + "confirm": "确认并开始发布", + "chooseAgain": "重新选择", + "progressTitle": "Bundle 发布进度", + "activeTitle": "发布中的套件", + "activeDescription": "这些 Bundle 正在等待成员技能发布完成,完成后才会生成套件草稿。", + "memberProgress": "已完成 {{completed}} / {{total}} 个成员,其中 {{waiting}} 个等待扫描或审核", + "continueOperation": "查看发布进度", + "loadingOperation": "正在读取操作状态", + "operationLoadFailed": "无法读取 Bundle 操作状态,请检查网络后重试。", + "operationTargetMismatch": "恢复的 Bundle 操作与当前套件不匹配,已清除该恢复记录。", + "reloadOperation": "重新读取", + "redactedMember": "无权查看的成员", + "viewMemberSkill": "查看成员技能", + "memberSkillNotCreated": "技能尚未创建", + "forgetOperation": "放弃恢复此操作", + "importAnother": "导入另一个 Bundle", + "openDraft": "打开套件草稿", + "retryMemberPublish": "继续处理", + "retryScan": "重新检查", + "retrying": "正在重试…", + "retryFailed": "重试 Bundle 操作失败", + "cancelOperation": "停止创建套件", + "cancelFailed": "取消 Bundle 操作失败", + "openingTask": "正在打开独立发布任务页面…", + "taskListTitle": "套件发布任务", + "taskListDescription": "优先处理需要你介入的任务,其余任务由系统自动推进。", + "taskDetailTitle": "套件发布任务", + "taskDetailDescription": "跟踪成员技能发布与套件草稿创建,不会跳回创建页面。", + "backToTasks": "返回发布任务", + "backToSuite": "返回套件详情", + "taskEmpty": "暂无发布任务", + "taskEmptyDescription": "从本地导入 Suite Bundle 并确认后,发布进度会显示在这里。", + "groups": { + "attention": "先处理这些", + "attentionDescription": "阻塞或计划已变化,需要你操作后才能继续", + "attentionEmpty": "当前没有需要处理的任务。", + "progress": "系统处理中", + "progressDescription": "无需操作,系统会继续处理成员技能和审核", + "progressEmpty": "当前没有进行中的任务。", + "recent": "已结束", + "recentDescription": "草稿已创建或任务已停止,可按需查看记录", + "recentEmpty": "暂无最近完成的任务。" + }, + "taskCount": "共 {{count}} 个发布任务", + "priorityHint": "需要处理的任务已置顶", + "taskAction": { + "RUNNING": "查看进度", + "WAITING_FOR_MEMBERS": "查看进度", + "BLOCKED_RETRYABLE": "继续处理", + "REPREVIEW_REQUIRED": "重新确认", + "SUITE_DRAFT_CREATED": "打开草稿", + "CANCELLED": "查看记录" + }, + "taskHint": { + "RUNNING": "系统正在发布成员", + "WAITING_FOR_MEMBERS": "等待成员审核完成", + "BLOCKED_RETRYABLE": "打开后重试未完成成员", + "REPREVIEW_REQUIRED": "重新预览并确认差异", + "SUITE_DRAFT_CREATED": "可继续编辑或提交审核", + "CANCELLED": "任务已停止,可查看记录" + }, + "statusLabel": { + "RUNNING": "处理中", + "WAITING_FOR_MEMBERS": "等待成员", + "BLOCKED_RETRYABLE": "成员未完成", + "REPREVIEW_REQUIRED": "配置已变化", + "SUITE_DRAFT_CREATED": "已生成草稿", + "CANCELLED": "已停止" + }, + "problem": { + "memberScanFailed": { + "title": "成员安全扫描失败", + "description": "原成员版本仍保留。确认扫描服务可用后,可重试该成员,无需重新上传 Bundle。" + }, + "memberExecutionFailed": { + "title": "有成员发布失败", + "description": "已完成成员不会重复处理。请处理下方未完成成员后继续。" + }, + "authorizationBlocked": { + "title": "权限或命名空间状态已变化", + "description": "当前账号可能已失去成员技能发布或套件管理权限,也可能是相关命名空间被冻结。恢复权限或解冻后可继续重试。" + }, + "planChanged": { + "title": "成员或权限有变化", + "description": "成员版本、权限或套件状态与确认时不再一致,原发布计划不能继续。请重新导入 Bundle,并确认最新差异。" + }, + "draftMissing": { + "title": "套件草稿没有生成", + "description": "成员没有生成可引用的技能版本,因此无法创建套件草稿。请修正成员包后重新导入 Bundle。" + }, + "blocked": { + "title": "成员技能暂时未能继续发布", + "description": "下方成员列表标出了未完成项。直接重试只会继续处理这些成员;若仍失败,请将任务 ID 提供给管理员。" + }, + "affectedMembers": "待处理成员:{{members}}", + "affectedMembersLabel": "待处理成员", + "affectedMembersMore": "另有 {{count}} 个" + }, + "lifecycleTitle": "套件发布阶段", + "lifecycle": { + "preview": "确认差异", + "previewDescription": "已锁定本次上传与成员变更", + "members": "推进成员发布", + "membersDescription": "创建或复用技能版本并等待审核", + "draft": "创建套件草稿", + "draftDescription": "仅在所有成员就绪后执行" + }, + "operationId": "任务 ID", + "lastUpdated": "最近更新", + "memberTitle": "成员技能", + "memberDescription": "成员按独立技能发布;重点处理标记为未完成的成员。", + "memberAction": "成员处理", + "memberStatus": { + "PLANNED": "等待开始", + "RUNNING": "正在创建版本", + "WAITING_FOR_MEMBER": "发布处理中", + "COMPLETED": "可加入套件", + "BLOCKED_RETRYABLE": "需要处理", + "REPREVIEW_REQUIRED": "需要重新确认", + "CANCELLED": "已停止后续处理", + "ATTENTION": "未完成" + }, + "noMembers": "暂无成员进度。", + "cancelConfirmTitle": "停止创建这个套件?", + "cancelConfirmDescription": "停止后,SkillHub 不再等待成员或创建套件草稿。已经创建的技能版本和已经提交的审核任务不会撤回,仍可在技能审核中继续处理。", + "cancelConfirmAction": "停止创建套件", + "cancelledTitle": "套件创建已停止", + "cancelledDescription": "任务记录会保留。已创建的技能版本和审核任务仍然存在,不会自动撤回或删除。", + "startAgain": "重新导入 Bundle", + "relationship": { + "ADDED": "新增成员", + "UPDATED": "更新固定版本", + "UNCHANGED": "保持不变", + "REMOVED": "移除成员" + }, + "action": { + "CREATE_SKILL": "创建技能", + "CREATE_VERSION": "创建版本", + "REUSE_VERSION": "复用已有版本", + "REFERENCE_VERSION": "引用精确版本", + "NONE": "不产生发布写入" + }, + "source": { + "PACKAGE": "携带成员包", + "REFERENCE": "引用已有版本" + }, + "path": { + "noWrite": "复用或引用已有版本,不产生 Skill 发布写入。", + "private": "创建 PRIVATE Skill 版本并保持私有,不自动公开。", + "publish": "创建 Skill 版本并进入现有扫描、发布或审核流程;最终状态以进度页为准。" + }, + "errors": { + "empty-folder": "所选目录或 ZIP 为空。", + "mixed-folder-roots": "一次只能选择一个 Bundle 根目录。", + "missing-suite-manifest": "根目录缺少 SUITE.yaml。", + "duplicate-suite-manifest": "Bundle 中只能有一个 SUITE.yaml。", + "too-many-files": "Bundle 文件数超过上限。", + "file-too-large": "Bundle 中存在超过 10 MB 的文件。", + "bundle-too-large": "Bundle 总大小超过 100 MB。", + "invalid-zip": "请选择 .zip 文件。" + } + }, "namespace": "命名空间", "slug": "标识", "selectNamespace": "选择命名空间", @@ -1961,15 +2272,43 @@ "namePlaceholder": "营销活动工作流", "overview": "概述(Markdown)", "overviewHint": "说明套件的使用场景、成员协作顺序、输入输出和注意事项。", + "overviewPromptScenario": "适用场景:什么任务适合使用这组技能?", + "overviewPromptPreparation": "使用准备:运行前需要哪些信息、文件或环境?", + "overviewPromptSequence": "成员分工:各技能负责什么,按什么顺序使用?", + "overviewPromptInputsOutputs": "输入输出:用户提供什么,最终会得到什么?", + "overviewPromptBoundaries": "注意事项:有哪些权限、风险或不适用边界?", + "entryGuideTitle": "入口技能固定版本说明", + "entryGuideSource": "内容来自 {{coordinate}}@{{version}} 的 SKILL.md,不会自动切换到最新版本。", + "entryGuideLoading": "正在加载入口技能说明", + "entryGuideUnavailable": "当前账号不能读取该固定版本的入口技能说明。", "visibilityPublic": "公开", "visibilityNamespace": "仅命名空间", "visibilityPrivate": "私有", "summary": "简介", + "summaryLabel": "摘要", "changelog": "版本说明", "selectSkills": "选择技能", "searchSkills": "搜索可用技能", + "memberSearchDescription": "先选择查找范围,再按关键词或筛选条件添加成员。", + "memberSearchScope": "成员技能查找范围", + "scopeMarket": "技能市场", + "scopeNamespace": "当前命名空间", + "scopeCoordinate": "精确坐标", + "searchOnSubmitHint": "点击搜索后才查询,不会默认加载全部技能", + "memberSearchEmptyTitle": "选择范围后开始搜索", + "memberSearchEmptyDescription": "输入关键词、选择可见性或使用精确坐标后点击搜索,避免一次性加载全部技能。", + "search": "搜索", + "memberSkillColumn": "技能", + "memberDescriptionColumn": "说明", + "memberVersionColumn": "版本", + "memberActionColumn": "操作", + "recommendedCandidate": "推荐", + "addMember": "添加", "noCandidates": "没有符合当前可见性要求的已发布版本。", "selectedMembers": "套件成员({{count}}/100)", + "selectedMembersDescription": "按使用顺序组织成员,并选择一个入口技能。", + "expandMembers": "展开成员设置", + "collapseMembers": "收起成员设置", "setEntry": "设为入口技能", "entryRequired": "请选择一个入口技能", "confirmVersionUpdateTitle": "确认更换固定版本", @@ -1998,6 +2337,47 @@ "resourceTypeSkill": "技能", "managementTitle": "套件管理", "managementDescription": "管理新版本、发布状态和套件容器;这些操作不会改变成员技能。", + "management": { + "backToSuites": "返回我的套件", + "yankedTitle": "v{{version}} 已下架", + "yankedDescription": "该版本不能被新用户安装,成员技能不受影响。", + "degradedTitle": "v{{version}} 当前不可安装", + "availableTitle": "v{{version}} 可正常安装", + "availableDescription": "当前版本及所有成员技能均可用。", + "tabs": { + "overview": "套件信息", + "members": "成员 {{count}}", + "versions": "版本 {{count}}", + "publishing": "发布记录 {{count}}" + }, + "previewMarket": "预览市场页面", + "draftDescription": "成员版本已经固定;补齐发布信息后即可提交审核。", + "reviewDescription": "当前版本正在审核,审核完成前无需重复提交。", + "memberCountValue": "{{count}} 个成员", + "membersSnapshotDescription": "当前版本固定引用以下技能;修改套件不会修改成员技能本身。", + "editMembers": "修改成员", + "memberLifecycleHint": "成员技能拥有独立生命周期,权限或可用性变化会影响套件发布。", + "memberVersions": "成员版本", + "publishChecklist": "发布前检查", + "publishChecklistDescription": "完成以下内容后才能提交当前版本。", + "ready": "已完成", + "incomplete": "待补充", + "marketContent": "市场展示内容", + "marketContentDescription": "这里只提供预览,完整内容将在市场页面展示。", + "editContent": "编辑内容", + "recentProgress": "最近进展", + "basicInfo": "基本信息", + "memberCount": "成员", + "membersTitle": "成员技能({{count}})", + "member": "技能", + "role": "角色", + "memberRole": "成员技能", + "normal": "正常", + "recentPublishing": "最近发布记录", + "viewAll": "查看全部", + "openPublishingHint": "进入发布记录查看任务状态", + "publishingDescription": "查看这个套件的 Bundle 发布任务与成员推进状态。" + }, "reopenDraft": "退回草稿并编辑", "reopened": "已恢复为草稿", "createVersion": "创建新版本", diff --git a/web/src/pages/dashboard.tsx b/web/src/pages/dashboard.tsx index e2df560f..4639a210 100644 --- a/web/src/pages/dashboard.tsx +++ b/web/src/pages/dashboard.tsx @@ -1,4 +1,5 @@ import { Link } from '@tanstack/react-router' +import { useState } from 'react' import { useTranslation } from 'react-i18next' import { useAuth } from '@/features/auth/use-auth' import { canViewGovernanceCenter } from '@/shared/lib/governance-access' @@ -6,7 +7,7 @@ import { APP_SHELL_PAGE_CLASS_NAME } from '@/app/page-shell-style' import { DashboardPageHeader } from '@/shared/components/dashboard-page-header' import { Star, Heart, Package, Boxes, Key, Shield, Flag, Globe, - UserCog, Lock, Bell, Clock, ChevronRight, + UserCog, Lock, Bell, Clock, ChevronDown, ChevronRight, } from 'lucide-react' /** @@ -160,9 +161,47 @@ export function DashboardSidebar({ t: ReturnType['t'] pathname: string }) { + const [mobileOpen, setMobileOpen] = useState(false) + const activeItem = groups.flatMap(group => group.items) + .find(item => pathname === item.to || pathname.startsWith(item.to)) + return (