mirror of
https://github.com/iflytek/skillhub.git
synced 2026-10-17 04:33:36 +00:00
feat(authoring): draft domain model, CRUD and structure-layer validation
Layer (a) of the authoring platform (tracked in #934): the SkillDraft domain — drafts with revisions, digest and optimistic concurrency, draft files in object storage, validation runs/events/findings — plus the V68 migration, the draft CRUD REST API with a scaffolded SKILL.md, and the STRUCTURE validation layer executed by the run orchestrator (package layout, frontmatter rules, file limits) reporting findings with machine-applicable fix suggestions. Runs and findings stream over SSE with a polling fallback. Later layers add runtime binding and behavior execution (b), the fix loop and publish integration (c), the web workbench (d) and docs (e). Verified: skillhub-domain 609 tests green; the draft flow integration test covers scaffold → broken frontmatter → FAILED run with a fixable FRONTMATTER_FIELD_MISSING finding → repair → SUCCEEDED re-validation, plus the optimistic-concurrency conflict path. Signed-off-by: zjncs <18910855655@163.com>
This commit is contained in:
parent
a463efb535
commit
a5729d24e1
54 changed files with 4013 additions and 1 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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<DraftResponse> createDraft(@Valid
|
||||
@RequestBody CreateDraftRequest request,
|
||||
@RequestAttribute("userId") String userId,
|
||||
@RequestAttribute(value = "platformRoles", required = false)
|
||||
Set<String> 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<List<DraftResponse>> 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<DraftResponse> getDraft(@PathVariable Long draftId,
|
||||
@RequestAttribute("userId") String userId,
|
||||
@RequestAttribute(value = "platformRoles", required = false)
|
||||
Set<String> 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<Void> deleteDraft(@PathVariable Long draftId,
|
||||
@RequestAttribute("userId") String userId,
|
||||
@RequestAttribute(value = "platformRoles", required = false)
|
||||
Set<String> 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<List<DraftFileResponse>> 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<SaveDraftFileResponse> saveFile(@PathVariable Long draftId,
|
||||
@Valid
|
||||
@RequestBody SaveDraftFileRequest request,
|
||||
@RequestAttribute("userId") String userId,
|
||||
@RequestAttribute(value = "platformRoles", required = false)
|
||||
Set<String> 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<DraftFileContentResponse> readFile(@PathVariable Long draftId,
|
||||
@RequestParam("path") String path,
|
||||
@RequestAttribute("userId") String userId,
|
||||
@RequestAttribute(value = "platformRoles", required = false)
|
||||
Set<String> 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<Void> 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<String> 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<ValidationRunResponse> startRun(@PathVariable Long draftId,
|
||||
@RequestAttribute("userId") String userId,
|
||||
@RequestAttribute(value = "platformRoles", required = false)
|
||||
Set<String> 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<List<ValidationRunResponse>> 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<ValidationRunResponse> getRun(@PathVariable Long runId,
|
||||
@RequestAttribute("userId") String userId,
|
||||
@RequestAttribute(value = "platformRoles", required = false)
|
||||
Set<String> 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<ValidationRunResponse> cancelRun(@PathVariable Long runId,
|
||||
@RequestAttribute("userId") String userId,
|
||||
@RequestAttribute(value = "platformRoles", required = false)
|
||||
Set<String> 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<List<ValidationEventResponse>> listEvents(
|
||||
@PathVariable Long runId,
|
||||
@RequestParam(value = "afterSeq", required = false) Integer afterSeq,
|
||||
@RequestAttribute("userId") String userId,
|
||||
@RequestAttribute(value = "platformRoles", required = false)
|
||||
Set<String> 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<List<ValidationFindingResponse>> listFindings(
|
||||
@PathVariable Long runId,
|
||||
@RequestAttribute("userId") String userId,
|
||||
@RequestAttribute(value = "platformRoles", required = false)
|
||||
Set<String> platformRoles) {
|
||||
runService.getOwnedRun(runId, userId, platformRoles);
|
||||
return ok("response.success", runService.listFindings(runId).stream()
|
||||
.map(ValidationFindingResponse::from)
|
||||
.toList());
|
||||
}
|
||||
}
|
||||
|
|
@ -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<String> platformRoles) {
|
||||
ValidationRun run = runService.getOwnedRun(runId, userId, platformRoles);
|
||||
int lastSeq = resolveCursor(afterSeq, lastEventId);
|
||||
List<ValidationEvent> 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
) {
|
||||
}
|
||||
|
|
@ -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
|
||||
) {
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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<String, Object> 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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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<String, Object> summary
|
||||
) {
|
||||
|
||||
public static ValidationRunResponse from(ValidationRun run) {
|
||||
return from(run, run.getSummary());
|
||||
}
|
||||
|
||||
public static ValidationRunResponse from(ValidationRun run, Map<String, Object> 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<Long, CopyOnWriteArrayList<EmitterSession>> 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<ValidationEvent> 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<EmitterSession> 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<EmitterSession> sessions = sessionsByRun.remove(runId);
|
||||
if (sessions == null) {
|
||||
return;
|
||||
}
|
||||
for (EmitterSession session : sessions) {
|
||||
session.emitter().complete();
|
||||
}
|
||||
}
|
||||
|
||||
private void detach(Long runId, EmitterSession session) {
|
||||
List<EmitterSession> sessions = sessionsByRun.get(runId);
|
||||
if (sessions != null) {
|
||||
sessions.remove(session);
|
||||
}
|
||||
}
|
||||
|
||||
private String envelope(ValidationEvent event) {
|
||||
Map<String, Object> payload = event.getPayload() == null ? Map.of() : event.getPayload();
|
||||
Map<String, Object> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<Long, AtomicBoolean> cancelFlags = new ConcurrentHashMap<>();
|
||||
|
||||
/** Serializes the RUN_FINISHED emission so it happens exactly once per run. */
|
||||
private final Map<Long, Object> 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<String> 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<String, Object> 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<PackageEntry> 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<String, Object> 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<String, Object> summary(RunContext context) {
|
||||
Map<String, Object> 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<FindingDraft> 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<ValidationRun> 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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} 的修复建议无法解析
|
||||
|
|
|
|||
|
|
@ -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<String, byte[]> 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<String> 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<ValidationFinding> 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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<DraftFile> findByDraftIdOrderByFilePath(Long draftId);
|
||||
|
||||
Optional<DraftFile> findByDraftIdAndFilePath(Long draftId, String filePath);
|
||||
|
||||
List<DraftFile> findByDraftIdAndFilePathIn(Long draftId, List<String> filePaths);
|
||||
|
||||
void delete(DraftFile file);
|
||||
|
||||
void deleteByDraftId(Long draftId);
|
||||
|
||||
long countByDraftId(Long draftId);
|
||||
|
||||
long sumSizeByDraftId(Long draftId);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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<FilePatch> patches
|
||||
) {
|
||||
|
||||
public List<FilePatch> safePatches() {
|
||||
return patches == null ? List.of() : List.copyOf(patches);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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<SkillDraft> findById(Long id);
|
||||
|
||||
List<SkillDraft> findByOwnerIdOrderByUpdatedAtDesc(String ownerId);
|
||||
|
||||
List<SkillDraft> findByNamespaceId(Long namespaceId);
|
||||
|
||||
/** Case-insensitive uniqueness of (owner, name) used at creation time. */
|
||||
Optional<SkillDraft> findByOwnerIdAndNameIgnoreCase(String ownerId, String name);
|
||||
|
||||
void delete(SkillDraft draft);
|
||||
|
||||
boolean existsByIdAndOwnerId(Long id, String ownerId);
|
||||
}
|
||||
|
|
@ -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.
|
||||
*
|
||||
* <p>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;
|
||||
|
|
@ -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.
|
||||
*
|
||||
* <p>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<FindingDraft> 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<PackageEntry> entries) {
|
||||
List<FindingDraft> findings = new ArrayList<>();
|
||||
Set<String> 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<FindingDraft> complianceFindings(Map<String, Object> frontmatter,
|
||||
List<PackageEntry> entries) {
|
||||
List<FindingDraft> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
*
|
||||
* <p>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<String> 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<SkillDraft> listDrafts(String ownerId) {
|
||||
return draftRepository.findByOwnerIdOrderByUpdatedAtDesc(ownerId);
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public SkillDraft getOwnedDraft(Long draftId, String userId, Set<String> 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<String> platformRoles) {
|
||||
SkillDraft draft = getOwnedDraft(draftId, userId, platformRoles);
|
||||
List<String> 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<String> 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<DraftFile> 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<String> 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<DraftFile> listFiles(Long draftId) {
|
||||
return fileRepository.findByDraftIdOrderByFilePath(draftId);
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public FileContent readFile(Long draftId, String userId, String rawPath,
|
||||
Set<String> 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<FilePatch> patches,
|
||||
Integer expectedRevision, Set<String> 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<DraftFile> 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<PackageEntry> materializeEntries(Long draftId) {
|
||||
List<PackageEntry> 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<DraftFile> 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<String> 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<String> 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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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<ValidationRunStatus> 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<String> 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<String> 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<ValidationRun> settleIfActive(Long runId, ValidationRunStatus terminalStatus,
|
||||
int errorCount, int warningCount,
|
||||
Map<String, Object> 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<String, Object> 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<String> 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<ValidationRun> listRuns(Long draftId) {
|
||||
return runRepository.findByDraftIdOrderByCreatedAtDesc(draftId);
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public List<ValidationEvent> listEvents(Long runId, Integer afterSeq) {
|
||||
if (afterSeq == null) {
|
||||
return eventRepository.findByRunIdOrderBySeqAsc(runId);
|
||||
}
|
||||
return eventRepository.findByRunIdAndSeqGreaterThanOrderBySeqAsc(runId, afterSeq);
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public List<ValidationFinding> listFindings(Long runId) {
|
||||
List<ValidationFinding> 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<ValidationRun> findActiveRuns() {
|
||||
return runRepository.findByStatusIn(ACTIVE_STATUSES);
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public List<ValidationRun> 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<String> platformRoles) {
|
||||
if (draft.getOwnerId().equals(userId)
|
||||
|| (platformRoles != null && platformRoles.contains(SUPER_ADMIN))) {
|
||||
return;
|
||||
}
|
||||
throw new DomainForbiddenException("error.authoring.draft.forbidden", draft.getId());
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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<String, Object> 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<String, Object> 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<String, Object> getPayload() {
|
||||
return payload;
|
||||
}
|
||||
|
||||
public Instant getCreatedAt() {
|
||||
return createdAt;
|
||||
}
|
||||
}
|
||||
|
|
@ -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<ValidationEvent> findByRunIdOrderBySeqAsc(Long runId);
|
||||
|
||||
/** Events with seq strictly greater than the cursor, ordered for replay. */
|
||||
List<ValidationEvent> findByRunIdAndSeqGreaterThanOrderBySeqAsc(Long runId, int afterSeq);
|
||||
|
||||
List<ValidationEvent> findByRunIdAndSeqIn(Long runId, List<Integer> seqs);
|
||||
|
||||
Optional<Integer> findMaxSeqByRunId(Long runId);
|
||||
|
||||
void deleteByRunId(Long runId);
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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<ValidationFinding> findById(Long id);
|
||||
|
||||
List<ValidationFinding> findByRunId(Long runId);
|
||||
|
||||
List<ValidationFinding> findByRunIdAndStatus(Long runId, FindingStatus status);
|
||||
|
||||
List<ValidationFinding> findByRunIdAndStatusAndLayer(Long runId, FindingStatus status, ValidationLayer layer);
|
||||
|
||||
List<ValidationFinding> findByRunIdIn(List<Long> runIds);
|
||||
|
||||
void deleteByRunId(Long runId);
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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.
|
||||
*
|
||||
* <p>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<String, Object> 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<String, Object> 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<String, Object> 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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<ValidationRun> findById(Long id);
|
||||
|
||||
Optional<ValidationRun> findByIdForUpdate(Long id);
|
||||
|
||||
List<ValidationRun> findByDraftIdOrderByCreatedAtDesc(Long draftId);
|
||||
|
||||
/** Latest run recorded for an exact draft revision, used by the submit gate. */
|
||||
Optional<ValidationRun> findFirstByDraftIdAndDraftRevisionOrderByCreatedAtDesc(
|
||||
Long draftId, Integer draftRevision);
|
||||
|
||||
/** Runs still in a non-terminal state, used by the maintenance sweep. */
|
||||
List<ValidationRun> findByStatusIn(List<ValidationRunStatus> statuses);
|
||||
|
||||
List<ValidationRun> findByStatusInAndCreatedAtBefore(List<ValidationRunStatus> statuses, Instant cutoff);
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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<DraftFile, Long>, DraftFileRepository {
|
||||
|
||||
List<DraftFile> findByDraftIdOrderByFilePath(Long draftId);
|
||||
|
||||
Optional<DraftFile> findByDraftIdAndFilePath(Long draftId, String filePath);
|
||||
|
||||
List<DraftFile> findByDraftIdAndFilePathIn(Long draftId, List<String> 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);
|
||||
}
|
||||
|
|
@ -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<SkillDraft, Long>, SkillDraftRepository {
|
||||
|
||||
List<SkillDraft> findByOwnerIdOrderByUpdatedAtDesc(String ownerId);
|
||||
|
||||
List<SkillDraft> findByNamespaceId(Long namespaceId);
|
||||
|
||||
Optional<SkillDraft> findByOwnerIdAndNameIgnoreCase(String ownerId, String name);
|
||||
|
||||
boolean existsByIdAndOwnerId(Long id, String ownerId);
|
||||
}
|
||||
|
|
@ -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<ValidationEvent, Long>,
|
||||
ValidationEventRepository {
|
||||
|
||||
List<ValidationEvent> findByRunIdOrderBySeqAsc(Long runId);
|
||||
|
||||
List<ValidationEvent> findByRunIdAndSeqGreaterThanOrderBySeqAsc(Long runId, int afterSeq);
|
||||
|
||||
List<ValidationEvent> findByRunIdAndSeqIn(Long runId, List<Integer> seqs);
|
||||
|
||||
@Override
|
||||
@Query("SELECT MAX(event.seq) FROM ValidationEvent event WHERE event.runId = :runId")
|
||||
Optional<Integer> findMaxSeqByRunId(@Param("runId") Long runId);
|
||||
|
||||
void deleteByRunId(Long runId);
|
||||
}
|
||||
|
|
@ -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<ValidationFinding, Long>,
|
||||
ValidationFindingRepository {
|
||||
|
||||
Optional<ValidationFinding> findById(Long id);
|
||||
|
||||
List<ValidationFinding> findByRunId(Long runId);
|
||||
|
||||
List<ValidationFinding> findByRunIdAndStatus(Long runId, FindingStatus status);
|
||||
|
||||
List<ValidationFinding> findByRunIdAndStatusAndLayer(
|
||||
Long runId, FindingStatus status, ValidationLayer layer);
|
||||
|
||||
List<ValidationFinding> findByRunIdIn(List<Long> runIds);
|
||||
|
||||
void deleteByRunId(Long runId);
|
||||
}
|
||||
|
|
@ -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.
|
||||
*
|
||||
* <p>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<ValidationRun, Long>,
|
||||
ValidationRunRepository {
|
||||
|
||||
@Override
|
||||
@Query(value = "SELECT * FROM validation_run WHERE id = :id FOR UPDATE", nativeQuery = true)
|
||||
Optional<ValidationRun> findByIdForUpdate(@Param("id") Long id);
|
||||
|
||||
List<ValidationRun> findByDraftIdOrderByCreatedAtDesc(Long draftId);
|
||||
|
||||
Optional<ValidationRun> findFirstByDraftIdAndDraftRevisionOrderByCreatedAtDesc(
|
||||
Long draftId, Integer draftRevision);
|
||||
|
||||
List<ValidationRun> findByStatusIn(List<ValidationRunStatus> statuses);
|
||||
|
||||
List<ValidationRun> findByStatusInAndCreatedAtBefore(
|
||||
List<ValidationRunStatus> statuses, Instant cutoff);
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue