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:
zjncs 2026-10-10 14:34:33 +08:00
parent a463efb535
commit a5729d24e1
54 changed files with 4013 additions and 1 deletions

View file

@ -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) {

View file

@ -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;
}
}

View file

@ -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;
}
}

View file

@ -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());
}
}

View file

@ -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);
}
}

View file

@ -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
) {
}

View file

@ -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
) {
}

View file

@ -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());
}
}

View file

@ -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());
}
}

View file

@ -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);
}
}

View file

@ -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());
}
}

View file

@ -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());
}
}

View file

@ -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());
}
}

View file

@ -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);
}
}

View file

@ -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;
}
}
}
}

View file

@ -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));
}
}
}

View file

@ -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
}
}
}

View file

@ -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.

View file

@ -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);

View file

@ -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

View file

@ -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}

View file

@ -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} 的修复建议无法解析

View file

@ -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;
}
}

View file

@ -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;
}
}

View file

@ -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);
}

View file

@ -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;
}
}

View file

@ -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);
}
}

View file

@ -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;
}
}

View file

@ -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);
}

View file

@ -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;

View file

@ -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);
}
}
}

View file

@ -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));
}
}

View file

@ -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();
}
}

View file

@ -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());
}
}

View file

@ -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);
}
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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;
}
}

View file

@ -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);
}

View file

@ -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
}

View file

@ -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;
}
}

View file

@ -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);
}

View file

@ -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
}

View file

@ -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;
}
}

View file

@ -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);
}

View file

@ -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();
}
}

View file

@ -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;

View file

@ -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);
}
}
}

View file

@ -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");
}
}

View file

@ -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);
}

View file

@ -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);
}

View file

@ -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);
}

View file

@ -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);
}

View file

@ -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);
}