diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/SkillhubApplication.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/SkillhubApplication.java index 71ee7de8..56540ea7 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/SkillhubApplication.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/SkillhubApplication.java @@ -1,6 +1,7 @@ package com.iflytek.skillhub; import com.iflytek.skillhub.bootstrap.BuiltinSkillProperties; +import com.iflytek.skillhub.config.AuthoringProperties; import com.iflytek.skillhub.config.ProfileFieldPolicyProperties; import com.iflytek.skillhub.config.ProfileModerationProperties; import org.springframework.boot.SpringApplication; @@ -14,7 +15,8 @@ import org.springframework.boot.context.properties.EnableConfigurationProperties @EnableConfigurationProperties({ BuiltinSkillProperties.class, ProfileModerationProperties.class, - ProfileFieldPolicyProperties.class + ProfileFieldPolicyProperties.class, + AuthoringProperties.class }) public class SkillhubApplication { public static void main(String[] args) { diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/AuthoringExecutorConfig.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/AuthoringExecutorConfig.java new file mode 100644 index 00000000..79771dfa --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/AuthoringExecutorConfig.java @@ -0,0 +1,29 @@ +package com.iflytek.skillhub.config; + +import java.util.concurrent.Executor; +import java.util.concurrent.ThreadPoolExecutor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; + +/** + * Dedicated executor for authoring validation runs. Kept separate from + * {@code skillhubEventExecutor} because validation tasks run short-lived subprocesses + * and blocking HTTP calls that would starve the lightweight event executor. + */ +@Configuration +public class AuthoringExecutorConfig { + + @Bean(name = "authoringValidationExecutor") + public Executor authoringValidationExecutor(AuthoringProperties properties) { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + executor.setCorePoolSize(Math.max(1, properties.getExecutorThreads())); + executor.setMaxPoolSize(Math.max(1, properties.getExecutorThreads())); + executor.setQueueCapacity(100); + executor.setThreadNamePrefix("authoring-validation-"); + // a full queue runs the task on the submitting thread instead of dropping runs + executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); + executor.initialize(); + return executor; + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/AuthoringProperties.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/AuthoringProperties.java new file mode 100644 index 00000000..5d0c07a1 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/AuthoringProperties.java @@ -0,0 +1,57 @@ +package com.iflytek.skillhub.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * Feature configuration for the skill authoring and validation platform + * ({@code skillhub.authoring.*}). This layer covers the draft authoring and + * structure-validation core; runtime-binding configuration (script execution + * backends, LLM endpoints, MCP servers) arrives with the runtime layer. + */ +@ConfigurationProperties(prefix = "skillhub.authoring") +public class AuthoringProperties { + + /** Root directory for per-run isolated working directories. */ + private String workspaceRoot = System.getProperty("java.io.tmpdir") + "/skillhub-authoring"; + + /** Concurrent validation runs executed in-process. */ + private int executorThreads = 4; + + /** Hard wall-clock cap for one whole validation run. */ + private long runTimeoutMs = 900_000; + + /** Active runs older than this are swept to TIMED_OUT by the maintenance task. */ + private int staleRunMinutes = 30; + + public String getWorkspaceRoot() { + return workspaceRoot; + } + + public void setWorkspaceRoot(String workspaceRoot) { + this.workspaceRoot = workspaceRoot; + } + + public int getExecutorThreads() { + return executorThreads; + } + + public void setExecutorThreads(int executorThreads) { + this.executorThreads = executorThreads; + } + + public long getRunTimeoutMs() { + return runTimeoutMs; + } + + public void setRunTimeoutMs(long runTimeoutMs) { + this.runTimeoutMs = runTimeoutMs; + } + + public int getStaleRunMinutes() { + return staleRunMinutes; + } + + public void setStaleRunMinutes(int staleRunMinutes) { + this.staleRunMinutes = staleRunMinutes; + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/authoring/SkillAuthoringController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/authoring/SkillAuthoringController.java new file mode 100644 index 00000000..1b67a284 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/authoring/SkillAuthoringController.java @@ -0,0 +1,228 @@ +package com.iflytek.skillhub.controller.authoring; + +import com.iflytek.skillhub.controller.BaseApiController; +import com.iflytek.skillhub.domain.authoring.SkillDraft; +import com.iflytek.skillhub.domain.authoring.service.SkillDraftService; +import com.iflytek.skillhub.domain.authoring.service.ValidationRunService; +import com.iflytek.skillhub.domain.authoring.validation.ValidationRun; +import com.iflytek.skillhub.dto.ApiResponse; +import com.iflytek.skillhub.dto.CreateDraftRequest; +import com.iflytek.skillhub.dto.DraftFileContentResponse; +import com.iflytek.skillhub.dto.DraftFileResponse; +import com.iflytek.skillhub.dto.DraftResponse; +import com.iflytek.skillhub.dto.SaveDraftFileRequest; +import com.iflytek.skillhub.dto.SaveDraftFileResponse; +import com.iflytek.skillhub.dto.ValidationEventResponse; +import com.iflytek.skillhub.dto.ValidationFindingResponse; +import com.iflytek.skillhub.dto.ValidationRunResponse; +import com.iflytek.skillhub.service.authoring.ValidationRunOrchestrator; +import io.swagger.v3.oas.annotations.Operation; +import jakarta.validation.Valid; +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.List; +import java.util.Set; +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.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestAttribute; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +/** + * REST endpoints for skill draft authoring and structure validation. Drafts + * own a SKILL.md scaffold and arbitrary resource files; validation runs + * execute the STRUCTURE layer and report findings with fix suggestions. + * Runtime binding, behavior execution and the publish integration arrive with + * the later layers. + */ +@RestController +@RequestMapping({"/api/v1/authoring", "/api/web/authoring"}) +public class SkillAuthoringController extends BaseApiController { + + private final SkillDraftService draftService; + private final ValidationRunService runService; + private final ValidationRunOrchestrator orchestrator; + + public SkillAuthoringController(com.iflytek.skillhub.dto.ApiResponseFactory responseFactory, + SkillDraftService draftService, + ValidationRunService runService, + ValidationRunOrchestrator orchestrator) { + super(responseFactory); + this.draftService = draftService; + this.runService = runService; + this.orchestrator = orchestrator; + } + + // ---------------------------------------------------------------- drafts + + @Operation(operationId = "createAuthoringDraft", summary = "Create a draft seeded with a SKILL.md scaffold") + @PostMapping("/drafts") + public ApiResponse createDraft(@Valid + @RequestBody CreateDraftRequest request, + @RequestAttribute("userId") String userId, + @RequestAttribute(value = "platformRoles", required = false) + Set platformRoles) { + SkillDraft draft = draftService.createDraft( + request.namespaceSlug(), userId, request.name(), request.requirement(), platformRoles); + return ok("response.success.created", DraftResponse.from(draft)); + } + + @Operation(operationId = "listAuthoringDrafts", summary = "List the current user's drafts") + @GetMapping("/drafts") + public ApiResponse> listDrafts(@RequestAttribute("userId") String userId) { + return ok("response.success", draftService.listDrafts(userId).stream() + .map(DraftResponse::from) + .toList()); + } + + @Operation(operationId = "getAuthoringDraft", summary = "Get one draft owned by the current user") + @GetMapping("/drafts/{draftId}") + public ApiResponse getDraft(@PathVariable Long draftId, + @RequestAttribute("userId") String userId, + @RequestAttribute(value = "platformRoles", required = false) + Set platformRoles) { + return ok("response.success", + DraftResponse.from(draftService.getOwnedDraft(draftId, userId, platformRoles))); + } + + @Operation(operationId = "deleteAuthoringDraft", summary = "Delete a draft and its files") + @DeleteMapping("/drafts/{draftId}") + public ApiResponse deleteDraft(@PathVariable Long draftId, + @RequestAttribute("userId") String userId, + @RequestAttribute(value = "platformRoles", required = false) + Set platformRoles) { + draftService.deleteDraft(draftId, userId, platformRoles); + return ok("response.success.deleted", null); + } + + // ---------------------------------------------------------------- draft files + + @Operation(operationId = "listDraftFiles", summary = "List a draft's files with metadata") + @GetMapping("/drafts/{draftId}/files") + public ApiResponse> listFiles(@PathVariable Long draftId) { + return ok("response.success", draftService.listFiles(draftId).stream() + .map(DraftFileResponse::from) + .toList()); + } + + @Operation(operationId = "saveDraftFile", summary = "Create or update one draft file") + @PutMapping("/drafts/{draftId}/files") + public ApiResponse saveFile(@PathVariable Long draftId, + @Valid + @RequestBody SaveDraftFileRequest request, + @RequestAttribute("userId") String userId, + @RequestAttribute(value = "platformRoles", required = false) + Set platformRoles) { + byte[] content = request.isBase64() + ? Base64.getDecoder().decode(request.content()) + : request.content().getBytes(StandardCharsets.UTF_8); + return ok("response.success.updated", SaveDraftFileResponse.from( + draftService.saveFile(draftId, userId, request.path(), content, + request.contentType(), request.expectedRevision(), platformRoles))); + } + + @Operation(operationId = "readDraftFile", summary = "Read one draft file's content") + @GetMapping("/drafts/{draftId}/files/content") + public ApiResponse readFile(@PathVariable Long draftId, + @RequestParam("path") String path, + @RequestAttribute("userId") String userId, + @RequestAttribute(value = "platformRoles", required = false) + Set platformRoles) { + SkillDraftService.FileContent content = + draftService.readFile(draftId, userId, path, platformRoles); + return ok("response.success", new DraftFileContentResponse( + content.file().getFilePath(), + content.file().getSha256(), + content.file().getSize(), + content.file().getContentType(), + content.asText())); + } + + @Operation(operationId = "deleteDraftFile", summary = "Delete one draft file") + @DeleteMapping("/drafts/{draftId}/files") + public ApiResponse deleteFile(@PathVariable Long draftId, + @RequestParam("path") String path, + @RequestParam(value = "expectedRevision", required = false) + Integer expectedRevision, + @RequestAttribute("userId") String userId, + @RequestAttribute(value = "platformRoles", required = false) + Set platformRoles) { + draftService.deleteFile(draftId, userId, path, expectedRevision, platformRoles); + return ok("response.success.deleted", null); + } + + // ---------------------------------------------------------------- validation runs + + @Operation(operationId = "startValidationRun", summary = "Start a validation run for the draft's current revision") + @PostMapping("/drafts/{draftId}/runs") + public ApiResponse startRun(@PathVariable Long draftId, + @RequestAttribute("userId") String userId, + @RequestAttribute(value = "platformRoles", required = false) + Set platformRoles) { + ValidationRun run = runService.startRun(draftId, userId, platformRoles); + orchestrator.submit(run.getId()); + return ok("response.success.created", ValidationRunResponse.from(run)); + } + + @Operation(operationId = "listValidationRuns", summary = "List the draft's validation runs") + @GetMapping("/drafts/{draftId}/runs") + public ApiResponse> listRuns(@PathVariable Long draftId) { + return ok("response.success", runService.listRuns(draftId).stream() + .map(ValidationRunResponse::from) + .toList()); + } + + @Operation(operationId = "getValidationRun", summary = "Get one validation run") + @GetMapping("/runs/{runId}") + public ApiResponse getRun(@PathVariable Long runId, + @RequestAttribute("userId") String userId, + @RequestAttribute(value = "platformRoles", required = false) + Set platformRoles) { + ValidationRun run = runService.getOwnedRun(runId, userId, platformRoles); + return ok("response.success", ValidationRunResponse.from(run)); + } + + @Operation(operationId = "cancelValidationRun", summary = "Request cooperative cancellation of a run") + @PostMapping("/runs/{runId}/cancel") + public ApiResponse cancelRun(@PathVariable Long runId, + @RequestAttribute("userId") String userId, + @RequestAttribute(value = "platformRoles", required = false) + Set platformRoles) { + ValidationRun run = orchestrator.requestCancel(runId, userId, platformRoles); + return ok("response.success.updated", ValidationRunResponse.from(run)); + } + + // ---------------------------------------------------------------- events & findings + + @Operation(operationId = "listValidationEvents", summary = "List run events after a sequence cursor (polling)") + @GetMapping("/runs/{runId}/events") + public ApiResponse> listEvents( + @PathVariable Long runId, + @RequestParam(value = "afterSeq", required = false) Integer afterSeq, + @RequestAttribute("userId") String userId, + @RequestAttribute(value = "platformRoles", required = false) + Set platformRoles) { + runService.getOwnedRun(runId, userId, platformRoles); + return ok("response.success", runService.listEvents(runId, afterSeq).stream() + .map(ValidationEventResponse::from) + .toList()); + } + + @Operation(operationId = "listValidationFindings", summary = "List a run's findings with fix suggestions") + @GetMapping("/runs/{runId}/findings") + public ApiResponse> listFindings( + @PathVariable Long runId, + @RequestAttribute("userId") String userId, + @RequestAttribute(value = "platformRoles", required = false) + Set platformRoles) { + runService.getOwnedRun(runId, userId, platformRoles); + return ok("response.success", runService.listFindings(runId).stream() + .map(ValidationFindingResponse::from) + .toList()); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/authoring/ValidationStreamController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/authoring/ValidationStreamController.java new file mode 100644 index 00000000..92e6ead2 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/authoring/ValidationStreamController.java @@ -0,0 +1,68 @@ +package com.iflytek.skillhub.controller.authoring; + +import com.iflytek.skillhub.domain.authoring.service.ValidationRunService; +import com.iflytek.skillhub.domain.authoring.validation.ValidationEvent; +import com.iflytek.skillhub.domain.authoring.validation.ValidationRun; +import com.iflytek.skillhub.service.authoring.ValidationEventBroadcaster; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import java.util.List; +import java.util.Set; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestAttribute; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +/** + * Server-sent events for validation runs. Browsers authenticate via the session + * cookie (EventSource cannot send headers); token clients use the /api/v1 variant. + * Event ids are the per-run sequence numbers, so reconnecting clients resume from + * Last-Event-ID without gaps or duplicates. + */ +@Tag(name = "Skill authoring") +@RestController +@RequestMapping({"/api/v1/authoring", "/api/web/authoring"}) +public class ValidationStreamController { + + private final ValidationRunService runService; + private final ValidationEventBroadcaster broadcaster; + + public ValidationStreamController(ValidationRunService runService, + ValidationEventBroadcaster broadcaster) { + this.runService = runService; + this.broadcaster = broadcaster; + } + + @Operation(operationId = "streamValidationEvents", + summary = "Stream run events over SSE; resumes from Last-Event-ID") + @GetMapping(value = "/runs/{runId}/events/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE) + public SseEmitter streamEvents(@PathVariable Long runId, + @RequestParam(value = "afterSeq", required = false) Integer afterSeq, + @RequestHeader(value = "Last-Event-ID", required = false) String lastEventId, + @RequestAttribute("userId") String userId, + @RequestAttribute(value = "platformRoles", required = false) + Set platformRoles) { + ValidationRun run = runService.getOwnedRun(runId, userId, platformRoles); + int lastSeq = resolveCursor(afterSeq, lastEventId); + List replay = runService.listEvents(run.getId(), lastSeq); + return broadcaster.subscribe(run.getId(), lastSeq, replay); + } + + private int resolveCursor(Integer afterSeq, String lastEventId) { + int fromParam = afterSeq == null ? 0 : Math.max(0, afterSeq); + int fromHeader = 0; + if (lastEventId != null && !lastEventId.isBlank()) { + try { + fromHeader = Math.max(0, Integer.parseInt(lastEventId.trim())); + } catch (NumberFormatException ignored) { + // malformed Last-Event-ID restarts from the beginning + } + } + return Math.max(fromParam, fromHeader); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/CreateDraftRequest.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/CreateDraftRequest.java new file mode 100644 index 00000000..cc59ea4d --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/CreateDraftRequest.java @@ -0,0 +1,11 @@ +package com.iflytek.skillhub.dto; + +import jakarta.validation.constraints.NotBlank; + +/** Request body for creating a skill authoring draft. */ +public record CreateDraftRequest( + @NotBlank(message = "{error.badRequest}") String namespaceSlug, + @NotBlank(message = "{error.badRequest}") String name, + String requirement +) { +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/DraftFileContentResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/DraftFileContentResponse.java new file mode 100644 index 00000000..a1d283c0 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/DraftFileContentResponse.java @@ -0,0 +1,11 @@ +package com.iflytek.skillhub.dto; + +/** Draft file content, always delivered as text (draft files are text formats). */ +public record DraftFileContentResponse( + String path, + String sha256, + Long size, + String contentType, + String content +) { +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/DraftFileResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/DraftFileResponse.java new file mode 100644 index 00000000..f2f25c2b --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/DraftFileResponse.java @@ -0,0 +1,25 @@ +package com.iflytek.skillhub.dto; + +import com.iflytek.skillhub.domain.authoring.DraftFile; +import java.time.Instant; + +/** Draft file metadata (content is fetched separately). */ +public record DraftFileResponse( + Long id, + String path, + String sha256, + Long size, + String contentType, + Instant updatedAt +) { + + public static DraftFileResponse from(DraftFile file) { + return new DraftFileResponse( + file.getId(), + file.getFilePath(), + file.getSha256(), + file.getSize(), + file.getContentType(), + file.getUpdatedAt()); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/DraftResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/DraftResponse.java new file mode 100644 index 00000000..60aa7bb9 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/DraftResponse.java @@ -0,0 +1,41 @@ +package com.iflytek.skillhub.dto; + +import com.iflytek.skillhub.domain.authoring.SkillDraft; +import java.time.Instant; + +/** Draft summary/aggregate exposed to the authoring UI. */ +public record DraftResponse( + Long id, + Long namespaceId, + String name, + String requirement, + Integer revision, + String contentDigest, + boolean validated, + Integer validatedRevision, + Long validatedRunId, + Long submittedSkillId, + Long submittedVersionId, + Instant submittedAt, + Instant createdAt, + Instant updatedAt +) { + + public static DraftResponse from(SkillDraft draft) { + return new DraftResponse( + draft.getId(), + draft.getNamespaceId(), + draft.getName(), + draft.getRequirement(), + draft.getRevision(), + draft.getContentDigest(), + draft.isCurrentRevisionValidated(), + draft.getValidatedRevision(), + draft.getValidatedRunId(), + draft.getSubmittedSkillId(), + draft.getSubmittedVersionId(), + draft.getSubmittedAt(), + draft.getCreatedAt(), + draft.getUpdatedAt()); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SaveDraftFileRequest.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SaveDraftFileRequest.java new file mode 100644 index 00000000..88c82ab0 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SaveDraftFileRequest.java @@ -0,0 +1,21 @@ +package com.iflytek.skillhub.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +/** + * Request body for saving one draft file. Text is the default encoding; base64 + * transports binary-safe content. + */ +public record SaveDraftFileRequest( + @NotBlank(message = "{error.badRequest}") String path, + @NotNull(message = "{error.badRequest}") String content, + String encoding, + String contentType, + Integer expectedRevision +) { + + public boolean isBase64() { + return "base64".equalsIgnoreCase(encoding); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SaveDraftFileResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SaveDraftFileResponse.java new file mode 100644 index 00000000..e0b16202 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SaveDraftFileResponse.java @@ -0,0 +1,20 @@ +package com.iflytek.skillhub.dto; + +import com.iflytek.skillhub.domain.authoring.service.SkillDraftService; + +/** Result of saving one draft file: updated draft plus file metadata. */ +public record SaveDraftFileResponse( + DraftResponse draft, + DraftFileResponse file, + boolean created, + boolean revisionAdvanced +) { + + public static SaveDraftFileResponse from(SkillDraftService.SaveFileOutcome outcome) { + return new SaveDraftFileResponse( + DraftResponse.from(outcome.draft()), + DraftFileResponse.from(outcome.file()), + outcome.created(), + outcome.revisionAdvanced()); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/ValidationEventResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/ValidationEventResponse.java new file mode 100644 index 00000000..9a7265b9 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/ValidationEventResponse.java @@ -0,0 +1,28 @@ +package com.iflytek.skillhub.dto; + +import com.iflytek.skillhub.domain.authoring.validation.ValidationEvent; +import java.time.Instant; +import java.util.Map; + +/** One persisted validation event (log line, tool call, finding, phase marker...). */ +public record ValidationEventResponse( + Long id, + Long runId, + Integer seq, + String type, + String phase, + Map payload, + Instant createdAt +) { + + public static ValidationEventResponse from(ValidationEvent event) { + return new ValidationEventResponse( + event.getId(), + event.getRunId(), + event.getSeq(), + event.getEventType().name(), + event.getPhase(), + event.getPayload() == null ? Map.of() : event.getPayload(), + event.getCreatedAt()); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/ValidationFindingResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/ValidationFindingResponse.java new file mode 100644 index 00000000..a0d85379 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/ValidationFindingResponse.java @@ -0,0 +1,41 @@ +package com.iflytek.skillhub.dto; + +import com.iflytek.skillhub.domain.authoring.FixSuggestion; +import com.iflytek.skillhub.domain.authoring.validation.ValidationFinding; +import java.time.Instant; + +/** + * One validation finding. {@code suggestion} carries the machine-applicable fix + * (patches with old/new values) when one exists, for preview and confirmed apply. + */ +public record ValidationFindingResponse( + Long id, + Long runId, + String layer, + String ruleCode, + String severity, + String filePath, + String location, + String message, + FixSuggestion suggestion, + String status, + Integer appliedRevision, + Instant createdAt +) { + + public static ValidationFindingResponse from(ValidationFinding finding) { + return new ValidationFindingResponse( + finding.getId(), + finding.getRunId(), + finding.getLayer().name(), + finding.getRuleCode(), + finding.getSeverity().name(), + finding.getFilePath(), + finding.getLocation(), + finding.getMessage(), + finding.getSuggestion(), + finding.getStatus().name(), + finding.getAppliedRevision(), + finding.getCreatedAt()); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/ValidationRunResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/ValidationRunResponse.java new file mode 100644 index 00000000..921a05d1 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/ValidationRunResponse.java @@ -0,0 +1,46 @@ +package com.iflytek.skillhub.dto; + +import com.iflytek.skillhub.domain.authoring.validation.ValidationRun; +import java.time.Instant; +import java.util.Map; + +/** Validation run state and outcome counters. */ +public record ValidationRunResponse( + Long id, + Long draftId, + Integer draftRevision, + String status, + boolean cancelRequested, + boolean active, + boolean terminal, + Integer errorCount, + Integer warningCount, + String triggeredBy, + Instant startedAt, + Instant finishedAt, + Instant createdAt, + Map summary +) { + + public static ValidationRunResponse from(ValidationRun run) { + return from(run, run.getSummary()); + } + + public static ValidationRunResponse from(ValidationRun run, Map summary) { + return new ValidationRunResponse( + run.getId(), + run.getDraftId(), + run.getDraftRevision(), + run.getStatus().name(), + run.isCancelRequested(), + run.isActive(), + run.getStatus().isTerminal(), + run.getErrorCount(), + run.getWarningCount(), + run.getTriggeredBy(), + run.getStartedAt(), + run.getFinishedAt(), + run.getCreatedAt(), + summary == null ? Map.of() : summary); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/authoring/ValidationEventBroadcaster.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/authoring/ValidationEventBroadcaster.java new file mode 100644 index 00000000..0dc5eaeb --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/authoring/ValidationEventBroadcaster.java @@ -0,0 +1,153 @@ +package com.iflytek.skillhub.service.authoring; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.iflytek.skillhub.domain.authoring.validation.ValidationEvent; +import com.iflytek.skillhub.domain.authoring.validation.ValidationEventType; +import java.io.IOException; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArrayList; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +/** + * In-memory SSE hub for validation runs. Each subscriber tracks the last sequence + * number it saw, so replay (Last-Event-ID / afterSeq) and live delivery can be + * interleaved without duplicates. The hub is per-instance by design: the current + * deployment runs one backend node, and remote clients always have the polling + * endpoint as a fallback. + */ +@Component +public class ValidationEventBroadcaster { + + /** SSE connection lifetime cap; clients reconnect with Last-Event-ID on expiry. */ + static final long EMITTER_TIMEOUT_MS = 30L * 60 * 1000; + + private final Map> sessionsByRun = new ConcurrentHashMap<>(); + private final ObjectMapper objectMapper; + + public ValidationEventBroadcaster(ObjectMapper objectMapper) { + this.objectMapper = objectMapper; + } + + /** + * Registers a live emitter and replays already-persisted events through it. The + * session cursor makes replay and concurrent live delivery duplicate-free and + * order-preserving: every send fires only for sequence numbers the session has + * not delivered yet. + * + * @param runId the run to subscribe to + * @param lastSeq the subscriber's last seen sequence number (Last-Event-ID) + * @param replay persisted events after {@code lastSeq}, oldest first + */ + public SseEmitter subscribe(Long runId, int lastSeq, List replay) { + EmitterSession session = new EmitterSession(lastSeq); + sessionsByRun.computeIfAbsent(runId, key -> new CopyOnWriteArrayList<>()).add(session); + Runnable detach = () -> detach(runId, session); + session.emitter().onCompletion(detach); + session.emitter().onTimeout(detach); + session.emitter().onError(error -> detach.run()); + + boolean terminal = false; + for (ValidationEvent event : replay) { + if (!session.send(event.getSeq(), envelope(event))) { + detach(runId, session); + return session.emitter(); + } + if (event.getEventType() == ValidationEventType.RUN_FINISHED) { + terminal = true; + } + } + if (terminal) { + // run already finished: close the stream so clients stop reconnecting + session.emitter().complete(); + } + return session.emitter(); + } + + /** Pushes one persisted event to every live subscriber of the run. */ + public void publish(Long runId, ValidationEvent event) { + List sessions = sessionsByRun.get(runId); + if (sessions == null) { + return; + } + String envelope = envelope(event); + for (EmitterSession session : sessions) { + if (!session.send(event.getSeq(), envelope)) { + detach(runId, session); + } + } + } + + /** Sends the terminal event and completes all subscribers of a finished run. */ + public void completeRun(Long runId, ValidationEvent terminalEvent) { + publish(runId, terminalEvent); + List sessions = sessionsByRun.remove(runId); + if (sessions == null) { + return; + } + for (EmitterSession session : sessions) { + session.emitter().complete(); + } + } + + private void detach(Long runId, EmitterSession session) { + List sessions = sessionsByRun.get(runId); + if (sessions != null) { + sessions.remove(session); + } + } + + private String envelope(ValidationEvent event) { + Map payload = event.getPayload() == null ? Map.of() : event.getPayload(); + Map envelope = Map.of( + "seq", event.getSeq(), + "type", event.getEventType().name(), + "phase", event.getPhase() == null ? "" : event.getPhase(), + "payload", payload, + "createdAt", event.getCreatedAt().toString()); + try { + return objectMapper.writeValueAsString(envelope); + } catch (IOException exception) { + return "{\"seq\":" + event.getSeq() + ",\"type\":\"" + event.getEventType() + "\"}"; + } + } + + /** One subscriber connection with its own delivery cursor and serialized writes. */ + static final class EmitterSession { + + private final SseEmitter emitter; + private int lastSeq; + + EmitterSession(int lastSeq) { + this.emitter = new SseEmitter(EMITTER_TIMEOUT_MS); + this.lastSeq = lastSeq; + } + + SseEmitter emitter() { + return emitter; + } + + int lastSeq() { + return lastSeq; + } + + /** Sends unless the event predates this session's cursor; false means the pipe broke. */ + synchronized boolean send(int seq, String envelope) { + if (seq <= lastSeq) { + return true; + } + try { + emitter.send(SseEmitter.event() + .id(String.valueOf(seq)) + .data(envelope, MediaType.APPLICATION_JSON)); + lastSeq = seq; + return true; + } catch (IOException | IllegalStateException exception) { + return false; + } + } + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/authoring/ValidationRunOrchestrator.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/authoring/ValidationRunOrchestrator.java new file mode 100644 index 00000000..fe20a821 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/authoring/ValidationRunOrchestrator.java @@ -0,0 +1,244 @@ +package com.iflytek.skillhub.service.authoring; + +import com.iflytek.skillhub.domain.authoring.SkillDraft; +import com.iflytek.skillhub.domain.authoring.service.DraftStructureValidator; +import com.iflytek.skillhub.domain.authoring.service.SkillDraftService; +import com.iflytek.skillhub.domain.authoring.service.ValidationRunService; +import com.iflytek.skillhub.domain.authoring.validation.FindingDraft; +import com.iflytek.skillhub.domain.authoring.validation.FindingSeverity; +import com.iflytek.skillhub.domain.authoring.validation.ValidationEvent; +import com.iflytek.skillhub.domain.authoring.validation.ValidationEventType; +import com.iflytek.skillhub.domain.authoring.validation.ValidationFinding; +import com.iflytek.skillhub.domain.authoring.validation.ValidationLayer; +import com.iflytek.skillhub.domain.authoring.validation.ValidationRun; +import com.iflytek.skillhub.domain.authoring.validation.ValidationRunStatus; +import com.iflytek.skillhub.domain.shared.exception.DomainConflictException; +import com.iflytek.skillhub.domain.skill.validation.PackageEntry; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.Executor; +import java.util.concurrent.atomic.AtomicBoolean; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.stereotype.Service; + +/** + * Executes validation runs for skill drafts. This layer runs the STRUCTURE + * phase: package layout, SKILL.md frontmatter and file limits, reported as + * structured findings with fix suggestions. The CONFIG and BEHAVIOR phases + * (runtime binding, MCP probing, script/LLM task execution) arrive with the + * runtime layer. + */ +@Service +public class ValidationRunOrchestrator { + + private static final Logger log = LoggerFactory.getLogger(ValidationRunOrchestrator.class); + private static final int MAX_EVENT_TEXT = 4_000; + + private final ValidationRunService runService; + private final SkillDraftService draftService; + private final DraftStructureValidator structureValidator; + private final ValidationEventBroadcaster broadcaster; + private final Executor executor; + + private final Map cancelFlags = new ConcurrentHashMap<>(); + + /** Serializes the RUN_FINISHED emission so it happens exactly once per run. */ + private final Map terminalLocks = new ConcurrentHashMap<>(); + + public ValidationRunOrchestrator(ValidationRunService runService, + SkillDraftService draftService, + DraftStructureValidator structureValidator, + ValidationEventBroadcaster broadcaster, + @Qualifier("authoringValidationExecutor") Executor executor) { + this.runService = runService; + this.draftService = draftService; + this.structureValidator = structureValidator; + this.broadcaster = broadcaster; + this.executor = executor; + } + + /** Enqueues one QUEUED run for execution. */ + public void submit(Long runId) { + executor.execute(() -> executeRun(runId)); + } + + /** + * Requests cooperative cancellation. QUEUED runs settle immediately; running runs + * settle when their executor observes the flag. + */ + public ValidationRun requestCancel(Long runId, String userId, Set platformRoles) { + ValidationRun run = runService.requestCancel(runId, userId, platformRoles); + cancelFlags.computeIfAbsent(runId, key -> new AtomicBoolean()).set(true); + if (run.getStatus().isTerminal()) { + emitTerminalOnce(runId); + } + return run; + } + + /** + * Idempotent terminal settle: persists the outcome (first writer wins), appends the + * RUN_FINISHED event exactly once, and completes SSE subscribers. + */ + public void settle(Long runId, ValidationRunStatus status, int errorCount, int warningCount, + Map summary) { + runService.settleIfActive(runId, status, errorCount, warningCount, summary); + emitTerminalOnce(runId); + } + + // ---------------------------------------------------------------- execution + + private void executeRun(Long runId) { + try { + ValidationRun run = runService.claimForExecution(runId); + if (run.getStatus().isTerminal()) { + emitTerminalOnce(runId); + return; + } + executePipeline(run); + } catch (DomainConflictException exception) { + // cancelled while queued, or already claimed/settled elsewhere + log.info("Validation run {} no longer claimable: {}", runId, exception.getMessage()); + emitTerminalOnce(runId); + } catch (Exception exception) { + log.error("Validation run {} crashed", runId, exception); + settle(runId, ValidationRunStatus.FAILED, 1, 0, + Map.of("internalError", truncate(String.valueOf(exception.getMessage()), 500))); + } finally { + cancelFlags.remove(runId); + terminalLocks.remove(runId); + } + } + + private void executePipeline(ValidationRun run) { + Long runId = run.getId(); + SkillDraft draft = draftService.getDraft(run.getDraftId()); + List entries = draftService.materializeEntries(draft.getId()); + RunContext context = new RunContext(runId); + + emit(runId, ValidationEventType.RUN_STARTED, null, Map.of( + "draftId", draft.getId(), + "draftName", draft.getName(), + "draftRevision", run.getDraftRevision())); + + try { + // ---- STRUCTURE layer + emit(runId, ValidationEventType.PHASE_STARTED, "STRUCTURE", Map.of()); + DraftStructureValidator.StructureReport structure = + structureValidator.validate(draft.getName(), draft.getRequirement(), entries); + structure.findings().forEach(context::recordFinding); + emit(runId, ValidationEventType.PHASE_FINISHED, "STRUCTURE", Map.of( + "findings", structure.findings().size(), + "errors", context.errorCount(ValidationLayer.STRUCTURE), + "warnings", context.warningCount(ValidationLayer.STRUCTURE))); + + if (cancelFlags.getOrDefault(runId, new AtomicBoolean(false)).get()) { + settle(runId, ValidationRunStatus.CANCELLED, context.errorCount, context.warningCount, + summary(context)); + return; + } + + settle(runId, + context.errorCount == 0 ? ValidationRunStatus.SUCCEEDED : ValidationRunStatus.FAILED, + context.errorCount, context.warningCount, summary(context)); + } catch (Exception exception) { + log.error("Validation pipeline for run {} failed", runId, exception); + settle(runId, ValidationRunStatus.FAILED, context.errorCount + 1, context.warningCount, + Map.of("internalError", truncate(String.valueOf(exception.getMessage()), 500))); + } + } + + // ---------------------------------------------------------------- events + + private void emit(Long runId, ValidationEventType type, String phase, Map payload) { + ValidationEvent event = runService.appendEvent(runId, type, phase, payload); + broadcaster.publish(runId, event); + } + + /** Appends the RUN_FINISHED event exactly once per run, then completes SSE subscribers. */ + private void emitTerminalOnce(Long runId) { + Object lock = terminalLocks.computeIfAbsent(runId, key -> new Object()); + synchronized (lock) { + for (ValidationEvent event : runService.listEvents(runId, null)) { + if (event.getEventType() == ValidationEventType.RUN_FINISHED) { + broadcaster.completeRun(runId, event); + return; + } + } + ValidationRun run = runService.getRun(runId); + ValidationEvent terminal = runService.appendEvent(runId, + ValidationEventType.RUN_FINISHED, null, Map.of( + "status", run.getStatus().name(), + "errorCount", run.getErrorCount(), + "warningCount", run.getWarningCount())); + broadcaster.completeRun(runId, terminal); + } + } + + // ---------------------------------------------------------------- small helpers + + private Map summary(RunContext context) { + Map summary = new java.util.LinkedHashMap<>(); + summary.put("structureErrors", context.errorCount(ValidationLayer.STRUCTURE)); + summary.put("structureWarnings", context.warningCount(ValidationLayer.STRUCTURE)); + summary.put("errorCount", context.errorCount); + summary.put("warningCount", context.warningCount); + return summary; + } + + private static String truncate(String value, int limit) { + if (value == null) { + return ""; + } + return value.length() <= limit ? value : value.substring(0, limit) + "…"; + } + + /** Per-run finding bookkeeping; each finding is persisted and emitted as an event. */ + private final class RunContext { + + private final Long runId; + private final List findings = new ArrayList<>(); + private int errorCount; + private int warningCount; + + RunContext(Long runId) { + this.runId = runId; + } + + int errorCount(ValidationLayer layer) { + return (int) findings.stream() + .filter(finding -> finding.layer() == layer + && finding.severity() == FindingSeverity.ERROR) + .count(); + } + + int warningCount(ValidationLayer layer) { + return (int) findings.stream() + .filter(finding -> finding.layer() == layer + && finding.severity() == FindingSeverity.WARNING) + .count(); + } + + void recordFinding(FindingDraft draft) { + findings.add(draft); + if (draft.severity() == FindingSeverity.ERROR) { + errorCount++; + } else if (draft.severity() == FindingSeverity.WARNING) { + warningCount++; + } + ValidationFinding persisted = runService.recordFinding(runId, draft); + emit(runId, ValidationEventType.FINDING, draft.layer().name(), Map.of( + "findingId", persisted.getId(), + "layer", draft.layer().name(), + "ruleCode", draft.ruleCode(), + "severity", draft.severity().name(), + "filePath", draft.filePath() == null ? "" : draft.filePath(), + "message", truncate(draft.message(), MAX_EVENT_TEXT), + "hasSuggestion", draft.suggestion() != null)); + } + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/task/ValidationRunMaintenanceTask.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/task/ValidationRunMaintenanceTask.java new file mode 100644 index 00000000..f7c687c8 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/task/ValidationRunMaintenanceTask.java @@ -0,0 +1,117 @@ +package com.iflytek.skillhub.task; + +import com.iflytek.skillhub.config.AuthoringProperties; +import com.iflytek.skillhub.domain.authoring.validation.ValidationRun; +import com.iflytek.skillhub.domain.authoring.validation.ValidationRunStatus; +import com.iflytek.skillhub.domain.authoring.service.ValidationRunService; +import com.iflytek.skillhub.service.authoring.ValidationRunOrchestrator; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.time.Instant; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +/** + * Bounded recovery for validation runs: active runs older than the configured stale + * threshold are settled as TIMED_OUT (covering executor crashes and restarts), and + * orphaned workspace directories are removed best-effort. + */ +@Component +public class ValidationRunMaintenanceTask { + + private static final Logger log = LoggerFactory.getLogger(ValidationRunMaintenanceTask.class); + + private final ValidationRunService runService; + private final ValidationRunOrchestrator orchestrator; + private final AuthoringProperties properties; + + public ValidationRunMaintenanceTask(ValidationRunService runService, + ValidationRunOrchestrator orchestrator, + AuthoringProperties properties) { + this.runService = runService; + this.orchestrator = orchestrator; + this.properties = properties; + } + + @Scheduled(fixedDelayString = "${skillhub.authoring.maintenance-interval-ms:30000}") + public void sweep() { + settleStaleRuns(); + cleanupWorkspaces(); + } + + private void settleStaleRuns() { + Instant cutoff = Instant.now().minus(Duration.ofMinutes(properties.getStaleRunMinutes())); + List stale = runService.findActiveRunsCreatedBefore(cutoff); + for (ValidationRun run : stale) { + log.warn("Settling stale validation run {} (draft {}, status {}) as TIMED_OUT", + run.getId(), run.getDraftId(), run.getStatus()); + orchestrator.settle(run.getId(), ValidationRunStatus.TIMED_OUT, + run.getErrorCount(), run.getWarningCount(), + Map.of("reason", "stale run swept by maintenance task")); + } + } + + /** Removes workspace directories of runs that are no longer active. */ + private void cleanupWorkspaces() { + Path root = Path.of(properties.getWorkspaceRoot()); + if (!Files.isDirectory(root)) { + return; + } + try (var directories = Files.list(root)) { + directories.filter(Files::isDirectory) + .filter(directory -> directory.getFileName().toString().startsWith("run-")) + .forEach(this::cleanupIfOrphaned); + } catch (IOException exception) { + log.warn("Failed to list validation workspaces under {}: {}", root, exception.getMessage()); + } + } + + private void cleanupIfOrphaned(Path directory) { + String fileName = directory.getFileName().toString(); + long runId; + try { + runId = Long.parseLong(fileName.substring("run-".length())); + } catch (NumberFormatException exception) { + return; + } + try { + ValidationRun run = runService.getRun(runId); + if (run.isActive()) { + return; + } + try (var stream = Files.walk(directory)) { + stream.sorted(Comparator.reverseOrder()).forEach(path -> { + try { + Files.deleteIfExists(path); + } catch (IOException ignored) { + // best-effort cleanup + } + }); + } + } catch (Exception exception) { + // unknown run id (workspace of a deleted draft) — remove the directory too + deleteQuietly(directory); + } + } + + private void deleteQuietly(Path directory) { + try (var stream = Files.walk(directory)) { + stream.sorted(Comparator.reverseOrder()).forEach(path -> { + try { + Files.deleteIfExists(path); + } catch (IOException ignored) { + // best-effort cleanup + } + }); + } catch (IOException ignored) { + // best-effort cleanup + } + } +} diff --git a/server/skillhub-app/src/main/resources/application.yml b/server/skillhub-app/src/main/resources/application.yml index 73660e11..fdbb39a4 100644 --- a/server/skillhub-app/src/main/resources/application.yml +++ b/server/skillhub-app/src/main/resources/application.yml @@ -134,6 +134,14 @@ spring: enable: ${SPRING_MAIL_SMTP_STARTTLS_ENABLE:false} skillhub: + authoring: + # Root directory for per-run isolated validation workspaces + workspace-root: ${SKILLHUB_AUTHORING_WORKSPACE_ROOT:${java.io.tmpdir}/skillhub-authoring} + executor-threads: ${SKILLHUB_AUTHORING_EXECUTOR_THREADS:4} + # Hard wall-clock cap for one whole validation run (15 minutes) + run-timeout-ms: ${SKILLHUB_AUTHORING_RUN_TIMEOUT_MS:900000} + # Active runs older than this are swept to TIMED_OUT (crash recovery) + stale-run-minutes: ${SKILLHUB_AUTHORING_STALE_RUN_MINUTES:30} suite: # Fail closed for deployment modes whose upgrade topology is unknown. Single-instance # distributions explicitly enable this after ruling out mixed application versions. diff --git a/server/skillhub-app/src/main/resources/db/migration/V68__authoring_platform.sql b/server/skillhub-app/src/main/resources/db/migration/V68__authoring_platform.sql new file mode 100644 index 00000000..d986818a --- /dev/null +++ b/server/skillhub-app/src/main/resources/db/migration/V68__authoring_platform.sql @@ -0,0 +1,130 @@ +-- Skill authoring platform: drafts, draft files, runtime bindings, and validation +-- runs with their persisted events and findings. Drafts feed validated content into +-- the existing publish pipeline, so these tables are authored standalone and only +-- reference namespace/skill_version loosely (no circular foreign keys). + +CREATE TABLE skill_draft ( + id BIGSERIAL PRIMARY KEY, + namespace_id BIGINT NOT NULL REFERENCES namespace(id), + owner_id VARCHAR(128) NOT NULL, + name VARCHAR(128) NOT NULL, + requirement TEXT, + revision INT NOT NULL DEFAULT 1, + content_digest VARCHAR(64) NOT NULL, + validated_revision INT, + validated_run_id BIGINT, + submitted_skill_id BIGINT, + submitted_version_id BIGINT, + submitted_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + version BIGINT NOT NULL DEFAULT 0, + CONSTRAINT chk_skill_draft_revision CHECK (revision > 0) +); + +-- Name uniqueness is enforced case-insensitively per owner. +CREATE UNIQUE INDEX uq_skill_draft_owner_name + ON skill_draft(owner_id, LOWER(name)); + +CREATE INDEX idx_skill_draft_owner_updated + ON skill_draft(owner_id, updated_at DESC); + +CREATE INDEX idx_skill_draft_namespace + ON skill_draft(namespace_id); + +-- File bodies live in object storage under content-addressed keys; this table tracks +-- identity, digest, and size only. +CREATE TABLE draft_file ( + id BIGSERIAL PRIMARY KEY, + draft_id BIGINT NOT NULL REFERENCES skill_draft(id) ON DELETE CASCADE, + file_path VARCHAR(512) NOT NULL, + sha256 VARCHAR(64) NOT NULL, + size BIGINT NOT NULL, + content_type VARCHAR(128), + storage_key VARCHAR(768) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT uq_draft_file_path UNIQUE (draft_id, file_path), + CONSTRAINT chk_draft_file_size CHECK (size >= 0) +); + +CREATE INDEX idx_draft_file_draft + ON draft_file(draft_id); + +CREATE INDEX idx_draft_file_storage_key + ON draft_file(storage_key); + +-- One runtime binding per draft; secrets are referenced by name and resolved from +-- server-side configuration, never stored inline. +CREATE TABLE runtime_binding ( + id BIGSERIAL PRIMARY KEY, + draft_id BIGINT NOT NULL REFERENCES skill_draft(id) ON DELETE CASCADE, + agent_type VARCHAR(32) NOT NULL, + config JSONB, + tool_allowlist JSONB, + mcp_servers JSONB, + updated_by VARCHAR(128), + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT uq_runtime_binding_draft UNIQUE (draft_id) +); + +CREATE TABLE validation_run ( + id BIGSERIAL PRIMARY KEY, + draft_id BIGINT NOT NULL REFERENCES skill_draft(id) ON DELETE CASCADE, + draft_revision INT NOT NULL, + status VARCHAR(20) NOT NULL, + cancel_requested BOOLEAN NOT NULL DEFAULT FALSE, + error_count INT NOT NULL DEFAULT 0, + warning_count INT NOT NULL DEFAULT 0, + summary JSONB, + triggered_by VARCHAR(128) NOT NULL, + started_at TIMESTAMPTZ, + finished_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + version BIGINT NOT NULL DEFAULT 0, + CONSTRAINT chk_validation_run_error_count CHECK (error_count >= 0), + CONSTRAINT chk_validation_run_warning_count CHECK (warning_count >= 0) +); + +CREATE INDEX idx_validation_run_draft_created + ON validation_run(draft_id, created_at DESC); + +-- Partial index keeps the active-run uniqueness cheap for the maintenance sweep. +CREATE INDEX idx_validation_run_status + ON validation_run(status) + WHERE status IN ('QUEUED', 'PREPARING', 'RUNNING'); + +CREATE INDEX idx_validation_run_stale_sweep + ON validation_run(status, created_at); + +CREATE TABLE validation_event ( + id BIGSERIAL PRIMARY KEY, + run_id BIGINT NOT NULL REFERENCES validation_run(id) ON DELETE CASCADE, + seq INT NOT NULL, + event_type VARCHAR(32) NOT NULL, + phase VARCHAR(32), + payload JSONB, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT uq_validation_event_seq UNIQUE (run_id, seq) +); + +CREATE TABLE validation_finding ( + id BIGSERIAL PRIMARY KEY, + run_id BIGINT NOT NULL REFERENCES validation_run(id) ON DELETE CASCADE, + layer VARCHAR(16) NOT NULL, + rule_code VARCHAR(64) NOT NULL, + severity VARCHAR(16) NOT NULL, + file_path VARCHAR(512), + location VARCHAR(128), + message TEXT NOT NULL, + suggestion JSONB, + status VARCHAR(16) NOT NULL DEFAULT 'OPEN', + applied_revision INT, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT chk_validation_finding_status + CHECK (status IN ('OPEN', 'APPLIED', 'DISMISSED')) +); + +CREATE INDEX idx_validation_finding_run_status + ON validation_finding(run_id, status); diff --git a/server/skillhub-app/src/main/resources/messages.properties b/server/skillhub-app/src/main/resources/messages.properties index 24bf9324..ef5c8661 100644 --- a/server/skillhub-app/src/main/resources/messages.properties +++ b/server/skillhub-app/src/main/resources/messages.properties @@ -284,3 +284,38 @@ promotion.revocation.required=Use the reviewed revocation workflow to remove a p promotion.revocation.page_invalid=Page must be nonnegative and size must be between 1 and 100 error.auth.settings.unavailable=Authentication settings are unavailable error.system.roleGrant.unverifiedProvider=This identity provider does not attest verified email and cannot use initial role rules + +# Skill authoring and validation platform +error.authoring.draft.notFound=Draft not found: {0} +error.authoring.draft.forbidden=You do not own this draft: {0} +error.authoring.draft.name.required=Draft name is required +error.authoring.draft.name.tooLong=Draft name must be at most {0} characters +error.authoring.draft.name.duplicate=You already have a draft named ''{0}'' +error.authoring.draft.requirement.tooLong=Requirement text must be at most {0} characters +error.authoring.draft.notMember=You are not a member of namespace ''{0}'' +error.authoring.draft.revision.conflict=Draft revision mismatch: expected {0} but current is {1} +error.authoring.namespace.notFound=Namespace not found: {0} +error.authoring.namespace.frozen=Namespace ''{0}'' is frozen and cannot receive new drafts +error.authoring.namespace.archived=Namespace ''{0}'' is archived and cannot receive new drafts +error.authoring.file.notFound=Draft file not found: {0} +error.authoring.file.path.invalid=Invalid draft file path: {0} +error.authoring.file.content.required=File content is required +error.authoring.file.tooLarge=File {0} exceeds the {1} byte limit +error.authoring.file.contentMismatch=File {0} content does not match its extension: {1} +error.authoring.file.countExceeded=Draft exceeds the maximum of {0} files +error.authoring.file.extension.disallowed=File extension is not allowed: {0} +error.authoring.package.tooLarge=Draft total size exceeds the {0} byte limit +error.authoring.patch.empty=No patches to apply +error.authoring.patch.fileExists=Patch target already exists: {0} +error.authoring.patch.fileMissing=Patch target not found: {0} +error.authoring.patch.stale=File ''{0}'' changed since the suggestion was generated +error.authoring.patch.noChange=The patch did not change the draft content +error.authoring.run.notFound=Validation run not found: {0} +error.authoring.run.activeExists=Draft {0} already has an active validation run +error.authoring.run.notQueued=Validation run {0} is not queued +error.authoring.run.cancelled=Validation run {0} was cancelled +error.authoring.run.terminal=Validation run {0} is already finished +error.authoring.finding.notFound=Validation finding not found: {0} +error.authoring.finding.noSuggestion=Finding {0} has no applicable fix suggestion +error.authoring.finding.applied=The fix for finding {0} was already applied +error.authoring.finding.suggestionUnreadable=The fix suggestion of finding {0} could not be read diff --git a/server/skillhub-app/src/main/resources/messages_ru.properties b/server/skillhub-app/src/main/resources/messages_ru.properties index 566d443e..c919ca8e 100644 --- a/server/skillhub-app/src/main/resources/messages_ru.properties +++ b/server/skillhub-app/src/main/resources/messages_ru.properties @@ -225,3 +225,40 @@ error.suite.bundle.member.stateChanged=Состояние связанного S error.suite.bundle.actor.inactive=Пользователь операции Skill Suite Bundle больше не активен error.auth.settings.unavailable=Настройки аутентификации временно недоступны error.system.roleGrant.unverifiedProvider=Этот поставщик удостоверений не подтверждает адрес почты и не поддерживает правила первоначального назначения роли + +# Skill authoring and validation platform + +# Skill authoring and validation platform +error.authoring.draft.notFound=Черновик не найден: {0} +error.authoring.draft.forbidden=Вы не владеете этим черновиком: {0} +error.authoring.draft.name.required=Укажите название черновика +error.authoring.draft.name.tooLong=Название черновика не должно превышать {0} символов +error.authoring.draft.name.duplicate=У вас уже есть черновик с названием ''{0}'' +error.authoring.draft.requirement.tooLong=Описание требований не должно превышать {0} символов +error.authoring.draft.notMember=Вы не участник пространства имён ''{0}'' +error.authoring.draft.revision.conflict=Несовпадение ревизии черновика: ожидалась {0}, текущая {1} +error.authoring.namespace.notFound=Пространство имён не найдено: {0} +error.authoring.namespace.frozen=Пространство имён ''{0}'' заморожено, новые черновики недоступны +error.authoring.namespace.archived=Пространство имён ''{0}'' архивировано, новые черновики недоступны +error.authoring.file.notFound=Файл черновика не найден: {0} +error.authoring.file.path.invalid=Недопустимый путь к файлу черновика: {0} +error.authoring.file.content.required=Требуется содержимое файла +error.authoring.file.tooLarge=Файл {0} превышает ограничение в {1} байт +error.authoring.file.contentMismatch=Содержимое файла {0} не соответствует его расширению: {1} +error.authoring.file.countExceeded=Черновик превышает максимум в {0} файлов +error.authoring.file.extension.disallowed=Расширение файла не разрешено: {0} +error.authoring.package.tooLarge=Общий размер черновика превышает ограничение в {0} байт +error.authoring.patch.empty=Нет патчей для применения +error.authoring.patch.fileExists=Целевой файл патча уже существует: {0} +error.authoring.patch.fileMissing=Целевой файл патча не найден: {0} +error.authoring.patch.stale=Файл ''{0}'' изменился после формирования рекомендации +error.authoring.patch.noChange=Патч не изменил содержимое черновика +error.authoring.run.notFound=Задача проверки не найдена: {0} +error.authoring.run.activeExists=У черновика {0} уже есть активная задача проверки +error.authoring.run.notQueued=Задача проверки {0} не в очереди +error.authoring.run.cancelled=Задача проверки {0} была отменена +error.authoring.run.terminal=Задача проверки {0} уже завершена +error.authoring.finding.notFound=Результат проверки не найден: {0} +error.authoring.finding.noSuggestion=Для результата {0} нет применимой рекомендации +error.authoring.finding.applied=Исправление результата {0} уже применено +error.authoring.finding.suggestionUnreadable=Не удалось прочитать рекомендацию для результата {0} diff --git a/server/skillhub-app/src/main/resources/messages_zh.properties b/server/skillhub-app/src/main/resources/messages_zh.properties index 90bad54b..8b6ac4a3 100644 --- a/server/skillhub-app/src/main/resources/messages_zh.properties +++ b/server/skillhub-app/src/main/resources/messages_zh.properties @@ -284,3 +284,40 @@ promotion.revocation.required=已提升的技能须通过审核撤销流程删 promotion.revocation.page_invalid=页码不能为负数,每页数量须在 1 到 100 之间 error.auth.settings.unavailable=认证配置暂时不可用 error.system.roleGrant.unverifiedProvider=该身份来源不提供已验证邮箱,不能用于首次角色授权规则 + +# Skill authoring and validation platform + +# Skill authoring and validation platform +error.authoring.draft.notFound=草稿不存在:{0} +error.authoring.draft.forbidden=你不是该草稿的所有者:{0} +error.authoring.draft.name.required=草稿名称不能为空 +error.authoring.draft.name.tooLong=草稿名称不能超过 {0} 个字符 +error.authoring.draft.name.duplicate=你已经有一个名为“{0}”的草稿 +error.authoring.draft.requirement.tooLong=需求描述不能超过 {0} 个字符 +error.authoring.draft.notMember=你不是命名空间“{0}”的成员 +error.authoring.draft.revision.conflict=草稿版本不匹配:期望 {0},当前为 {1} +error.authoring.namespace.notFound=命名空间不存在:{0} +error.authoring.namespace.frozen=命名空间“{0}”已被冻结,无法创建新草稿 +error.authoring.namespace.archived=命名空间“{0}”已被归档,无法创建新草稿 +error.authoring.file.notFound=草稿文件不存在:{0} +error.authoring.file.path.invalid=非法的草稿文件路径:{0} +error.authoring.file.content.required=文件内容不能为空 +error.authoring.file.tooLarge=文件 {0} 超过了 {1} 字节的上限 +error.authoring.file.contentMismatch=文件 {0} 的内容与扩展名不匹配:{1} +error.authoring.file.countExceeded=草稿文件数超过了上限 {0} +error.authoring.file.extension.disallowed=不允许的文件扩展名:{0} +error.authoring.package.tooLarge=草稿总大小超过了 {0} 字节的上限 +error.authoring.patch.empty=没有可应用的补丁 +error.authoring.patch.fileExists=补丁目标文件已存在:{0} +error.authoring.patch.fileMissing=补丁目标文件不存在:{0} +error.authoring.patch.stale=文件“{0}”在建议生成后发生了变化 +error.authoring.patch.noChange=补丁没有改变草稿内容 +error.authoring.run.notFound=验证任务不存在:{0} +error.authoring.run.activeExists=草稿 {0} 已有一个进行中的验证任务 +error.authoring.run.notQueued=验证任务 {0} 不处于排队状态 +error.authoring.run.cancelled=验证任务 {0} 已被取消 +error.authoring.run.terminal=验证任务 {0} 已经结束 +error.authoring.finding.notFound=验证问题项不存在:{0} +error.authoring.finding.noSuggestion=问题项 {0} 没有可应用的修复建议 +error.authoring.finding.applied=问题项 {0} 的修复已被应用 +error.authoring.finding.suggestionUnreadable=问题项 {0} 的修复建议无法解析 diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/authoring/AuthoringDraftFlowIntegrationTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/authoring/AuthoringDraftFlowIntegrationTest.java new file mode 100644 index 00000000..51bad052 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/authoring/AuthoringDraftFlowIntegrationTest.java @@ -0,0 +1,247 @@ +package com.iflytek.skillhub.authoring; + +import com.iflytek.skillhub.TestRedisConfig; +import com.iflytek.skillhub.domain.authoring.SkillDraft; +import com.iflytek.skillhub.domain.authoring.service.SkillDraftService; +import com.iflytek.skillhub.domain.authoring.service.ValidationRunService; +import com.iflytek.skillhub.domain.authoring.validation.ValidationFinding; +import com.iflytek.skillhub.domain.authoring.validation.ValidationRun; +import com.iflytek.skillhub.domain.authoring.validation.ValidationRunStatus; +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.user.UserAccount; +import com.iflytek.skillhub.domain.user.UserAccountRepository; +import com.iflytek.skillhub.service.authoring.ValidationRunOrchestrator; +import com.iflytek.skillhub.storage.ObjectMetadata; +import com.iflytek.skillhub.storage.ObjectStorageService; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; +import org.springframework.context.annotation.Primary; +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; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.fail; + +/** + * Draft authoring and structure-layer validation against the real service + * stack (Testcontainers PostgreSQL, in-memory object storage): create draft → + * scaffold → break SKILL.md → validation run fails with a fixable finding → + * repair the file → re-validation succeeds. Runs on real PostgreSQL because + * the JSONB columns must round-trip exactly as they do in production. + */ +@SpringBootTest +@ActiveProfiles("test") +@Import({TestRedisConfig.class, AuthoringDraftFlowIntegrationTest.InMemoryStorageConfig.class}) +@Testcontainers +class AuthoringDraftFlowIntegrationTest { + + @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"); + } + + private static final String USER = "author-user-1"; + private static final String NS_SLUG = "authoring-test-ns"; + + @Autowired private SkillDraftService draftService; + @Autowired private ValidationRunService runService; + @Autowired private ValidationRunOrchestrator orchestrator; + @Autowired private NamespaceRepository namespaceRepository; + @Autowired private NamespaceMemberRepository namespaceMemberRepository; + @Autowired private UserAccountRepository userAccountRepository; + + @TestConfiguration + static class InMemoryStorageConfig { + + @Bean + @Primary + ObjectStorageService inMemoryObjectStorage() { + return new InMemoryObjectStorage(); + } + } + + static final class InMemoryObjectStorage implements ObjectStorageService { + private final Map objects = new ConcurrentHashMap<>(); + + @Override + public void putObject(String key, InputStream data, long size, String contentType) { + try { + objects.put(key, data.readAllBytes()); + } catch (Exception exception) { + throw new IllegalStateException(exception); + } + } + + @Override + public InputStream getObject(String key) { + byte[] content = objects.get(key); + if (content == null) { + throw new IllegalArgumentException("missing object: " + key); + } + return new java.io.ByteArrayInputStream(content); + } + + @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) { + byte[] content = objects.get(key); + if (content == null) { + throw new IllegalArgumentException("missing object: " + key); + } + return new ObjectMetadata(content.length, "application/octet-stream", java.time.Instant.now()); + } + + @Override + public String generatePresignedUrl(String key, Duration expiry, String downloadFilename) { + return "http://localhost/" + key; + } + } + + @BeforeEach + void setUpNamespace() { + if (userAccountRepository.findById(USER).isEmpty()) { + userAccountRepository.save(new UserAccount(USER, "Author User", "author@example.com", null)); + } + if (namespaceRepository.findBySlug(NS_SLUG).isEmpty()) { + Namespace namespace = new Namespace(NS_SLUG, "Authoring Test", USER); + namespace.setStatus(com.iflytek.skillhub.domain.namespace.NamespaceStatus.ACTIVE); + Namespace saved = namespaceRepository.save(namespace); + if (namespaceMemberRepository.findByNamespaceIdAndUserId(saved.getId(), USER).isEmpty()) { + namespaceMemberRepository.save(new NamespaceMember(saved.getId(), USER, NamespaceRole.MEMBER)); + } + } + } + + @Test + void structureLayerFailsFixablyThenSucceedsAfterRepair() { + // ---- create with scaffold; the scaffold bytes are readable straight away + SkillDraft draft = draftService.createDraft(NS_SLUG, USER, "draft-flow", "summarizes documents", null); + assertThat(draft.getRevision()).isEqualTo(1); + assertThat(draftService.listFiles(draft.getId())) + .extracting(file -> file.getFilePath()) + .containsExactly("SKILL.md"); + assertThat(draftService.readFile(draft.getId(), USER, "SKILL.md", null).asText()) + .contains("name: draft-flow") + .contains("description: summarizes documents"); + + // ---- break the frontmatter: drop the description field + draftService.saveFile(draft.getId(), USER, "SKILL.md", + "---\nname: draft-flow\n---\n\n# draft-flow\n".getBytes(StandardCharsets.UTF_8), + "text/markdown", null, null); + + // ---- validate: the structure layer must fail with a fixable finding + ValidationRun broken = runService.startRun(draft.getId(), USER, null); + orchestrator.submit(broken.getId()); + ValidationRun failed = awaitTerminal(broken.getId()); + + assertThat(failed.getStatus()).isEqualTo(ValidationRunStatus.FAILED); + List findings = runService.listFindings(broken.getId()); + assertThat(findings).hasSize(1); + ValidationFinding finding = findings.get(0); + assertThat(finding.getRuleCode()).isEqualTo("FRONTMATTER_FIELD_MISSING"); + assertThat(finding.getSuggestion()).isNotNull(); + assertThat(finding.getSuggestion().safePatches()).hasSize(1); + assertThat(runService.listEvents(broken.getId(), null)) + .extracting(event -> event.getEventType().name()) + .contains("RUN_STARTED", "PHASE_STARTED", "FINDING", "RUN_FINISHED"); + + // ---- repair the file (the fix-apply endpoint arrives with the fix-loop layer) + draftService.saveFile(draft.getId(), USER, "SKILL.md", validSkillMdBytes(), + "text/markdown", null, null); + + // ---- re-validate: the same draft now passes the structure layer + ValidationRun repaired = runService.startRun(draft.getId(), USER, null); + orchestrator.submit(repaired.getId()); + ValidationRun succeeded = awaitTerminal(repaired.getId()); + + assertThat(succeeded.getStatus()).isEqualTo(ValidationRunStatus.SUCCEEDED); + assertThat(succeeded.getErrorCount()).isZero(); + assertThat(runService.listFindings(repaired.getId())).isEmpty(); + assertThat(draftService.getDraft(draft.getId()).getValidatedRevision()) + .isEqualTo(succeeded.getDraftRevision()); + } + + @Test + void concurrentFileSavesConflictOnRevision() { + SkillDraft draft = draftService.createDraft(NS_SLUG, USER, "optimistic-draft", null, null); + + // a save pinned to the current revision succeeds and bumps it + draftService.saveFile(draft.getId(), USER, "SKILL.md", validSkillMdBytes(), + "text/markdown", 1, null); + assertThat(draftService.getDraft(draft.getId()).getRevision()).isEqualTo(2); + + // a stale writer (still expecting revision 1) must be rejected + assertThrows(Exception.class, () -> draftService.saveFile( + draft.getId(), USER, "SKILL.md", validSkillMdBytes(), + "text/markdown", 1, null)); + } + + private String validSkillMd() { + return "---\nname: draft-flow\ndescription: summarizes documents\n---\n\n# draft-flow\n"; + } + + private byte[] validSkillMdBytes() { + return validSkillMd().getBytes(StandardCharsets.UTF_8); + } + + private ValidationRun awaitTerminal(Long runId) { + long deadline = System.currentTimeMillis() + 60_000; + while (System.currentTimeMillis() < deadline) { + if (runService.getRun(runId).getStatus().isTerminal()) { + return runService.getRun(runId); + } + try { + Thread.sleep(200); + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + fail("interrupted while waiting for the validation run to finish"); + } + } + fail("validation run did not reach a terminal state in time"); + return null; + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/DraftFile.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/DraftFile.java new file mode 100644 index 00000000..1ff28c06 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/DraftFile.java @@ -0,0 +1,121 @@ +package com.iflytek.skillhub.domain.authoring; + +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.PreUpdate; +import jakarta.persistence.Table; +import java.time.Clock; +import java.time.Instant; + +/** + * One file inside a skill draft. File bodies live in object storage under a + * content-addressed key; this record tracks identity, integrity digest, and size. + */ +@Entity +@Table(name = "draft_file", + uniqueConstraints = @jakarta.persistence.UniqueConstraint( + name = "uq_draft_file_path", columnNames = {"draft_id", "file_path"})) +public class DraftFile { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "draft_id", nullable = false) + private Long draftId; + + @Column(name = "file_path", nullable = false, length = 512) + private String filePath; + + @Column(nullable = false, length = 64) + private String sha256; + + @Column(nullable = false) + private Long size; + + @Column(name = "content_type", length = 128) + private String contentType; + + @Column(name = "storage_key", nullable = false, length = 768) + private String storageKey; + + @Column(name = "created_at", nullable = false, updatable = false) + private Instant createdAt; + + @Column(name = "updated_at", nullable = false) + private Instant updatedAt; + + protected DraftFile() { + } + + public DraftFile(Long draftId, String filePath, String sha256, Long size, + String contentType, String storageKey) { + this.draftId = draftId; + this.filePath = filePath; + this.sha256 = sha256; + this.size = size; + this.contentType = contentType; + this.storageKey = storageKey; + } + + @PrePersist + protected void onCreate() { + Instant now = Instant.now(Clock.systemUTC()); + createdAt = now; + updatedAt = now; + } + + @PreUpdate + protected void onUpdate() { + updatedAt = Instant.now(Clock.systemUTC()); + } + + public void updateContent(String sha256, Long size, String contentType, String storageKey) { + this.sha256 = sha256; + this.size = size; + this.contentType = contentType; + this.storageKey = storageKey; + } + + // Getters + + public Long getId() { + return id; + } + + public Long getDraftId() { + return draftId; + } + + public String getFilePath() { + return filePath; + } + + public String getSha256() { + return sha256; + } + + public Long getSize() { + return size; + } + + public String getContentType() { + return contentType; + } + + public String getStorageKey() { + return storageKey; + } + + public Instant getCreatedAt() { + return createdAt; + } + + public Instant getUpdatedAt() { + return updatedAt; + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/DraftFileRepository.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/DraftFileRepository.java new file mode 100644 index 00000000..beb43ddf --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/DraftFileRepository.java @@ -0,0 +1,26 @@ +package com.iflytek.skillhub.domain.authoring; + +import java.util.List; +import java.util.Optional; + +/** + * Domain repository contract for draft files. + */ +public interface DraftFileRepository { + + DraftFile save(DraftFile file); + + List findByDraftIdOrderByFilePath(Long draftId); + + Optional findByDraftIdAndFilePath(Long draftId, String filePath); + + List findByDraftIdAndFilePathIn(Long draftId, List filePaths); + + void delete(DraftFile file); + + void deleteByDraftId(Long draftId); + + long countByDraftId(Long draftId); + + long sumSizeByDraftId(Long draftId); +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/FilePatch.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/FilePatch.java new file mode 100644 index 00000000..2ff13367 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/FilePatch.java @@ -0,0 +1,19 @@ +package com.iflytek.skillhub.domain.authoring; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import java.util.List; + +/** + * A machine-applicable file replacement. {@code oldValue} is the exact current content + * (null when the file does not exist yet); applying the patch replaces it with + * {@code newValue}. {@code oldSha256} guards against applying a suggestion built on a + * stale revision of the file. + */ +public record FilePatch(String filePath, String oldSha256, String oldValue, String newValue) { + + /** Derived helper, not part of the persisted shape. */ + @JsonIgnore + public boolean isCreation() { + return oldValue == null; + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/FixSuggestion.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/FixSuggestion.java new file mode 100644 index 00000000..8cb78757 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/FixSuggestion.java @@ -0,0 +1,23 @@ +package com.iflytek.skillhub.domain.authoring; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.List; + +/** + * A fix suggestion attached to a validation finding. Suggestions are proposals only: + * they are never applied without explicit user confirmation, and applying them always + * produces a new draft revision so history stays auditable. + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +@JsonIgnoreProperties(ignoreUnknown = true) +public record FixSuggestion( + String description, + @JsonProperty("patches") List patches +) { + + public List safePatches() { + return patches == null ? List.of() : List.copyOf(patches); + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/SkillDraft.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/SkillDraft.java new file mode 100644 index 00000000..c8f3f842 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/SkillDraft.java @@ -0,0 +1,194 @@ +package com.iflytek.skillhub.domain.authoring; + +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.PreUpdate; +import jakarta.persistence.Table; +import jakarta.persistence.Version; +import java.time.Clock; +import java.time.Instant; + +/** + * A skill authoring draft owned by one user. Drafts hold editable SKILL.md and resource files, + * are content-addressed by {@code contentDigest}, and advance a monotonically increasing + * {@code revision} on every content change. A draft is only submittable when its current + * revision matches a revision with a passing validation run. + */ +@Entity +@Table(name = "skill_draft") +public class SkillDraft { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "namespace_id", nullable = false) + private Long namespaceId; + + @Column(name = "owner_id", nullable = false, length = 128) + private String ownerId; + + @Column(nullable = false, length = 128) + private String name; + + @Column(columnDefinition = "TEXT") + private String requirement; + + @Column(name = "revision", nullable = false) + private Integer revision = 1; + + @Column(name = "content_digest", nullable = false, length = 64) + private String contentDigest; + + @Column(name = "validated_revision") + private Integer validatedRevision; + + @Column(name = "validated_run_id") + private Long validatedRunId; + + @Column(name = "submitted_skill_id") + private Long submittedSkillId; + + @Column(name = "submitted_version_id") + private Long submittedVersionId; + + @Column(name = "submitted_at") + private Instant submittedAt; + + @Column(name = "created_at", nullable = false, updatable = false) + private Instant createdAt; + + @Column(name = "updated_at", nullable = false) + private Instant updatedAt; + + @Version + @Column(name = "version", nullable = false) + private Long optimisticVersion; + + protected SkillDraft() { + } + + public SkillDraft(Long namespaceId, String ownerId, String name, String requirement, + String contentDigest) { + this.namespaceId = namespaceId; + this.ownerId = ownerId; + this.name = name; + this.requirement = requirement; + this.contentDigest = contentDigest; + } + + @PrePersist + protected void onCreate() { + Instant now = Instant.now(Clock.systemUTC()); + createdAt = now; + updatedAt = now; + } + + @PreUpdate + protected void onUpdate() { + updatedAt = Instant.now(Clock.systemUTC()); + } + + /** + * Marks the draft content as changed: bumps the revision and attaches the new digest. + * Validation state is intentionally preserved — callers compare + * {@code validatedRevision} against {@code revision} to decide whether the draft + * is still considered validated. + */ + public void applyContentChange(int newRevision, String newContentDigest) { + this.revision = newRevision; + this.contentDigest = newContentDigest; + } + + public void markValidated(int validatedRevision, Long validatedRunId) { + this.validatedRevision = validatedRevision; + this.validatedRunId = validatedRunId; + } + + public void markSubmitted(Long skillId, Long versionId, Instant submittedAt) { + this.submittedSkillId = skillId; + this.submittedVersionId = versionId; + this.submittedAt = submittedAt; + } + + /** Returns true when the current revision has a passing validation run attached. */ + public boolean isCurrentRevisionValidated() { + return validatedRevision != null && validatedRevision.equals(revision); + } + + // Getters + + public Long getId() { + return id; + } + + public Long getNamespaceId() { + return namespaceId; + } + + public String getOwnerId() { + return ownerId; + } + + public String getName() { + return name; + } + + public String getRequirement() { + return requirement; + } + + public Integer getRevision() { + return revision; + } + + public String getContentDigest() { + return contentDigest; + } + + public Integer getValidatedRevision() { + return validatedRevision; + } + + public Long getValidatedRunId() { + return validatedRunId; + } + + public Long getSubmittedSkillId() { + return submittedSkillId; + } + + public Long getSubmittedVersionId() { + return submittedVersionId; + } + + public Instant getSubmittedAt() { + return submittedAt; + } + + public Instant getCreatedAt() { + return createdAt; + } + + public Instant getUpdatedAt() { + return updatedAt; + } + + public Long getOptimisticVersion() { + return optimisticVersion; + } + + // Setters + + public void setName(String name) { + this.name = name; + } + + public void setRequirement(String requirement) { + this.requirement = requirement; + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/SkillDraftRepository.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/SkillDraftRepository.java new file mode 100644 index 00000000..87611eb6 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/SkillDraftRepository.java @@ -0,0 +1,25 @@ +package com.iflytek.skillhub.domain.authoring; + +import java.util.List; +import java.util.Optional; + +/** + * Domain repository contract for skill authoring drafts. + */ +public interface SkillDraftRepository { + + SkillDraft save(SkillDraft draft); + + Optional findById(Long id); + + List findByOwnerIdOrderByUpdatedAtDesc(String ownerId); + + List findByNamespaceId(Long namespaceId); + + /** Case-insensitive uniqueness of (owner, name) used at creation time. */ + Optional findByOwnerIdAndNameIgnoreCase(String ownerId, String name); + + void delete(SkillDraft draft); + + boolean existsByIdAndOwnerId(Long id, String ownerId); +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/package-info.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/package-info.java new file mode 100644 index 00000000..cc113566 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/package-info.java @@ -0,0 +1,9 @@ +/** + * Skill authoring drafts: editable skill packages owned by one user, validated in a + * controlled runtime before being submitted to the publish pipeline. + * + *

Contains the draft, draft file, and runtime binding aggregates together with + * their repository contracts. Validation execution state lives in the + * {@code com.iflytek.skillhub.domain.authoring.validation} package. + */ +package com.iflytek.skillhub.domain.authoring; diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/service/DraftStructureValidator.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/service/DraftStructureValidator.java new file mode 100644 index 00000000..c9cafb9b --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/service/DraftStructureValidator.java @@ -0,0 +1,217 @@ +package com.iflytek.skillhub.domain.authoring.service; + +import com.iflytek.skillhub.domain.authoring.FixSuggestion; +import com.iflytek.skillhub.domain.authoring.FilePatch; +import com.iflytek.skillhub.domain.authoring.validation.FindingDraft; +import com.iflytek.skillhub.domain.authoring.validation.ValidationLayer; +import com.iflytek.skillhub.domain.shared.exception.LocalizedDomainException; +import com.iflytek.skillhub.domain.skill.metadata.ComplianceMetadataService; +import com.iflytek.skillhub.domain.skill.metadata.SkillMetadataParser; +import com.iflytek.skillhub.domain.skill.validation.PackageEntry; +import com.iflytek.skillhub.domain.skill.validation.SkillPackagePolicy; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.HexFormat; +import java.util.List; +import java.util.Map; +import java.util.Set; +import org.springframework.stereotype.Service; + +/** + * The STRUCTURE validation layer: package layout, SKILL.md frontmatter, file limits, + * and content/extension consistency. Mirrors the checks of the publish-time + * {@code SkillPackageValidator} rule by rule, but reports structured findings with + * machine-applicable fix suggestions instead of opaque error strings. + * + *

Package limits and path rules come from the shared {@link SkillPackagePolicy}, + * so draft-time and publish-time rules cannot drift. + */ +@Service +public class DraftStructureValidator { + + private final SkillMetadataParser metadataParser; + private final ComplianceMetadataService complianceMetadataService; + private final SkillScaffoldGenerator scaffoldGenerator; + + public DraftStructureValidator(SkillMetadataParser metadataParser, + SkillScaffoldGenerator scaffoldGenerator) { + this.metadataParser = metadataParser; + this.complianceMetadataService = new ComplianceMetadataService(); + this.scaffoldGenerator = scaffoldGenerator; + } + + public record StructureReport(List findings, boolean skillMdPresent) { + + public boolean hasErrors() { + return findings.stream().anyMatch(finding -> finding.severity() + == com.iflytek.skillhub.domain.authoring.validation.FindingSeverity.ERROR); + } + } + + /** + * @param draftName draft name, used to build fix suggestions (e.g. missing SKILL.md) + * @param requirement original requirement text, reused in scaffold suggestions + */ + public StructureReport validate(String draftName, String requirement, List entries) { + List findings = new ArrayList<>(); + Set normalizedPaths = new HashSet<>(); + PackageEntry skillMd = null; + long totalSize = 0; + + for (PackageEntry entry : entries) { + String normalizedPath; + try { + normalizedPath = SkillPackagePolicy.normalizeEntryPath(entry.path()); + } catch (IllegalArgumentException exception) { + findings.add(FindingDraft.error(ValidationLayer.STRUCTURE, "PATH_INVALID", + entry.path(), exception.getMessage(), null)); + continue; + } + normalizedPath = SkillPackagePolicy.canonicalizeSkillMdPath(normalizedPath); + + if (!normalizedPaths.add(normalizedPath)) { + findings.add(FindingDraft.error(ValidationLayer.STRUCTURE, "PATH_DUPLICATE", + normalizedPath, "Duplicate file path: " + normalizedPath, null)); + } + if (!SkillPackagePolicy.hasAllowedExtension(normalizedPath)) { + findings.add(FindingDraft.warning(ValidationLayer.STRUCTURE, "EXTENSION_DISALLOWED", + normalizedPath, "Disallowed file extension: " + normalizedPath)); + } + String contentMismatch = SkillPackagePolicy.validateContentMatchesExtension( + normalizedPath, entry.content()); + if (contentMismatch != null) { + findings.add(FindingDraft.error(ValidationLayer.STRUCTURE, "CONTENT_MISMATCH", + normalizedPath, contentMismatch, null)); + } + if (entry.size() > SkillPackagePolicy.MAX_SINGLE_FILE_SIZE) { + findings.add(FindingDraft.error(ValidationLayer.STRUCTURE, "FILE_TOO_LARGE", + normalizedPath, "File too large: " + normalizedPath + " (" + + entry.size() + " bytes, max " + SkillPackagePolicy.MAX_SINGLE_FILE_SIZE + ")", + null)); + } + if (SkillPackagePolicy.SKILL_MD_PATH.equals(normalizedPath) && skillMd == null) { + skillMd = entry; + } + totalSize += entry.size(); + } + + if (entries.size() > SkillPackagePolicy.MAX_FILE_COUNT) { + findings.add(FindingDraft.error(ValidationLayer.STRUCTURE, "FILE_COUNT_EXCEEDED", null, + "Too many files: " + entries.size() + " (max " + SkillPackagePolicy.MAX_FILE_COUNT + ")", + null)); + } + if (totalSize > SkillPackagePolicy.MAX_TOTAL_PACKAGE_SIZE) { + findings.add(FindingDraft.error(ValidationLayer.STRUCTURE, "PACKAGE_TOO_LARGE", null, + "Package too large: " + totalSize + " bytes (max " + + SkillPackagePolicy.MAX_TOTAL_PACKAGE_SIZE + ")", null)); + } + + if (skillMd == null) { + String scaffold = scaffoldGenerator.generateSkillMd(draftName, requirement); + findings.add(FindingDraft.error(ValidationLayer.STRUCTURE, "SKILL_MD_MISSING", null, + "Missing required file: SKILL.md at root", + new FixSuggestion("Create an initial SKILL.md scaffold", + List.of(new FilePatch(SkillPackagePolicy.SKILL_MD_PATH, null, null, scaffold))))); + return new StructureReport(findings, false); + } + + String skillMdContent = new String(skillMd.content(), StandardCharsets.UTF_8); + try { + var metadata = metadataParser.parse(skillMdContent); + findings.addAll(complianceFindings(metadata.frontmatter(), entries)); + } catch (LocalizedDomainException exception) { + findings.add(frontmatterFinding(exception, skillMdContent, draftName)); + } + + return new StructureReport(findings, true); + } + + private List complianceFindings(Map frontmatter, + List entries) { + List findings = new ArrayList<>(); + for (String problem : complianceMetadataService.validate(frontmatter, entries)) { + findings.add(FindingDraft.error(ValidationLayer.STRUCTURE, "COMPLIANCE_INVALID", + SkillPackagePolicy.SKILL_MD_PATH, problem, null)); + } + return findings; + } + + private FindingDraft frontmatterFinding(LocalizedDomainException exception, + String skillMdContent, String draftName) { + return switch (exception.messageCode()) { + case "error.skill.metadata.requiredField.missing" -> { + String field = String.valueOf(exception.messageArgs()[0]); + String replacement = insertFrontmatterField(skillMdContent, field, + suggestionValue(field, draftName)); + yield FindingDraft.error(ValidationLayer.STRUCTURE, "FRONTMATTER_FIELD_MISSING", + SkillPackagePolicy.SKILL_MD_PATH, + "SKILL.md frontmatter is missing required field: " + field, + new FixSuggestion("Add required field '" + field + "'", + List.of(new FilePatch(SkillPackagePolicy.SKILL_MD_PATH, + sha256Hex(skillMdContent), + skillMdContent, replacement)))); + } + case "error.skill.metadata.frontmatter.missingStart" -> FindingDraft.error( + ValidationLayer.STRUCTURE, "FRONTMATTER_MISSING_START", + SkillPackagePolicy.SKILL_MD_PATH, + "SKILL.md must start with a '---' frontmatter block", null); + case "error.skill.metadata.frontmatter.missingEnd" -> FindingDraft.error( + ValidationLayer.STRUCTURE, "FRONTMATTER_MISSING_END", + SkillPackagePolicy.SKILL_MD_PATH, + "SKILL.md frontmatter is missing the closing '---' marker", null); + case "error.skill.metadata.frontmatter.missingContent" -> FindingDraft.error( + ValidationLayer.STRUCTURE, "FRONTMATTER_EMPTY", + SkillPackagePolicy.SKILL_MD_PATH, "SKILL.md frontmatter is empty", null); + case "error.skill.metadata.yaml.notMap" -> FindingDraft.error( + ValidationLayer.STRUCTURE, "FRONTMATTER_NOT_MAP", + SkillPackagePolicy.SKILL_MD_PATH, + "SKILL.md frontmatter must be a YAML object", null); + case "error.skill.metadata.yaml.invalid" -> FindingDraft.error( + ValidationLayer.STRUCTURE, "FRONTMATTER_YAML_INVALID", + SkillPackagePolicy.SKILL_MD_PATH, + "SKILL.md frontmatter has invalid YAML: " + + (exception.messageArgs().length > 0 ? exception.messageArgs()[0] : ""), + null); + default -> FindingDraft.error(ValidationLayer.STRUCTURE, "FRONTMATTER_INVALID", + SkillPackagePolicy.SKILL_MD_PATH, exception.messageCode(), null); + }; + } + + private String suggestionValue(String field, String draftName) { + return switch (field) { + case "name" -> draftName == null ? "my-skill" : draftName; + case "description" -> "TODO: describe what this skill does"; + default -> "TODO"; + }; + } + + /** Inserts {@code field: value} as the first line inside the frontmatter block. */ + private String insertFrontmatterField(String content, String field, String value) { + String[] lines = content.split("\n", -1); + if (lines.length == 0 || !lines[0].trim().equals("---")) { + return "---\n" + field + ": " + value + "\n---\n" + content; + } + StringBuilder rebuilt = new StringBuilder(); + rebuilt.append(lines[0]).append('\n'); + rebuilt.append(field).append(": ").append(value).append('\n'); + for (int i = 1; i < lines.length; i++) { + rebuilt.append(lines[i]); + if (i < lines.length - 1) { + rebuilt.append('\n'); + } + } + return rebuilt.toString(); + } + + private static String sha256Hex(String content) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + return HexFormat.of().formatHex(digest.digest(content.getBytes(StandardCharsets.UTF_8))); + } catch (NoSuchAlgorithmException exception) { + throw new IllegalStateException("SHA-256 unavailable", exception); + } + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/service/SkillDraftService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/service/SkillDraftService.java new file mode 100644 index 00000000..17474859 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/service/SkillDraftService.java @@ -0,0 +1,463 @@ +package com.iflytek.skillhub.domain.authoring.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.iflytek.skillhub.domain.authoring.DraftFile; +import com.iflytek.skillhub.domain.authoring.FilePatch; +import com.iflytek.skillhub.domain.authoring.SkillDraft; +import com.iflytek.skillhub.domain.authoring.SkillDraftRepository; +import com.iflytek.skillhub.domain.authoring.DraftFileRepository; +import com.iflytek.skillhub.domain.namespace.Namespace; +import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; +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.shared.exception.DomainConflictException; +import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; +import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException; +import com.iflytek.skillhub.domain.skill.validation.PackageEntry; +import com.iflytek.skillhub.domain.skill.validation.SkillPackagePolicy; +import com.iflytek.skillhub.storage.ObjectStorageService; +import java.io.ByteArrayInputStream; +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.HexFormat; +import java.util.List; +import java.util.Locale; +import java.util.Optional; +import java.util.Set; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * Domain service for skill authoring drafts: creation with a scaffold, content-addressed + * file storage, revision and digest bookkeeping, patch application, and materialization + * into package entries for validation and submission. + * + *

Revisions advance only when content actually changes; a no-op save keeps the + * revision (and therefore a prior validation verdict) intact. Every content change + * recomputes the digest, which binds validation runs to exact draft content. + */ +@Service +public class SkillDraftService { + + private static final Logger log = LoggerFactory.getLogger(SkillDraftService.class); + private static final int MAX_NAME_LENGTH = 128; + private static final int MAX_REQUIREMENT_LENGTH = 8_000; + private static final String SUPER_ADMIN = "SUPER_ADMIN"; + + public record SaveFileOutcome(SkillDraft draft, DraftFile file, boolean created, boolean revisionAdvanced) {} + + private final SkillDraftRepository draftRepository; + private final DraftFileRepository fileRepository; + private final NamespaceRepository namespaceRepository; + private final NamespaceMemberRepository namespaceMemberRepository; + private final ObjectStorageService objectStorageService; + private final SkillScaffoldGenerator scaffoldGenerator; + private final ObjectMapper objectMapper; + + public SkillDraftService(SkillDraftRepository draftRepository, + DraftFileRepository fileRepository, + NamespaceRepository namespaceRepository, + NamespaceMemberRepository namespaceMemberRepository, + ObjectStorageService objectStorageService, + SkillScaffoldGenerator scaffoldGenerator, + ObjectMapper objectMapper) { + this.draftRepository = draftRepository; + this.fileRepository = fileRepository; + this.namespaceRepository = namespaceRepository; + this.namespaceMemberRepository = namespaceMemberRepository; + this.objectStorageService = objectStorageService; + this.scaffoldGenerator = scaffoldGenerator; + this.objectMapper = objectMapper; + } + + // ---------------------------------------------------------------- draft lifecycle + + /** + * Creates a draft in the target namespace and seeds it with a SKILL.md scaffold + * derived from the requirement description. + */ + @Transactional + public SkillDraft createDraft(String namespaceSlug, String ownerId, String name, + String requirement, Set platformRoles) { + validateName(name); + if (requirement != null && requirement.length() > MAX_REQUIREMENT_LENGTH) { + throw new DomainBadRequestException("error.authoring.draft.requirement.tooLong", + MAX_REQUIREMENT_LENGTH); + } + + Namespace namespace = namespaceRepository.findBySlug(namespaceSlug) + .orElseThrow(() -> new DomainNotFoundException("error.authoring.namespace.notFound", namespaceSlug)); + assertNamespaceWritable(namespace); + assertNamespaceMember(namespace, ownerId, platformRoles); + + if (draftRepository.findByOwnerIdAndNameIgnoreCase(ownerId, name).isPresent()) { + throw new DomainConflictException("error.authoring.draft.name.duplicate", name); + } + + String scaffold = scaffoldGenerator.generateSkillMd(name, requirement); + SkillDraft draft = draftRepository.save( + new SkillDraft(namespace.getId(), ownerId, name, requirement, "pending")); + DraftFile scaffoldFile = storeFile(draft.getId(), SkillPackagePolicy.SKILL_MD_PATH, + scaffold.getBytes(StandardCharsets.UTF_8), "text/markdown"); + fileRepository.save(scaffoldFile); + // the scaffold must be readable immediately: editor, structure validation, + // and submit all read it back from object storage + putContent(scaffoldFile.getStorageKey(), scaffold.getBytes(StandardCharsets.UTF_8), + "text/markdown"); + draft.applyContentChange(1, computeDigest(draft.getId())); + return draftRepository.save(draft); + } + + @Transactional(readOnly = true) + public List listDrafts(String ownerId) { + return draftRepository.findByOwnerIdOrderByUpdatedAtDesc(ownerId); + } + + @Transactional(readOnly = true) + public SkillDraft getOwnedDraft(Long draftId, String userId, Set platformRoles) { + SkillDraft draft = draftRepository.findById(draftId) + .orElseThrow(() -> new DomainNotFoundException("error.authoring.draft.notFound", draftId)); + assertOwner(draft, userId, platformRoles); + return draft; + } + + /** Trusted accessor for internal executors (validation pipeline, submit flow). */ + @Transactional(readOnly = true) + public SkillDraft getDraft(Long draftId) { + return draftRepository.findById(draftId) + .orElseThrow(() -> new DomainNotFoundException("error.authoring.draft.notFound", draftId)); + } + + @Transactional + public void deleteDraft(Long draftId, String userId, Set platformRoles) { + SkillDraft draft = getOwnedDraft(draftId, userId, platformRoles); + List storageKeys = fileRepository.findByDraftIdOrderByFilePath(draftId).stream() + .map(DraftFile::getStorageKey) + .toList(); + if (!storageKeys.isEmpty()) { + try { + objectStorageService.deleteObjects(storageKeys); + } catch (Exception exception) { + // storage garbage is not worth blocking a draft deletion; keys are + // namespaced under drafts/{draftId} and become unreachable + log.warn("Failed to clean up draft storage for draft {}: {}", + draftId, exception.getMessage()); + } + } + draftRepository.delete(draft); + } + + // ---------------------------------------------------------------- file editing + + /** + * Saves one file. When {@code expectedRevision} is provided it must match the current + * revision, otherwise the save is rejected with a conflict (optimistic concurrency). + */ + @Transactional + public SaveFileOutcome saveFile(Long draftId, String userId, String rawPath, byte[] content, + String contentType, Integer expectedRevision, + Set platformRoles) { + SkillDraft draft = getOwnedDraft(draftId, userId, platformRoles); + assertRevisionMatches(draft, expectedRevision); + String path = normalizeAndCheckPath(rawPath); + + if (content == null) { + throw new DomainBadRequestException("error.authoring.file.content.required"); + } + if (content.length > SkillPackagePolicy.MAX_SINGLE_FILE_SIZE) { + throw new DomainBadRequestException("error.authoring.file.tooLarge", path, + SkillPackagePolicy.MAX_SINGLE_FILE_SIZE); + } + String mismatch = SkillPackagePolicy.validateContentMatchesExtension(path, content); + if (mismatch != null) { + throw new DomainBadRequestException("error.authoring.file.contentMismatch", path, mismatch); + } + + Optional existing = fileRepository.findByDraftIdAndFilePath(draftId, path); + if (existing.isEmpty() && fileRepository.countByDraftId(draftId) >= SkillPackagePolicy.MAX_FILE_COUNT) { + throw new DomainBadRequestException("error.authoring.file.countExceeded", + SkillPackagePolicy.MAX_FILE_COUNT); + } + long currentTotal = fileRepository.sumSizeByDraftId(draftId) + - existing.map(DraftFile::getSize).orElse(0L); + if (currentTotal + content.length > SkillPackagePolicy.MAX_TOTAL_PACKAGE_SIZE) { + throw new DomainBadRequestException("error.authoring.package.tooLarge", + SkillPackagePolicy.MAX_TOTAL_PACKAGE_SIZE); + } + + DraftFile file = existing + .map(current -> { + current.updateContent(sha256Hex(content), (long) content.length, + resolveContentType(path, contentType), storageKey(draftId, content)); + return current; + }) + .orElseGet(() -> storeFile(draftId, path, content, resolveContentType(path, contentType))); + file = fileRepository.save(file); + // write content after resolving the key so the same digest always lands in the same object + putContent(storageKey(draftId, content), content, resolveContentType(path, contentType)); + + boolean revisionAdvanced = advanceRevisionIfContentChanged(draft); + return new SaveFileOutcome(draft, file, existing.isEmpty(), revisionAdvanced); + } + + @Transactional + public void deleteFile(Long draftId, String userId, String rawPath, Integer expectedRevision, + Set platformRoles) { + SkillDraft draft = getOwnedDraft(draftId, userId, platformRoles); + assertRevisionMatches(draft, expectedRevision); + String path = normalizeAndCheckPath(rawPath); + DraftFile file = fileRepository.findByDraftIdAndFilePath(draftId, path) + .orElseThrow(() -> new DomainNotFoundException("error.authoring.file.notFound", path)); + fileRepository.delete(file); + advanceRevisionIfContentChanged(draft); + } + + @Transactional(readOnly = true) + public List listFiles(Long draftId) { + return fileRepository.findByDraftIdOrderByFilePath(draftId); + } + + @Transactional(readOnly = true) + public FileContent readFile(Long draftId, String userId, String rawPath, + Set platformRoles) { + getOwnedDraft(draftId, userId, platformRoles); + String path; + try { + path = SkillPackagePolicy.canonicalizeSkillMdPath( + SkillPackagePolicy.normalizeEntryPath(rawPath)); + } catch (IllegalArgumentException exception) { + throw new DomainBadRequestException("error.authoring.file.path.invalid", rawPath); + } + DraftFile file = fileRepository.findByDraftIdAndFilePath(draftId, path) + .orElseThrow(() -> new DomainNotFoundException("error.authoring.file.notFound", path)); + byte[] content; + try (InputStream input = objectStorageService.getObject(file.getStorageKey())) { + content = input.readAllBytes(); + } catch (IOException exception) { + throw new IllegalStateException("Failed to read draft file: " + path, exception); + } + return new FileContent(file, content); + } + + public record FileContent(DraftFile file, byte[] content) { + public String asText() { + return new String(content, StandardCharsets.UTF_8); + } + } + + // ---------------------------------------------------------------- patches + + /** + * Applies a confirmed set of file patches atomically: every patch must target the + * exact current content (sha256 match), and the whole batch produces exactly one + * new draft revision. + */ + @Transactional + public SkillDraft applyPatch(Long draftId, String userId, List patches, + Integer expectedRevision, Set platformRoles) { + SkillDraft draft = getOwnedDraft(draftId, userId, platformRoles); + assertRevisionMatches(draft, expectedRevision); + if (patches == null || patches.isEmpty()) { + throw new DomainBadRequestException("error.authoring.patch.empty"); + } + + for (FilePatch patch : patches) { + String path = normalizeAndCheckPath(patch.filePath()); + byte[] content = patch.newValue().getBytes(StandardCharsets.UTF_8); + String contentType = resolveContentType(path, null); + Optional existing = fileRepository.findByDraftIdAndFilePath(draftId, path); + if (patch.isCreation()) { + if (existing.isPresent()) { + throw new DomainConflictException("error.authoring.patch.fileExists", path); + } + fileRepository.save(storeFile(draftId, path, content, contentType)); + } else { + DraftFile current = existing.orElseThrow(() -> + new DomainConflictException("error.authoring.patch.fileMissing", path)); + String currentSha = current.getSha256(); + if (patch.oldSha256() == null || !patch.oldSha256().equalsIgnoreCase(currentSha)) { + throw new DomainConflictException("error.authoring.patch.stale", path); + } + current.updateContent(sha256Hex(content), (long) content.length, + contentType, storageKey(draftId, content)); + fileRepository.save(current); + } + putContent(storageKey(draftId, content), content, contentType); + } + + boolean advanced = advanceRevisionIfContentChanged(draft); + if (!advanced) { + // a patch that does not change the digest is a programming error upstream + throw new DomainBadRequestException("error.authoring.patch.noChange"); + } + return draft; + } + + // ---------------------------------------------------------------- materialization + + /** + * Builds streaming package entries from the draft's current files. Used by the + * validation orchestrator (snapshot) and the submit flow (publish input). + */ + @Transactional(readOnly = true) + public List materializeEntries(Long draftId) { + List entries = new ArrayList<>(); + for (DraftFile file : fileRepository.findByDraftIdOrderByFilePath(draftId)) { + entries.add(PackageEntry.streaming( + file.getFilePath(), + file.getSize(), + file.getContentType(), + () -> objectStorageService.getObject(file.getStorageKey()))); + } + return entries; + } + + @Transactional(readOnly = true) + public String computeDigest(Long draftId) { + List files = new ArrayList<>(fileRepository.findByDraftIdOrderByFilePath(draftId)); + files.sort(Comparator.comparing(DraftFile::getFilePath)); + StringBuilder manifest = new StringBuilder(); + for (DraftFile file : files) { + manifest.append(file.getFilePath()).append('\u0000') + .append(file.getSha256()).append('\n'); + } + return sha256Hex(manifest.toString()); + } + + @Transactional(readOnly = true) + public String resolveNamespaceSlug(SkillDraft draft) { + return namespaceRepository.findById(draft.getNamespaceId()) + .map(Namespace::getSlug) + .orElseThrow(() -> new DomainNotFoundException( + "error.authoring.namespace.notFound", draft.getNamespaceId())); + } + + // ---------------------------------------------------------------- internals + + private DraftFile storeFile(Long draftId, String path, byte[] content, String contentType) { + return new DraftFile(draftId, path, sha256Hex(content), (long) content.length, + contentType, storageKey(draftId, content)); + } + + private void putContent(String key, byte[] content, String contentType) { + if (!objectStorageService.exists(key)) { + objectStorageService.putObject(key, new ByteArrayInputStream(content), + content.length, contentType); + } + } + + private boolean advanceRevisionIfContentChanged(SkillDraft draft) { + String newDigest = computeDigest(draft.getId()); + if (newDigest.equals(draft.getContentDigest())) { + return false; + } + draft.applyContentChange(draft.getRevision() + 1, newDigest); + draftRepository.save(draft); + return true; + } + + private String storageKey(Long draftId, byte[] content) { + return "drafts/" + draftId + "/" + sha256Hex(content); + } + + private String resolveContentType(String path, String provided) { + if (provided != null && !provided.isBlank()) { + return provided; + } + String lower = path.toLowerCase(Locale.ROOT); + if (lower.endsWith(".md")) { + return "text/markdown"; + } + if (lower.endsWith(".yaml") || lower.endsWith(".yml")) { + return "application/yaml"; + } + if (lower.endsWith(".json")) { + return "application/json"; + } + if (lower.endsWith(".py")) { + return "text/x-python"; + } + if (lower.endsWith(".sh")) { + return "text/x-shellscript"; + } + return "application/octet-stream"; + } + + private String normalizeAndCheckPath(String rawPath) { + String normalized; + try { + normalized = SkillPackagePolicy.canonicalizeSkillMdPath( + SkillPackagePolicy.normalizeEntryPath(rawPath)); + } catch (IllegalArgumentException exception) { + throw new DomainBadRequestException("error.authoring.file.path.invalid", rawPath); + } + if (!SkillPackagePolicy.hasAllowedExtension(normalized)) { + throw new DomainBadRequestException("error.authoring.file.extension.disallowed", normalized); + } + return normalized; + } + + private void assertRevisionMatches(SkillDraft draft, Integer expectedRevision) { + if (expectedRevision != null && !expectedRevision.equals(draft.getRevision())) { + throw new DomainConflictException("error.authoring.draft.revision.conflict", + expectedRevision, draft.getRevision()); + } + } + + private void validateName(String name) { + if (name == null || name.isBlank()) { + throw new DomainBadRequestException("error.authoring.draft.name.required"); + } + if (name.length() > MAX_NAME_LENGTH) { + throw new DomainBadRequestException("error.authoring.draft.name.tooLong", MAX_NAME_LENGTH); + } + } + + private void assertNamespaceWritable(Namespace namespace) { + if (namespace.getStatus() == NamespaceStatus.FROZEN) { + throw new DomainForbiddenException("error.authoring.namespace.frozen", namespace.getSlug()); + } + if (namespace.getStatus() == NamespaceStatus.ARCHIVED) { + throw new DomainForbiddenException("error.authoring.namespace.archived", namespace.getSlug()); + } + } + + private void assertNamespaceMember(Namespace namespace, String userId, Set platformRoles) { + if (platformRoles != null && platformRoles.contains(SUPER_ADMIN)) { + return; + } + if (namespaceMemberRepository + .findByNamespaceIdAndUserId(namespace.getId(), userId).isEmpty()) { + throw new DomainForbiddenException("error.authoring.draft.notMember", namespace.getSlug()); + } + } + + private void assertOwner(SkillDraft draft, String userId, Set platformRoles) { + if (draft.getOwnerId().equals(userId) + || (platformRoles != null && platformRoles.contains(SUPER_ADMIN))) { + return; + } + throw new DomainForbiddenException("error.authoring.draft.forbidden", draft.getId()); + } + + public static String sha256Hex(byte[] content) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + return HexFormat.of().formatHex(digest.digest(content)); + } catch (NoSuchAlgorithmException exception) { + throw new IllegalStateException("SHA-256 unavailable", exception); + } + } + + public static String sha256Hex(String content) { + return sha256Hex(content.getBytes(StandardCharsets.UTF_8)); + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/service/SkillScaffoldGenerator.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/service/SkillScaffoldGenerator.java new file mode 100644 index 00000000..d0316acf --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/service/SkillScaffoldGenerator.java @@ -0,0 +1,78 @@ +package com.iflytek.skillhub.domain.authoring.service; + +import com.iflytek.skillhub.domain.namespace.SlugValidator; +import org.springframework.stereotype.Service; + +/** + * Generates the initial SKILL.md scaffold for a new draft from the author's name and + * requirement description. Template-based by design: scaffolds are a starting point + * the author edits and validates; model-assisted generation remains an optional + * extension layered on top of this deterministic baseline. + */ +@Service +public class SkillScaffoldGenerator { + + /** + * Builds the scaffold SKILL.md content. + * + * @param draftName the draft name chosen by the author + * @param requirement the natural-language requirement description + */ + public String generateSkillMd(String draftName, String requirement) { + String skillName = safeName(draftName); + String overview = requirement == null || requirement.isBlank() + ? "Describe what this skill does and when an agent should use it." + : requirement.strip(); + return """ + --- + name: %s + description: %s + --- + + # %s + + ## Overview + + %s + + ## Usage + + Describe step by step how an agent should apply this skill. + + ## Resources + + - `references/` — background material the skill can cite + - `scripts/` — executable helpers invoked during task execution + """.formatted(skillName, singleLine(overview), skillName, overview); + } + + /** Builds a starter validation.yaml with one script task template. */ + public String generateValidationYaml() { + return """ + version: 1 + tasks: + - name: smoke + description: Replace with a real check for this skill + type: script + script: scripts/check.sh + args: [] + timeoutMs: 30000 + assertions: + - type: exit_code + equals: 0 + """; + } + + private String safeName(String draftName) { + try { + String slug = SlugValidator.slugify(draftName); + return slug == null || slug.isBlank() ? "my-skill" : slug; + } catch (Exception exception) { + return "my-skill"; + } + } + + private String singleLine(String text) { + return text.replaceAll("\\s+", " ").trim(); + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/service/ValidationRunService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/service/ValidationRunService.java new file mode 100644 index 00000000..f2e21c96 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/service/ValidationRunService.java @@ -0,0 +1,256 @@ +package com.iflytek.skillhub.domain.authoring.service; + +import com.iflytek.skillhub.domain.authoring.SkillDraft; +import com.iflytek.skillhub.domain.authoring.SkillDraftRepository; +import com.iflytek.skillhub.domain.authoring.validation.FindingDraft; +import com.iflytek.skillhub.domain.authoring.validation.ValidationEvent; +import com.iflytek.skillhub.domain.authoring.validation.ValidationEventType; +import com.iflytek.skillhub.domain.authoring.validation.ValidationFinding; +import com.iflytek.skillhub.domain.authoring.validation.ValidationFindingRepository; +import com.iflytek.skillhub.domain.authoring.validation.ValidationEventRepository; +import com.iflytek.skillhub.domain.authoring.validation.ValidationRun; +import com.iflytek.skillhub.domain.authoring.validation.ValidationRunRepository; +import com.iflytek.skillhub.domain.authoring.validation.ValidationRunStatus; +import com.iflytek.skillhub.domain.shared.exception.DomainConflictException; +import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; +import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException; +import java.time.Clock; +import java.time.Instant; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +/** + * Domain service for validation run lifecycle: starting runs bound to a frozen draft + * revision, appending ordered events, recording findings, and settling runs into + * terminal states. Events and findings commit in their own transactions so partial + * progress is always visible to SSE subscribers and polling clients. + */ +@Service +public class ValidationRunService { + + private static final String SUPER_ADMIN = "SUPER_ADMIN"; + private static final List ACTIVE_STATUSES = List.of( + ValidationRunStatus.QUEUED, ValidationRunStatus.PREPARING, ValidationRunStatus.RUNNING); + + private final ValidationRunRepository runRepository; + private final ValidationEventRepository eventRepository; + private final ValidationFindingRepository findingRepository; + private final SkillDraftRepository draftRepository; + private final Clock clock; + + public ValidationRunService(ValidationRunRepository runRepository, + ValidationEventRepository eventRepository, + ValidationFindingRepository findingRepository, + SkillDraftRepository draftRepository, + Clock clock) { + this.runRepository = runRepository; + this.eventRepository = eventRepository; + this.findingRepository = findingRepository; + this.draftRepository = draftRepository; + this.clock = clock; + } + + // ---------------------------------------------------------------- lifecycle + + /** + * Creates a QUEUED run bound to the draft's current revision. Only one active run + * per draft is allowed. + */ + @Transactional + public ValidationRun startRun(Long draftId, String userId, Set platformRoles) { + SkillDraft draft = draftRepository.findById(draftId) + .orElseThrow(() -> new DomainNotFoundException("error.authoring.draft.notFound", draftId)); + assertOwner(draft, userId, platformRoles); + if (runRepository.findByDraftIdOrderByCreatedAtDesc(draftId).stream() + .anyMatch(ValidationRun::isActive)) { + throw new DomainConflictException("error.authoring.run.activeExists", draftId); + } + return runRepository.save( + new ValidationRun(draftId, draft.getRevision(), userId)); + } + + /** + * Transitions a QUEUED run through PREPARING to RUNNING under a row lock, so + * cancellation and execution cannot interleave. Returns the refreshed run. + */ + @Transactional + public ValidationRun claimForExecution(Long runId) { + ValidationRun run = runRepository.findByIdForUpdate(runId) + .orElseThrow(() -> new DomainNotFoundException("error.authoring.run.notFound", runId)); + if (run.getStatus() == ValidationRunStatus.CANCELLED) { + throw new DomainConflictException("error.authoring.run.cancelled", runId); + } + if (run.getStatus() != ValidationRunStatus.QUEUED) { + throw new DomainConflictException("error.authoring.run.notQueued", runId); + } + if (run.isCancelRequested()) { + run.finish(ValidationRunStatus.CANCELLED, 0, 0, null, clock.instant()); + return runRepository.save(run); + } + run.markPreparing(); + run.markRunning(clock.instant()); + return runRepository.save(run); + } + + /** + * Idempotent cancel request. Sets the cooperative flag and settles QUEUED runs + * immediately; RUNNING/PREPARING runs are finished by their executor thread when + * it observes the flag (or by the maintenance sweep). + */ + @Transactional + public ValidationRun requestCancel(Long runId, String userId, Set platformRoles) { + ValidationRun run = runRepository.findByIdForUpdate(runId) + .orElseThrow(() -> new DomainNotFoundException("error.authoring.run.notFound", runId)); + SkillDraft draft = draftRepository.findById(run.getDraftId()) + .orElseThrow(() -> new DomainNotFoundException( + "error.authoring.draft.notFound", run.getDraftId())); + assertOwner(draft, userId, platformRoles); + + if (!run.isActive()) { + return run; // already terminal: cancel is idempotent + } + run.requestCancel(); + if (run.getStatus() == ValidationRunStatus.QUEUED) { + run.finish(ValidationRunStatus.CANCELLED, 0, 0, + Map.of("reason", "cancelled before start"), clock.instant()); + } + return runRepository.save(run); + } + + /** + * Settles a run into a terminal status if it is still active; returns empty when the + * run was already terminal (idempotent settle — the first terminal state wins). When + * the run succeeds with zero errors and the draft has not moved on, the draft is + * marked validated for this revision. + */ + @Transactional + public Optional settleIfActive(Long runId, ValidationRunStatus terminalStatus, + int errorCount, int warningCount, + Map summary) { + ValidationRun run = runRepository.findByIdForUpdate(runId) + .orElseThrow(() -> new DomainNotFoundException("error.authoring.run.notFound", runId)); + if (run.getStatus().isTerminal()) { + return Optional.empty(); + } + run.finish(terminalStatus, errorCount, warningCount, summary, clock.instant()); + ValidationRun settled = runRepository.save(run); + + if (terminalStatus == ValidationRunStatus.SUCCEEDED && errorCount == 0) { + draftRepository.findById(settled.getDraftId()).ifPresent(draft -> { + if (draft.getRevision().equals(settled.getDraftRevision())) { + draft.markValidated(settled.getDraftRevision(), settled.getId()); + draftRepository.save(draft); + } + }); + } + return Optional.of(settled); + } + + // ---------------------------------------------------------------- events & findings + + /** Appends one event with the next sequence number; commits independently. */ + @Transactional(propagation = Propagation.REQUIRES_NEW) + public ValidationEvent appendEvent(Long runId, ValidationEventType type, String phase, + Map payload) { + int nextSeq = eventRepository.findMaxSeqByRunId(runId).map(seq -> seq + 1).orElse(1); + return eventRepository.save( + new ValidationEvent(runId, nextSeq, type, phase, payload)); + } + + /** Records one finding and returns it; commits independently. */ + @Transactional(propagation = Propagation.REQUIRES_NEW) + public ValidationFinding recordFinding(Long runId, FindingDraft draft) { + return findingRepository.save(new ValidationFinding( + runId, draft.layer(), draft.ruleCode(), draft.severity(), + draft.filePath(), draft.location(), draft.message(), draft.suggestion())); + } + + // ---------------------------------------------------------------- queries + + @Transactional(readOnly = true) + public ValidationRun getOwnedRun(Long runId, String userId, Set platformRoles) { + ValidationRun run = runRepository.findById(runId) + .orElseThrow(() -> new DomainNotFoundException("error.authoring.run.notFound", runId)); + SkillDraft draft = draftRepository.findById(run.getDraftId()) + .orElseThrow(() -> new DomainNotFoundException( + "error.authoring.draft.notFound", run.getDraftId())); + assertOwner(draft, userId, platformRoles); + return run; + } + + /** Trusted accessor for internal executors (validation pipeline, maintenance sweep). */ + @Transactional(readOnly = true) + public ValidationRun getRun(Long runId) { + return runRepository.findById(runId) + .orElseThrow(() -> new DomainNotFoundException("error.authoring.run.notFound", runId)); + } + + @Transactional(readOnly = true) + public List listRuns(Long draftId) { + return runRepository.findByDraftIdOrderByCreatedAtDesc(draftId); + } + + @Transactional(readOnly = true) + public List listEvents(Long runId, Integer afterSeq) { + if (afterSeq == null) { + return eventRepository.findByRunIdOrderBySeqAsc(runId); + } + return eventRepository.findByRunIdAndSeqGreaterThanOrderBySeqAsc(runId, afterSeq); + } + + @Transactional(readOnly = true) + public List listFindings(Long runId) { + List findings = findingRepository.findByRunId(runId); + findings.sort(Comparator + .comparing(ValidationFinding::getSeverity) + .thenComparing(ValidationFinding::getId)); + return findings; + } + + @Transactional(readOnly = true) + public ValidationFinding getFinding(Long findingId) { + return findingRepository.findById(findingId) + .orElseThrow(() -> new DomainNotFoundException( + "error.authoring.finding.notFound", findingId)); + } + + @Transactional(readOnly = true) + public List findActiveRuns() { + return runRepository.findByStatusIn(ACTIVE_STATUSES); + } + + @Transactional(readOnly = true) + public List findActiveRunsCreatedBefore(Instant cutoff) { + return runRepository.findByStatusInAndCreatedAtBefore(ACTIVE_STATUSES, cutoff); + } + + /** + * The submission gate: the latest run bound to the draft's current revision must + * have succeeded with zero errors. + */ + @Transactional(readOnly = true) + public ValidationRun requirePassingRunForRevision(Long draftId, Integer revision) { + ValidationRun run = runRepository + .findFirstByDraftIdAndDraftRevisionOrderByCreatedAtDesc(draftId, revision) + .orElseThrow(() -> new DomainConflictException( + "error.authoring.submit.notValidated", revision)); + if (run.getStatus() != ValidationRunStatus.SUCCEEDED || run.getErrorCount() > 0) { + throw new DomainConflictException("error.authoring.submit.notValidated", revision); + } + return run; + } + + private void assertOwner(SkillDraft draft, String userId, Set platformRoles) { + if (draft.getOwnerId().equals(userId) + || (platformRoles != null && platformRoles.contains(SUPER_ADMIN))) { + return; + } + throw new DomainForbiddenException("error.authoring.draft.forbidden", draft.getId()); + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/validation/FindingDraft.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/validation/FindingDraft.java new file mode 100644 index 00000000..168fe36f --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/validation/FindingDraft.java @@ -0,0 +1,36 @@ +package com.iflytek.skillhub.domain.authoring.validation; + +import com.iflytek.skillhub.domain.authoring.FixSuggestion; + +/** + * A finding produced by one validation layer, before it is persisted. The orchestrator + * converts these into {@link ValidationFinding} rows and emits FINDING events. + */ +public record FindingDraft( + ValidationLayer layer, + String ruleCode, + FindingSeverity severity, + String filePath, + String location, + String message, + FixSuggestion suggestion +) { + + public static FindingDraft error(ValidationLayer layer, String ruleCode, String message) { + return new FindingDraft(layer, ruleCode, FindingSeverity.ERROR, null, null, message, null); + } + + public static FindingDraft error(ValidationLayer layer, String ruleCode, String filePath, + String message, FixSuggestion suggestion) { + return new FindingDraft(layer, ruleCode, FindingSeverity.ERROR, filePath, null, message, suggestion); + } + + public static FindingDraft warning(ValidationLayer layer, String ruleCode, String filePath, + String message) { + return new FindingDraft(layer, ruleCode, FindingSeverity.WARNING, filePath, null, message, null); + } + + public static FindingDraft info(ValidationLayer layer, String ruleCode, String message) { + return new FindingDraft(layer, ruleCode, FindingSeverity.INFO, null, null, message, null); + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/validation/FindingSeverity.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/validation/FindingSeverity.java new file mode 100644 index 00000000..9dd8fbf8 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/validation/FindingSeverity.java @@ -0,0 +1,10 @@ +package com.iflytek.skillhub.domain.authoring.validation; + +/** + * Severity of a validation finding. A run only fails when at least one ERROR finding exists. + */ +public enum FindingSeverity { + ERROR, + WARNING, + INFO +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/validation/FindingStatus.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/validation/FindingStatus.java new file mode 100644 index 00000000..08729c6b --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/validation/FindingStatus.java @@ -0,0 +1,13 @@ +package com.iflytek.skillhub.domain.authoring.validation; + +/** + * Lifecycle of a fix suggestion attached to a finding. + */ +public enum FindingStatus { + /** Suggestion is available but has not been applied to the draft. */ + OPEN, + /** The suggestion's patch was applied and produced a new draft revision. */ + APPLIED, + /** The user explicitly dismissed the suggestion. */ + DISMISSED +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/validation/ValidationEvent.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/validation/ValidationEvent.java new file mode 100644 index 00000000..366c66e7 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/validation/ValidationEvent.java @@ -0,0 +1,98 @@ +package com.iflytek.skillhub.domain.authoring.validation; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.PrePersist; +import jakarta.persistence.Table; +import java.time.Clock; +import java.time.Instant; +import java.util.Map; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; + +/** + * One persisted run event. {@code seq} is a per-run monotonic counter used both for + * ordered display and for SSE resume (Last-Event-ID) and polling (afterSeq) cursors. + */ +@Entity +@Table(name = "validation_event", + uniqueConstraints = @jakarta.persistence.UniqueConstraint( + name = "uq_validation_event_seq", columnNames = {"run_id", "seq"})) +public class ValidationEvent { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "run_id", nullable = false) + private Long runId; + + @Column(nullable = false) + private Integer seq; + + @Enumerated(EnumType.STRING) + @Column(name = "event_type", nullable = false, length = 32) + private ValidationEventType eventType; + + @Column(length = 32) + private String phase; + + @JdbcTypeCode(SqlTypes.JSON) + @Column(name = "payload") + private Map payload; + + @Column(name = "created_at", nullable = false, updatable = false) + private Instant createdAt; + + protected ValidationEvent() { + } + + public ValidationEvent(Long runId, Integer seq, ValidationEventType eventType, + String phase, Map payload) { + this.runId = runId; + this.seq = seq; + this.eventType = eventType; + this.phase = phase; + this.payload = payload; + } + + @PrePersist + protected void onCreate() { + createdAt = Instant.now(Clock.systemUTC()); + } + + // Getters + + public Long getId() { + return id; + } + + public Long getRunId() { + return runId; + } + + public Integer getSeq() { + return seq; + } + + public ValidationEventType getEventType() { + return eventType; + } + + public String getPhase() { + return phase; + } + + public Map getPayload() { + return payload; + } + + public Instant getCreatedAt() { + return createdAt; + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/validation/ValidationEventRepository.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/validation/ValidationEventRepository.java new file mode 100644 index 00000000..a766e266 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/validation/ValidationEventRepository.java @@ -0,0 +1,23 @@ +package com.iflytek.skillhub.domain.authoring.validation; + +import java.util.List; +import java.util.Optional; + +/** + * Domain repository contract for persisted validation events. + */ +public interface ValidationEventRepository { + + ValidationEvent save(ValidationEvent event); + + List findByRunIdOrderBySeqAsc(Long runId); + + /** Events with seq strictly greater than the cursor, ordered for replay. */ + List findByRunIdAndSeqGreaterThanOrderBySeqAsc(Long runId, int afterSeq); + + List findByRunIdAndSeqIn(Long runId, List seqs); + + Optional findMaxSeqByRunId(Long runId); + + void deleteByRunId(Long runId); +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/validation/ValidationEventType.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/validation/ValidationEventType.java new file mode 100644 index 00000000..db724dc8 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/validation/ValidationEventType.java @@ -0,0 +1,17 @@ +package com.iflytek.skillhub.domain.authoring.validation; + +/** + * Event types emitted during a validation run and persisted in order. The sequence + * number of each event enables SSE replay with Last-Event-ID resumption. + */ +public enum ValidationEventType { + RUN_STARTED, + PHASE_STARTED, + PHASE_FINISHED, + AGENT_MESSAGE, + TOOL_CALL, + TOOL_RESULT, + LOG, + FINDING, + RUN_FINISHED +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/validation/ValidationFinding.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/validation/ValidationFinding.java new file mode 100644 index 00000000..0d0d8d83 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/validation/ValidationFinding.java @@ -0,0 +1,152 @@ +package com.iflytek.skillhub.domain.authoring.validation; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.PrePersist; +import jakarta.persistence.Table; +import java.time.Clock; +import java.time.Instant; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; +import com.iflytek.skillhub.domain.authoring.FixSuggestion; + +/** + * A single problem or observation produced by one validation layer, optionally carrying + * a machine-applicable fix suggestion (patch payload) that the author can preview, + * confirm, and apply to produce a new draft revision. + */ +@Entity +@Table(name = "validation_finding") +public class ValidationFinding { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "run_id", nullable = false) + private Long runId; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 16) + private ValidationLayer layer; + + @Column(name = "rule_code", nullable = false, length = 64) + private String ruleCode; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 16) + private FindingSeverity severity; + + @Column(name = "file_path", length = 512) + private String filePath; + + @Column(length = 128) + private String location; + + @Column(nullable = false, columnDefinition = "TEXT") + private String message; + + /** Machine-applicable fix payload; null when no machine-applicable fix exists. */ + @JdbcTypeCode(SqlTypes.JSON) + @Column(name = "suggestion") + private FixSuggestion suggestion; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 16) + private FindingStatus status = FindingStatus.OPEN; + + @Column(name = "applied_revision") + private Integer appliedRevision; + + @Column(name = "created_at", nullable = false, updatable = false) + private Instant createdAt; + + protected ValidationFinding() { + } + + public ValidationFinding(Long runId, ValidationLayer layer, String ruleCode, + FindingSeverity severity, String filePath, String location, + String message, FixSuggestion suggestion) { + this.runId = runId; + this.layer = layer; + this.ruleCode = ruleCode; + this.severity = severity; + this.filePath = filePath; + this.location = location; + this.message = message; + this.suggestion = suggestion; + } + + @PrePersist + protected void onCreate() { + createdAt = Instant.now(Clock.systemUTC()); + } + + public void markApplied(int appliedRevision) { + this.status = FindingStatus.APPLIED; + this.appliedRevision = appliedRevision; + } + + public void markDismissed() { + this.status = FindingStatus.DISMISSED; + } + + public boolean hasSuggestion() { + return suggestion != null && !suggestion.safePatches().isEmpty(); + } + + // Getters + + public Long getId() { + return id; + } + + public Long getRunId() { + return runId; + } + + public ValidationLayer getLayer() { + return layer; + } + + public String getRuleCode() { + return ruleCode; + } + + public FindingSeverity getSeverity() { + return severity; + } + + public String getFilePath() { + return filePath; + } + + public String getLocation() { + return location; + } + + public String getMessage() { + return message; + } + + public FixSuggestion getSuggestion() { + return suggestion; + } + + public FindingStatus getStatus() { + return status; + } + + public Integer getAppliedRevision() { + return appliedRevision; + } + + public Instant getCreatedAt() { + return createdAt; + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/validation/ValidationFindingRepository.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/validation/ValidationFindingRepository.java new file mode 100644 index 00000000..9fb21b42 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/validation/ValidationFindingRepository.java @@ -0,0 +1,24 @@ +package com.iflytek.skillhub.domain.authoring.validation; + +import java.util.List; +import java.util.Optional; + +/** + * Domain repository contract for validation findings. + */ +public interface ValidationFindingRepository { + + ValidationFinding save(ValidationFinding finding); + + Optional findById(Long id); + + List findByRunId(Long runId); + + List findByRunIdAndStatus(Long runId, FindingStatus status); + + List findByRunIdAndStatusAndLayer(Long runId, FindingStatus status, ValidationLayer layer); + + List findByRunIdIn(List runIds); + + void deleteByRunId(Long runId); +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/validation/ValidationLayer.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/validation/ValidationLayer.java new file mode 100644 index 00000000..4ca2694d --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/validation/ValidationLayer.java @@ -0,0 +1,10 @@ +package com.iflytek.skillhub.domain.authoring.validation; + +/** + * The three validation layers executed by every run, in order. + */ +public enum ValidationLayer { + STRUCTURE, + CONFIG, + BEHAVIOR +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/validation/ValidationRun.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/validation/ValidationRun.java new file mode 100644 index 00000000..ecb72396 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/validation/ValidationRun.java @@ -0,0 +1,191 @@ +package com.iflytek.skillhub.domain.authoring.validation; + +import com.iflytek.skillhub.domain.shared.exception.DomainConflictException; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.PrePersist; +import jakarta.persistence.Table; +import jakarta.persistence.Version; +import java.time.Clock; +import java.time.Instant; +import java.util.Map; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; + +/** + * One immutable validation execution bound to a specific draft revision. + * + *

Transitions are guarded: only the executor moves a run forward and only + * active runs can be cancelled or timed out. {@code cancelRequested} is a + * cooperative flag set by the cancel API and honored between tasks. + */ +@Entity +@Table(name = "validation_run") +public class ValidationRun { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "draft_id", nullable = false) + private Long draftId; + + @Column(name = "draft_revision", nullable = false) + private Integer draftRevision; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + private ValidationRunStatus status = ValidationRunStatus.QUEUED; + + @Column(name = "cancel_requested", nullable = false) + private boolean cancelRequested; + + @Column(name = "error_count", nullable = false) + private Integer errorCount = 0; + + @Column(name = "warning_count", nullable = false) + private Integer warningCount = 0; + + @JdbcTypeCode(SqlTypes.JSON) + @Column(name = "summary") + private Map summary; + + @Column(name = "triggered_by", nullable = false, length = 128) + private String triggeredBy; + + @Column(name = "started_at") + private Instant startedAt; + + @Column(name = "finished_at") + private Instant finishedAt; + + @Column(name = "created_at", nullable = false, updatable = false) + private Instant createdAt; + + @Version + @Column(name = "version", nullable = false) + private Long optimisticVersion; + + protected ValidationRun() { + } + + public ValidationRun(Long draftId, Integer draftRevision, String triggeredBy) { + this.draftId = draftId; + this.draftRevision = draftRevision; + this.triggeredBy = triggeredBy; + this.status = ValidationRunStatus.QUEUED; + } + + @PrePersist + protected void onCreate() { + createdAt = Instant.now(Clock.systemUTC()); + } + + public void markPreparing() { + requireActive("markPreparing"); + if (status != ValidationRunStatus.QUEUED) { + throw new DomainConflictException("error.authoring.run.notQueued", id); + } + status = ValidationRunStatus.PREPARING; + } + + public void markRunning(Instant now) { + requireActive("markRunning"); + status = ValidationRunStatus.RUNNING; + startedAt = now; + } + + /** + * Settles the run into a terminal status and records finding counters plus a + * machine-readable summary. The status must be one of the natural outcomes + * (SUCCEEDED / FAILED / CANCELLED / TIMED_OUT). + */ + public void finish(ValidationRunStatus terminalStatus, int errorCount, int warningCount, + Map summary, Instant now) { + if (terminalStatus == null || !terminalStatus.isTerminal()) { + throw new IllegalArgumentException("Terminal status required: " + terminalStatus); + } + requireActive("finish"); + status = terminalStatus; + this.errorCount = errorCount; + this.warningCount = warningCount; + this.summary = summary; + this.finishedAt = now; + } + + /** Idempotent cooperative cancel request; repeated calls keep the first state. */ + public void requestCancel() { + if (isActive()) { + cancelRequested = true; + } + } + + public boolean isCancelRequested() { + return cancelRequested; + } + + public boolean isActive() { + return status.isActive(); + } + + private void requireActive(String action) { + if (status.isTerminal()) { + throw new DomainConflictException("error.authoring.run.terminal", id, status); + } + } + + // Getters + + public Long getId() { + return id; + } + + public Long getDraftId() { + return draftId; + } + + public Integer getDraftRevision() { + return draftRevision; + } + + public ValidationRunStatus getStatus() { + return status; + } + + public Integer getErrorCount() { + return errorCount; + } + + public Integer getWarningCount() { + return warningCount; + } + + public Map getSummary() { + return summary; + } + + public String getTriggeredBy() { + return triggeredBy; + } + + public Instant getStartedAt() { + return startedAt; + } + + public Instant getFinishedAt() { + return finishedAt; + } + + public Instant getCreatedAt() { + return createdAt; + } + + public Long getOptimisticVersion() { + return optimisticVersion; + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/validation/ValidationRunRepository.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/validation/ValidationRunRepository.java new file mode 100644 index 00000000..689f464d --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/validation/ValidationRunRepository.java @@ -0,0 +1,28 @@ +package com.iflytek.skillhub.domain.authoring.validation; + +import java.time.Instant; +import java.util.List; +import java.util.Optional; + +/** + * Domain repository contract for validation runs. + */ +public interface ValidationRunRepository { + + ValidationRun save(ValidationRun run); + + Optional findById(Long id); + + Optional findByIdForUpdate(Long id); + + List findByDraftIdOrderByCreatedAtDesc(Long draftId); + + /** Latest run recorded for an exact draft revision, used by the submit gate. */ + Optional findFirstByDraftIdAndDraftRevisionOrderByCreatedAtDesc( + Long draftId, Integer draftRevision); + + /** Runs still in a non-terminal state, used by the maintenance sweep. */ + List findByStatusIn(List statuses); + + List findByStatusInAndCreatedAtBefore(List statuses, Instant cutoff); +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/validation/ValidationRunStatus.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/validation/ValidationRunStatus.java new file mode 100644 index 00000000..e262099d --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/validation/ValidationRunStatus.java @@ -0,0 +1,24 @@ +package com.iflytek.skillhub.domain.authoring.validation; + +/** + * Lifecycle of one validation run. Runs are immutable snapshots of a draft revision: + * once started they execute the structure, configuration, and behavior layers in order + * and settle into a terminal status. + */ +public enum ValidationRunStatus { + QUEUED, + PREPARING, + RUNNING, + SUCCEEDED, + FAILED, + CANCELLED, + TIMED_OUT; + + public boolean isTerminal() { + return this == SUCCEEDED || this == FAILED || this == CANCELLED || this == TIMED_OUT; + } + + public boolean isActive() { + return !isTerminal(); + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/validation/package-info.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/validation/package-info.java new file mode 100644 index 00000000..c33cc2b7 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/validation/package-info.java @@ -0,0 +1,5 @@ +/** + * Validation runs, persisted event streams, and findings produced by the structure, + * configuration, and behavior validation layers. + */ +package com.iflytek.skillhub.domain.authoring.validation; diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/authoring/service/DraftStructureValidatorTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/authoring/service/DraftStructureValidatorTest.java new file mode 100644 index 00000000..a2b73c01 --- /dev/null +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/authoring/service/DraftStructureValidatorTest.java @@ -0,0 +1,121 @@ +package com.iflytek.skillhub.domain.authoring.service; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.iflytek.skillhub.domain.authoring.FilePatch; +import com.iflytek.skillhub.domain.authoring.FixSuggestion; +import com.iflytek.skillhub.domain.authoring.validation.FindingDraft; +import com.iflytek.skillhub.domain.authoring.validation.ValidationLayer; +import com.iflytek.skillhub.domain.skill.metadata.SkillMetadataParser; +import com.iflytek.skillhub.domain.skill.validation.PackageEntry; +import java.nio.charset.StandardCharsets; +import java.util.List; +import org.junit.jupiter.api.Test; + +class DraftStructureValidatorTest { + + private final DraftStructureValidator validator = new DraftStructureValidator( + new SkillMetadataParser(), new SkillScaffoldGenerator()); + + private static PackageEntry entry(String path, String content) { + byte[] bytes = content.getBytes(StandardCharsets.UTF_8); + return new PackageEntry(path, bytes, bytes.length, "text/plain"); + } + + @Test + void validPackageProducesNoFindings() { + DraftStructureValidator.StructureReport report = validator.validate("demo", "does demo things", + List.of(entry("SKILL.md", validSkillMd()), entry("scripts/check.sh", "echo OK"))); + + assertThat(report.findings()).isEmpty(); + assertThat(report.skillMdPresent()).isTrue(); + assertThat(report.hasErrors()).isFalse(); + } + + @Test + void missingSkillMdYieldsScaffoldSuggestion() { + DraftStructureValidator.StructureReport report = validator.validate("demo", "does demo things", + List.of(entry("scripts/check.sh", "echo OK"))); + + assertThat(report.skillMdPresent()).isFalse(); + assertThat(report.hasErrors()).isTrue(); + FindingDraft finding = report.findings().get(0); + assertThat(finding.ruleCode()).isEqualTo("SKILL_MD_MISSING"); + FixSuggestion suggestion = finding.suggestion(); + assertThat(suggestion).isNotNull(); + assertThat(suggestion.patches()).singleElement().satisfies(patch -> { + assertThat(patch.filePath()).isEqualTo("SKILL.md"); + assertThat(patch.isCreation()).isTrue(); + assertThat(patch.newValue()).contains("name: demo"); + }); + } + + @Test + void missingRequiredFrontmatterFieldYieldsInsertPatch() { + String skillMd = "---\nname: demo\n---\n\n# Demo\n"; + DraftStructureValidator.StructureReport report = validator.validate("demo", null, + List.of(entry("SKILL.md", skillMd))); + + assertThat(report.hasErrors()).isTrue(); + FindingDraft finding = report.findings().get(0); + assertThat(finding.ruleCode()).isEqualTo("FRONTMATTER_FIELD_MISSING"); + FixSuggestion suggestion = finding.suggestion(); + assertThat(suggestion).isNotNull(); + FilePatch patch = suggestion.patches().get(0); + assertThat(patch.filePath()).isEqualTo("SKILL.md"); + assertThat(patch.oldSha256()).isEqualTo(sha256Hex(skillMd)); + assertThat(patch.oldValue()).isEqualTo(skillMd); + // the missing field is inserted inside the frontmatter block, keeping valid YAML + assertThat(patch.newValue()).startsWith("---\n"); + assertThat(patch.newValue()).contains("description: TODO: describe what this skill does"); + assertThat(patch.newValue()).contains("name: demo"); + assertThat(patch.newValue()).endsWith("---\n\n# Demo\n"); + } + + @Test + void frontmatterWithoutOpeningMarkerIsReportedWithoutSuggestion() { + DraftStructureValidator.StructureReport report = validator.validate("demo", null, + List.of(entry("SKILL.md", "name: demo\ndescription: x\n"))); + + assertThat(report.hasErrors()).isTrue(); + assertThat(report.findings().get(0).ruleCode()).isEqualTo("FRONTMATTER_MISSING_START"); + assertThat(report.findings().get(0).suggestion()).isNull(); + } + + @Test + void disallowedExtensionIsAWarning() { + DraftStructureValidator.StructureReport report = validator.validate("demo", null, + List.of(entry("SKILL.md", validSkillMd()), entry("binary.exe", "MZ"))); + + assertThat(report.findings()).singleElement().satisfies(finding -> { + assertThat(finding.ruleCode()).isEqualTo("EXTENSION_DISALLOWED"); + assertThat(finding.severity()).isEqualTo( + com.iflytek.skillhub.domain.authoring.validation.FindingSeverity.WARNING); + assertThat(finding.layer()).isEqualTo(ValidationLayer.STRUCTURE); + }); + } + + @Test + void invalidPathIsAnError() { + DraftStructureValidator.StructureReport report = validator.validate("demo", null, + List.of(entry("SKILL.md", validSkillMd()), entry("../escape.sh", "echo hi"))); + + assertThat(report.hasErrors()).isTrue(); + assertThat(report.findings().stream().map(FindingDraft::ruleCode)) + .contains("PATH_INVALID"); + } + + private String validSkillMd() { + return "---\nname: demo\ndescription: A demo skill\n---\n\n# Demo\n"; + } + + private static String sha256Hex(String content) { + try { + return java.util.HexFormat.of().formatHex( + java.security.MessageDigest.getInstance("SHA-256") + .digest(content.getBytes(java.nio.charset.StandardCharsets.UTF_8))); + } catch (java.security.NoSuchAlgorithmException exception) { + throw new IllegalStateException(exception); + } + } +} diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/authoring/service/SkillScaffoldGeneratorTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/authoring/service/SkillScaffoldGeneratorTest.java new file mode 100644 index 00000000..07fbb914 --- /dev/null +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/authoring/service/SkillScaffoldGeneratorTest.java @@ -0,0 +1,42 @@ +package com.iflytek.skillhub.domain.authoring.service; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.iflytek.skillhub.domain.skill.metadata.SkillMetadataParser; +import org.junit.jupiter.api.Test; + +class SkillScaffoldGeneratorTest { + + private final SkillScaffoldGenerator generator = new SkillScaffoldGenerator(); + private final SkillMetadataParser parser = new SkillMetadataParser(); + + @Test + void skillMdScaffoldHasValidFrontmatter() { + String scaffold = generator.generateSkillMd("My Demo Skill", "It summarizes documents."); + + var metadata = parser.parse(scaffold); + assertThat(metadata.name()).isEqualTo("my-demo-skill"); + assertThat(metadata.description()).isEqualTo("It summarizes documents."); + assertThat(scaffold).contains("# my-demo-skill"); + } + + @Test + void blankRequirementFallsBackToPlaceholder() { + String scaffold = generator.generateSkillMd("demo", " "); + + var metadata = parser.parse(scaffold); + assertThat(metadata.description()).isNotBlank(); + assertThat(scaffold).contains("Describe what this skill does"); + } + + @Test + void validationYamlScaffoldShape() { + String yaml = generator.generateValidationYaml(); + + // the runtime layer's spec parser re-verifies parseability; here we pin + // the scaffold's shape so the two cannot drift + assertThat(yaml).contains("name: smoke"); + assertThat(yaml).contains("script: scripts/check.sh"); + assertThat(yaml).contains("type: script"); + } +} diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/DraftFileJpaRepository.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/DraftFileJpaRepository.java new file mode 100644 index 00000000..458bc818 --- /dev/null +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/DraftFileJpaRepository.java @@ -0,0 +1,30 @@ +package com.iflytek.skillhub.infra.jpa; + +import com.iflytek.skillhub.domain.authoring.DraftFile; +import com.iflytek.skillhub.domain.authoring.DraftFileRepository; +import java.util.List; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +/** + * JPA-backed repository for draft files. + */ +@Repository +public interface DraftFileJpaRepository extends JpaRepository, DraftFileRepository { + + List findByDraftIdOrderByFilePath(Long draftId); + + Optional findByDraftIdAndFilePath(Long draftId, String filePath); + + List findByDraftIdAndFilePathIn(Long draftId, List filePaths); + + long countByDraftId(Long draftId); + + @Query("SELECT COALESCE(SUM(file.size), 0) FROM DraftFile file WHERE file.draftId = :draftId") + long sumSizeByDraftId(@Param("draftId") Long draftId); + + void deleteByDraftId(Long draftId); +} diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillDraftJpaRepository.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillDraftJpaRepository.java new file mode 100644 index 00000000..db139890 --- /dev/null +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillDraftJpaRepository.java @@ -0,0 +1,23 @@ +package com.iflytek.skillhub.infra.jpa; + +import com.iflytek.skillhub.domain.authoring.SkillDraft; +import com.iflytek.skillhub.domain.authoring.SkillDraftRepository; +import java.util.List; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +/** + * JPA-backed repository for skill authoring drafts. + */ +@Repository +public interface SkillDraftJpaRepository extends JpaRepository, SkillDraftRepository { + + List findByOwnerIdOrderByUpdatedAtDesc(String ownerId); + + List findByNamespaceId(Long namespaceId); + + Optional findByOwnerIdAndNameIgnoreCase(String ownerId, String name); + + boolean existsByIdAndOwnerId(Long id, String ownerId); +} diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/ValidationEventJpaRepository.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/ValidationEventJpaRepository.java new file mode 100644 index 00000000..8bcdafd2 --- /dev/null +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/ValidationEventJpaRepository.java @@ -0,0 +1,30 @@ +package com.iflytek.skillhub.infra.jpa; + +import com.iflytek.skillhub.domain.authoring.validation.ValidationEvent; +import com.iflytek.skillhub.domain.authoring.validation.ValidationEventRepository; +import java.util.List; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +/** + * JPA-backed repository for validation run events. + */ +@Repository +public interface ValidationEventJpaRepository extends JpaRepository, + ValidationEventRepository { + + List findByRunIdOrderBySeqAsc(Long runId); + + List findByRunIdAndSeqGreaterThanOrderBySeqAsc(Long runId, int afterSeq); + + List findByRunIdAndSeqIn(Long runId, List seqs); + + @Override + @Query("SELECT MAX(event.seq) FROM ValidationEvent event WHERE event.runId = :runId") + Optional findMaxSeqByRunId(@Param("runId") Long runId); + + void deleteByRunId(Long runId); +} diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/ValidationFindingJpaRepository.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/ValidationFindingJpaRepository.java new file mode 100644 index 00000000..03696b0f --- /dev/null +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/ValidationFindingJpaRepository.java @@ -0,0 +1,31 @@ +package com.iflytek.skillhub.infra.jpa; + +import com.iflytek.skillhub.domain.authoring.validation.ValidationFinding; +import com.iflytek.skillhub.domain.authoring.validation.ValidationFindingRepository; +import com.iflytek.skillhub.domain.authoring.validation.ValidationLayer; +import com.iflytek.skillhub.domain.authoring.validation.FindingStatus; +import java.util.List; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +/** + * JPA-backed repository for validation findings. + */ +@Repository +public interface ValidationFindingJpaRepository extends JpaRepository, + ValidationFindingRepository { + + Optional findById(Long id); + + List findByRunId(Long runId); + + List findByRunIdAndStatus(Long runId, FindingStatus status); + + List findByRunIdAndStatusAndLayer( + Long runId, FindingStatus status, ValidationLayer layer); + + List findByRunIdIn(List runIds); + + void deleteByRunId(Long runId); +} diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/ValidationRunJpaRepository.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/ValidationRunJpaRepository.java new file mode 100644 index 00000000..d9706ea1 --- /dev/null +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/ValidationRunJpaRepository.java @@ -0,0 +1,37 @@ +package com.iflytek.skillhub.infra.jpa; + +import com.iflytek.skillhub.domain.authoring.validation.ValidationRun; +import com.iflytek.skillhub.domain.authoring.validation.ValidationRunRepository; +import com.iflytek.skillhub.domain.authoring.validation.ValidationRunStatus; +import java.time.Instant; +import java.util.List; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +/** + * JPA-backed repository for validation runs. + * + *

The claim query uses explicit {@code FOR UPDATE} SQL for the same H2 PostgreSQL + * compatibility reason documented on {@link SkillVersionJpaRepository}. + */ +@Repository +public interface ValidationRunJpaRepository extends JpaRepository, + ValidationRunRepository { + + @Override + @Query(value = "SELECT * FROM validation_run WHERE id = :id FOR UPDATE", nativeQuery = true) + Optional findByIdForUpdate(@Param("id") Long id); + + List findByDraftIdOrderByCreatedAtDesc(Long draftId); + + Optional findFirstByDraftIdAndDraftRevisionOrderByCreatedAtDesc( + Long draftId, Integer draftRevision); + + List findByStatusIn(List statuses); + + List findByStatusInAndCreatedAtBefore( + List statuses, Instant cutoff); +}