From 258062985e60fed40552ac21cd58170d5ca6a529 Mon Sep 17 00:00:00 2001 From: zjncs <18910855655@163.com> Date: Sun, 20 Sep 2026 02:35:40 +0800 Subject: [PATCH 01/19] feat(authoring): add draft domain model, validation findings and persistence Skill drafts with per-file content-addressed storage, runtime bindings (agent type, adapter config, tool allowlist, MCP server declarations), validation runs with ordered events and findings, plus the Flyway V60 schema (JSONB columns) and JPA repositories. Domain services cover draft lifecycle, file save/read with optimistic revision checks, binding validation, fix application, submit gating, and the structure/spec/ assertion rules with fix suggestions. Signed-off-by: zjncs <18910855655@163.com> --- .../db/migration/V60__authoring_platform.sql | 130 +++++ .../domain/authoring/AgentRuntimeType.java | 33 ++ .../skillhub/domain/authoring/DraftFile.java | 121 +++++ .../domain/authoring/DraftFileRepository.java | 26 + .../skillhub/domain/authoring/FilePatch.java | 19 + .../domain/authoring/FixSuggestion.java | 23 + .../domain/authoring/RuntimeBinding.java | 134 +++++ .../authoring/RuntimeBindingRepository.java | 15 + .../skillhub/domain/authoring/SkillDraft.java | 194 ++++++++ .../authoring/SkillDraftRepository.java | 25 + .../domain/authoring/package-info.java | 9 + .../authoring/runtime/AssertionEvaluator.java | 221 +++++++++ .../authoring/runtime/AssertionFailure.java | 13 + .../authoring/runtime/RuntimeEventSink.java | 21 + .../runtime/RuntimeExecutionContext.java | 50 ++ .../runtime/SkillRuntimeAdapter.java | 46 ++ .../authoring/runtime/TaskCancellation.java | 12 + .../domain/authoring/runtime/TaskResult.java | 56 +++ .../authoring/runtime/package-info.java | 5 + .../service/DraftStructureValidator.java | 206 ++++++++ .../authoring/service/DraftSubmitService.java | 94 ++++ .../authoring/service/FindingFixService.java | 94 ++++ .../service/RuntimeBindingService.java | 101 ++++ .../service/RuntimeBindingValidator.java | 203 ++++++++ .../authoring/service/SkillDraftService.java | 463 ++++++++++++++++++ .../service/SkillScaffoldGenerator.java | 78 +++ .../service/ValidationRunService.java | 256 ++++++++++ .../domain/authoring/spec/AssertionSpec.java | 36 ++ .../domain/authoring/spec/TaskType.java | 13 + .../domain/authoring/spec/ValidationSpec.java | 28 ++ .../authoring/spec/ValidationSpecParser.java | 211 ++++++++ .../authoring/spec/ValidationTaskSpec.java | 26 + .../domain/authoring/spec/package-info.java | 4 + .../authoring/validation/FindingDraft.java | 36 ++ .../authoring/validation/FindingSeverity.java | 10 + .../authoring/validation/FindingStatus.java | 13 + .../authoring/validation/ValidationEvent.java | 98 ++++ .../validation/ValidationEventRepository.java | 23 + .../validation/ValidationEventType.java | 17 + .../validation/ValidationFinding.java | 152 ++++++ .../ValidationFindingRepository.java | 24 + .../authoring/validation/ValidationLayer.java | 10 + .../authoring/validation/ValidationRun.java | 191 ++++++++ .../validation/ValidationRunRepository.java | 28 ++ .../validation/ValidationRunStatus.java | 24 + .../authoring/validation/package-info.java | 5 + .../runtime/AssertionEvaluatorTest.java | 138 ++++++ .../service/DraftStructureValidatorTest.java | 112 +++++ .../service/SkillScaffoldGeneratorTest.java | 44 ++ .../spec/ValidationSpecParserTest.java | 182 +++++++ .../infra/jpa/DraftFileJpaRepository.java | 30 ++ .../jpa/RuntimeBindingJpaRepository.java | 19 + .../infra/jpa/SkillDraftJpaRepository.java | 23 + .../jpa/ValidationEventJpaRepository.java | 30 ++ .../jpa/ValidationFindingJpaRepository.java | 31 ++ .../infra/jpa/ValidationRunJpaRepository.java | 37 ++ 56 files changed, 4243 insertions(+) create mode 100644 server/skillhub-app/src/main/resources/db/migration/V60__authoring_platform.sql create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/AgentRuntimeType.java create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/DraftFile.java create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/DraftFileRepository.java create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/FilePatch.java create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/FixSuggestion.java create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/RuntimeBinding.java create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/RuntimeBindingRepository.java create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/SkillDraft.java create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/SkillDraftRepository.java create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/package-info.java create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/runtime/AssertionEvaluator.java create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/runtime/AssertionFailure.java create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/runtime/RuntimeEventSink.java create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/runtime/RuntimeExecutionContext.java create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/runtime/SkillRuntimeAdapter.java create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/runtime/TaskCancellation.java create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/runtime/TaskResult.java create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/runtime/package-info.java create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/service/DraftStructureValidator.java create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/service/DraftSubmitService.java create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/service/FindingFixService.java create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/service/RuntimeBindingService.java create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/service/RuntimeBindingValidator.java create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/service/SkillDraftService.java create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/service/SkillScaffoldGenerator.java create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/service/ValidationRunService.java create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/spec/AssertionSpec.java create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/spec/TaskType.java create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/spec/ValidationSpec.java create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/spec/ValidationSpecParser.java create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/spec/ValidationTaskSpec.java create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/spec/package-info.java create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/validation/FindingDraft.java create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/validation/FindingSeverity.java create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/validation/FindingStatus.java create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/validation/ValidationEvent.java create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/validation/ValidationEventRepository.java create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/validation/ValidationEventType.java create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/validation/ValidationFinding.java create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/validation/ValidationFindingRepository.java create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/validation/ValidationLayer.java create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/validation/ValidationRun.java create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/validation/ValidationRunRepository.java create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/validation/ValidationRunStatus.java create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/validation/package-info.java create mode 100644 server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/authoring/runtime/AssertionEvaluatorTest.java create mode 100644 server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/authoring/service/DraftStructureValidatorTest.java create mode 100644 server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/authoring/service/SkillScaffoldGeneratorTest.java create mode 100644 server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/authoring/spec/ValidationSpecParserTest.java create mode 100644 server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/DraftFileJpaRepository.java create mode 100644 server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/RuntimeBindingJpaRepository.java create mode 100644 server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillDraftJpaRepository.java create mode 100644 server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/ValidationEventJpaRepository.java create mode 100644 server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/ValidationFindingJpaRepository.java create mode 100644 server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/ValidationRunJpaRepository.java diff --git a/server/skillhub-app/src/main/resources/db/migration/V60__authoring_platform.sql b/server/skillhub-app/src/main/resources/db/migration/V60__authoring_platform.sql new file mode 100644 index 000000000..d986818a9 --- /dev/null +++ b/server/skillhub-app/src/main/resources/db/migration/V60__authoring_platform.sql @@ -0,0 +1,130 @@ +-- Skill authoring platform: drafts, draft files, runtime bindings, and validation +-- runs with their persisted events and findings. Drafts feed validated content into +-- the existing publish pipeline, so these tables are authored standalone and only +-- reference namespace/skill_version loosely (no circular foreign keys). + +CREATE TABLE skill_draft ( + id BIGSERIAL PRIMARY KEY, + namespace_id BIGINT NOT NULL REFERENCES namespace(id), + owner_id VARCHAR(128) NOT NULL, + name VARCHAR(128) NOT NULL, + requirement TEXT, + revision INT NOT NULL DEFAULT 1, + content_digest VARCHAR(64) NOT NULL, + validated_revision INT, + validated_run_id BIGINT, + submitted_skill_id BIGINT, + submitted_version_id BIGINT, + submitted_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + version BIGINT NOT NULL DEFAULT 0, + CONSTRAINT chk_skill_draft_revision CHECK (revision > 0) +); + +-- Name uniqueness is enforced case-insensitively per owner. +CREATE UNIQUE INDEX uq_skill_draft_owner_name + ON skill_draft(owner_id, LOWER(name)); + +CREATE INDEX idx_skill_draft_owner_updated + ON skill_draft(owner_id, updated_at DESC); + +CREATE INDEX idx_skill_draft_namespace + ON skill_draft(namespace_id); + +-- File bodies live in object storage under content-addressed keys; this table tracks +-- identity, digest, and size only. +CREATE TABLE draft_file ( + id BIGSERIAL PRIMARY KEY, + draft_id BIGINT NOT NULL REFERENCES skill_draft(id) ON DELETE CASCADE, + file_path VARCHAR(512) NOT NULL, + sha256 VARCHAR(64) NOT NULL, + size BIGINT NOT NULL, + content_type VARCHAR(128), + storage_key VARCHAR(768) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT uq_draft_file_path UNIQUE (draft_id, file_path), + CONSTRAINT chk_draft_file_size CHECK (size >= 0) +); + +CREATE INDEX idx_draft_file_draft + ON draft_file(draft_id); + +CREATE INDEX idx_draft_file_storage_key + ON draft_file(storage_key); + +-- One runtime binding per draft; secrets are referenced by name and resolved from +-- server-side configuration, never stored inline. +CREATE TABLE runtime_binding ( + id BIGSERIAL PRIMARY KEY, + draft_id BIGINT NOT NULL REFERENCES skill_draft(id) ON DELETE CASCADE, + agent_type VARCHAR(32) NOT NULL, + config JSONB, + tool_allowlist JSONB, + mcp_servers JSONB, + updated_by VARCHAR(128), + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT uq_runtime_binding_draft UNIQUE (draft_id) +); + +CREATE TABLE validation_run ( + id BIGSERIAL PRIMARY KEY, + draft_id BIGINT NOT NULL REFERENCES skill_draft(id) ON DELETE CASCADE, + draft_revision INT NOT NULL, + status VARCHAR(20) NOT NULL, + cancel_requested BOOLEAN NOT NULL DEFAULT FALSE, + error_count INT NOT NULL DEFAULT 0, + warning_count INT NOT NULL DEFAULT 0, + summary JSONB, + triggered_by VARCHAR(128) NOT NULL, + started_at TIMESTAMPTZ, + finished_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + version BIGINT NOT NULL DEFAULT 0, + CONSTRAINT chk_validation_run_error_count CHECK (error_count >= 0), + CONSTRAINT chk_validation_run_warning_count CHECK (warning_count >= 0) +); + +CREATE INDEX idx_validation_run_draft_created + ON validation_run(draft_id, created_at DESC); + +-- Partial index keeps the active-run uniqueness cheap for the maintenance sweep. +CREATE INDEX idx_validation_run_status + ON validation_run(status) + WHERE status IN ('QUEUED', 'PREPARING', 'RUNNING'); + +CREATE INDEX idx_validation_run_stale_sweep + ON validation_run(status, created_at); + +CREATE TABLE validation_event ( + id BIGSERIAL PRIMARY KEY, + run_id BIGINT NOT NULL REFERENCES validation_run(id) ON DELETE CASCADE, + seq INT NOT NULL, + event_type VARCHAR(32) NOT NULL, + phase VARCHAR(32), + payload JSONB, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT uq_validation_event_seq UNIQUE (run_id, seq) +); + +CREATE TABLE validation_finding ( + id BIGSERIAL PRIMARY KEY, + run_id BIGINT NOT NULL REFERENCES validation_run(id) ON DELETE CASCADE, + layer VARCHAR(16) NOT NULL, + rule_code VARCHAR(64) NOT NULL, + severity VARCHAR(16) NOT NULL, + file_path VARCHAR(512), + location VARCHAR(128), + message TEXT NOT NULL, + suggestion JSONB, + status VARCHAR(16) NOT NULL DEFAULT 'OPEN', + applied_revision INT, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT chk_validation_finding_status + CHECK (status IN ('OPEN', 'APPLIED', 'DISMISSED')) +); + +CREATE INDEX idx_validation_finding_run_status + ON validation_finding(run_id, status); diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/AgentRuntimeType.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/AgentRuntimeType.java new file mode 100644 index 000000000..c412a6c8b --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/AgentRuntimeType.java @@ -0,0 +1,33 @@ +package com.iflytek.skillhub.domain.authoring; + +/** + * Runtime types a draft can bind for behavior validation. The registry of supported + * adapters lives in the application layer; the domain only knows the stable identifiers. + */ +public enum AgentRuntimeType { + + /** Executes the skill's own scripts in an isolated working directory. */ + LOCAL_SCRIPT("local-script"), + + /** Sends prompts to an OpenAI-compatible chat completion endpoint with SKILL.md as context. */ + OPENAI_COMPATIBLE("openai-compatible"); + + private final String identifier; + + AgentRuntimeType(String identifier) { + this.identifier = identifier; + } + + public String identifier() { + return identifier; + } + + public static AgentRuntimeType fromIdentifier(String identifier) { + for (AgentRuntimeType type : values()) { + if (type.identifier.equals(identifier)) { + return type; + } + } + throw new IllegalArgumentException("Unknown agent runtime type: " + identifier); + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/DraftFile.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/DraftFile.java new file mode 100644 index 000000000..1ff28c061 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/DraftFile.java @@ -0,0 +1,121 @@ +package com.iflytek.skillhub.domain.authoring; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.PrePersist; +import jakarta.persistence.PreUpdate; +import jakarta.persistence.Table; +import java.time.Clock; +import java.time.Instant; + +/** + * One file inside a skill draft. File bodies live in object storage under a + * content-addressed key; this record tracks identity, integrity digest, and size. + */ +@Entity +@Table(name = "draft_file", + uniqueConstraints = @jakarta.persistence.UniqueConstraint( + name = "uq_draft_file_path", columnNames = {"draft_id", "file_path"})) +public class DraftFile { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "draft_id", nullable = false) + private Long draftId; + + @Column(name = "file_path", nullable = false, length = 512) + private String filePath; + + @Column(nullable = false, length = 64) + private String sha256; + + @Column(nullable = false) + private Long size; + + @Column(name = "content_type", length = 128) + private String contentType; + + @Column(name = "storage_key", nullable = false, length = 768) + private String storageKey; + + @Column(name = "created_at", nullable = false, updatable = false) + private Instant createdAt; + + @Column(name = "updated_at", nullable = false) + private Instant updatedAt; + + protected DraftFile() { + } + + public DraftFile(Long draftId, String filePath, String sha256, Long size, + String contentType, String storageKey) { + this.draftId = draftId; + this.filePath = filePath; + this.sha256 = sha256; + this.size = size; + this.contentType = contentType; + this.storageKey = storageKey; + } + + @PrePersist + protected void onCreate() { + Instant now = Instant.now(Clock.systemUTC()); + createdAt = now; + updatedAt = now; + } + + @PreUpdate + protected void onUpdate() { + updatedAt = Instant.now(Clock.systemUTC()); + } + + public void updateContent(String sha256, Long size, String contentType, String storageKey) { + this.sha256 = sha256; + this.size = size; + this.contentType = contentType; + this.storageKey = storageKey; + } + + // Getters + + public Long getId() { + return id; + } + + public Long getDraftId() { + return draftId; + } + + public String getFilePath() { + return filePath; + } + + public String getSha256() { + return sha256; + } + + public Long getSize() { + return size; + } + + public String getContentType() { + return contentType; + } + + public String getStorageKey() { + return storageKey; + } + + public Instant getCreatedAt() { + return createdAt; + } + + public Instant getUpdatedAt() { + return updatedAt; + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/DraftFileRepository.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/DraftFileRepository.java new file mode 100644 index 000000000..beb43ddfe --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/DraftFileRepository.java @@ -0,0 +1,26 @@ +package com.iflytek.skillhub.domain.authoring; + +import java.util.List; +import java.util.Optional; + +/** + * Domain repository contract for draft files. + */ +public interface DraftFileRepository { + + DraftFile save(DraftFile file); + + List findByDraftIdOrderByFilePath(Long draftId); + + Optional findByDraftIdAndFilePath(Long draftId, String filePath); + + List findByDraftIdAndFilePathIn(Long draftId, List filePaths); + + void delete(DraftFile file); + + void deleteByDraftId(Long draftId); + + long countByDraftId(Long draftId); + + long sumSizeByDraftId(Long draftId); +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/FilePatch.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/FilePatch.java new file mode 100644 index 000000000..2ff133672 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/FilePatch.java @@ -0,0 +1,19 @@ +package com.iflytek.skillhub.domain.authoring; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import java.util.List; + +/** + * A machine-applicable file replacement. {@code oldValue} is the exact current content + * (null when the file does not exist yet); applying the patch replaces it with + * {@code newValue}. {@code oldSha256} guards against applying a suggestion built on a + * stale revision of the file. + */ +public record FilePatch(String filePath, String oldSha256, String oldValue, String newValue) { + + /** Derived helper, not part of the persisted shape. */ + @JsonIgnore + public boolean isCreation() { + return oldValue == null; + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/FixSuggestion.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/FixSuggestion.java new file mode 100644 index 000000000..8cb78757b --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/FixSuggestion.java @@ -0,0 +1,23 @@ +package com.iflytek.skillhub.domain.authoring; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.List; + +/** + * A fix suggestion attached to a validation finding. Suggestions are proposals only: + * they are never applied without explicit user confirmation, and applying them always + * produces a new draft revision so history stays auditable. + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +@JsonIgnoreProperties(ignoreUnknown = true) +public record FixSuggestion( + String description, + @JsonProperty("patches") List patches +) { + + public List safePatches() { + return patches == null ? List.of() : List.copyOf(patches); + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/RuntimeBinding.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/RuntimeBinding.java new file mode 100644 index 000000000..c741569a5 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/RuntimeBinding.java @@ -0,0 +1,134 @@ +package com.iflytek.skillhub.domain.authoring; + +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.PreUpdate; +import jakarta.persistence.Table; +import java.time.Clock; +import java.time.Instant; +import java.util.List; +import java.util.Map; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; + +/** + * Per-draft runtime configuration used by behavior validation. Holds the agent type, + * adapter-specific configuration, tool allowlist, and MCP server declarations. + * + *

Plain secrets must never be stored here: credentials are referenced by name and + * resolved from server-side configuration at validation time. The JSON columns are + * mapped as structured collections (not Strings) so the stored documents stay real + * JSON objects and Hibernate's read path is symmetric with its write path. + */ +@Entity +@Table(name = "runtime_binding", + uniqueConstraints = @jakarta.persistence.UniqueConstraint( + name = "uq_runtime_binding_draft", columnNames = "draft_id")) +public class RuntimeBinding { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "draft_id", nullable = false) + private Long draftId; + + @Enumerated(EnumType.STRING) + @Column(name = "agent_type", nullable = false, length = 32) + private AgentRuntimeType agentType; + + @JdbcTypeCode(SqlTypes.JSON) + @Column(name = "config") + private Map config; + + @JdbcTypeCode(SqlTypes.JSON) + @Column(name = "tool_allowlist") + private List toolAllowlist; + + @JdbcTypeCode(SqlTypes.JSON) + @Column(name = "mcp_servers") + private List> mcpServers; + + @Column(name = "updated_by", length = 128) + private String updatedBy; + + @Column(name = "updated_at", nullable = false) + private Instant updatedAt; + + @Column(name = "created_at", nullable = false, updatable = false) + private Instant createdAt; + + protected RuntimeBinding() { + } + + public RuntimeBinding(Long draftId, AgentRuntimeType agentType) { + this.draftId = draftId; + this.agentType = agentType; + } + + @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 update(AgentRuntimeType agentType, Map config, + List toolAllowlist, List> mcpServers, + String updatedBy) { + this.agentType = agentType; + this.config = config; + this.toolAllowlist = toolAllowlist; + this.mcpServers = mcpServers; + this.updatedBy = updatedBy; + } + + // Getters + + public Long getId() { + return id; + } + + public Long getDraftId() { + return draftId; + } + + public AgentRuntimeType getAgentType() { + return agentType; + } + + public Map getConfig() { + return config; + } + + public List getToolAllowlist() { + return toolAllowlist; + } + + public List> getMcpServers() { + return mcpServers; + } + + public String getUpdatedBy() { + return updatedBy; + } + + public Instant getUpdatedAt() { + return updatedAt; + } + + public Instant getCreatedAt() { + return createdAt; + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/RuntimeBindingRepository.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/RuntimeBindingRepository.java new file mode 100644 index 000000000..6ff71050d --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/RuntimeBindingRepository.java @@ -0,0 +1,15 @@ +package com.iflytek.skillhub.domain.authoring; + +import java.util.Optional; + +/** + * Domain repository contract for per-draft runtime bindings. + */ +public interface RuntimeBindingRepository { + + RuntimeBinding save(RuntimeBinding binding); + + Optional findByDraftId(Long draftId); + + void deleteByDraftId(Long draftId); +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/SkillDraft.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/SkillDraft.java new file mode 100644 index 000000000..c8f3f8426 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/SkillDraft.java @@ -0,0 +1,194 @@ +package com.iflytek.skillhub.domain.authoring; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.PrePersist; +import jakarta.persistence.PreUpdate; +import jakarta.persistence.Table; +import jakarta.persistence.Version; +import java.time.Clock; +import java.time.Instant; + +/** + * A skill authoring draft owned by one user. Drafts hold editable SKILL.md and resource files, + * are content-addressed by {@code contentDigest}, and advance a monotonically increasing + * {@code revision} on every content change. A draft is only submittable when its current + * revision matches a revision with a passing validation run. + */ +@Entity +@Table(name = "skill_draft") +public class SkillDraft { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "namespace_id", nullable = false) + private Long namespaceId; + + @Column(name = "owner_id", nullable = false, length = 128) + private String ownerId; + + @Column(nullable = false, length = 128) + private String name; + + @Column(columnDefinition = "TEXT") + private String requirement; + + @Column(name = "revision", nullable = false) + private Integer revision = 1; + + @Column(name = "content_digest", nullable = false, length = 64) + private String contentDigest; + + @Column(name = "validated_revision") + private Integer validatedRevision; + + @Column(name = "validated_run_id") + private Long validatedRunId; + + @Column(name = "submitted_skill_id") + private Long submittedSkillId; + + @Column(name = "submitted_version_id") + private Long submittedVersionId; + + @Column(name = "submitted_at") + private Instant submittedAt; + + @Column(name = "created_at", nullable = false, updatable = false) + private Instant createdAt; + + @Column(name = "updated_at", nullable = false) + private Instant updatedAt; + + @Version + @Column(name = "version", nullable = false) + private Long optimisticVersion; + + protected SkillDraft() { + } + + public SkillDraft(Long namespaceId, String ownerId, String name, String requirement, + String contentDigest) { + this.namespaceId = namespaceId; + this.ownerId = ownerId; + this.name = name; + this.requirement = requirement; + this.contentDigest = contentDigest; + } + + @PrePersist + protected void onCreate() { + Instant now = Instant.now(Clock.systemUTC()); + createdAt = now; + updatedAt = now; + } + + @PreUpdate + protected void onUpdate() { + updatedAt = Instant.now(Clock.systemUTC()); + } + + /** + * Marks the draft content as changed: bumps the revision and attaches the new digest. + * Validation state is intentionally preserved — callers compare + * {@code validatedRevision} against {@code revision} to decide whether the draft + * is still considered validated. + */ + public void applyContentChange(int newRevision, String newContentDigest) { + this.revision = newRevision; + this.contentDigest = newContentDigest; + } + + public void markValidated(int validatedRevision, Long validatedRunId) { + this.validatedRevision = validatedRevision; + this.validatedRunId = validatedRunId; + } + + public void markSubmitted(Long skillId, Long versionId, Instant submittedAt) { + this.submittedSkillId = skillId; + this.submittedVersionId = versionId; + this.submittedAt = submittedAt; + } + + /** Returns true when the current revision has a passing validation run attached. */ + public boolean isCurrentRevisionValidated() { + return validatedRevision != null && validatedRevision.equals(revision); + } + + // Getters + + public Long getId() { + return id; + } + + public Long getNamespaceId() { + return namespaceId; + } + + public String getOwnerId() { + return ownerId; + } + + public String getName() { + return name; + } + + public String getRequirement() { + return requirement; + } + + public Integer getRevision() { + return revision; + } + + public String getContentDigest() { + return contentDigest; + } + + public Integer getValidatedRevision() { + return validatedRevision; + } + + public Long getValidatedRunId() { + return validatedRunId; + } + + public Long getSubmittedSkillId() { + return submittedSkillId; + } + + public Long getSubmittedVersionId() { + return submittedVersionId; + } + + public Instant getSubmittedAt() { + return submittedAt; + } + + public Instant getCreatedAt() { + return createdAt; + } + + public Instant getUpdatedAt() { + return updatedAt; + } + + public Long getOptimisticVersion() { + return optimisticVersion; + } + + // Setters + + public void setName(String name) { + this.name = name; + } + + public void setRequirement(String requirement) { + this.requirement = requirement; + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/SkillDraftRepository.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/SkillDraftRepository.java new file mode 100644 index 000000000..87611eb6b --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/SkillDraftRepository.java @@ -0,0 +1,25 @@ +package com.iflytek.skillhub.domain.authoring; + +import java.util.List; +import java.util.Optional; + +/** + * Domain repository contract for skill authoring drafts. + */ +public interface SkillDraftRepository { + + SkillDraft save(SkillDraft draft); + + Optional findById(Long id); + + List findByOwnerIdOrderByUpdatedAtDesc(String ownerId); + + List findByNamespaceId(Long namespaceId); + + /** Case-insensitive uniqueness of (owner, name) used at creation time. */ + Optional findByOwnerIdAndNameIgnoreCase(String ownerId, String name); + + void delete(SkillDraft draft); + + boolean existsByIdAndOwnerId(Long id, String ownerId); +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/package-info.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/package-info.java new file mode 100644 index 000000000..cc1135668 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/package-info.java @@ -0,0 +1,9 @@ +/** + * Skill authoring drafts: editable skill packages owned by one user, validated in a + * controlled runtime before being submitted to the publish pipeline. + * + *

Contains the draft, draft file, and runtime binding aggregates together with + * their repository contracts. Validation execution state lives in the + * {@code com.iflytek.skillhub.domain.authoring.validation} package. + */ +package com.iflytek.skillhub.domain.authoring; diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/runtime/AssertionEvaluator.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/runtime/AssertionEvaluator.java new file mode 100644 index 000000000..981ace3b9 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/runtime/AssertionEvaluator.java @@ -0,0 +1,221 @@ +package com.iflytek.skillhub.domain.authoring.runtime; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.iflytek.skillhub.domain.authoring.spec.AssertionSpec; +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Pattern; +import java.util.regex.PatternSyntaxException; + +/** + * Evaluates rule-based assertions against a task result. Deterministic checks only: + * exit codes, regex/substring matching, JSON pointer equality, artifact presence and + * hashes, and tool call counts. Semantic quality checks are intentionally not + * approximated here — they belong to model-assisted review, which stays outside the + * minimal closed loop. + */ +public class AssertionEvaluator { + + /** Assertion types valid for SCRIPT tasks. */ + public static final List SCRIPT_ASSERTION_TYPES = List.of( + "exit_code", "stdout_matches", "stdout_contains", "stdout_json", + "artifact_exists", "tool_call_count"); + + /** Assertion types valid for PROMPT tasks. */ + public static final List PROMPT_ASSERTION_TYPES = List.of( + "response_matches", "response_contains", "response_json", "tool_call_count"); + + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + /** + * Validates assertion types and required parameters without a result. Used by the + * configuration layer so malformed assertions fail fast, before any execution. + */ + public List validateSyntax(List assertions, boolean promptTask) { + List problems = new ArrayList<>(); + List allowed = promptTask ? PROMPT_ASSERTION_TYPES : SCRIPT_ASSERTION_TYPES; + for (AssertionSpec assertion : assertions) { + String type = assertion.type(); + if (type == null || type.isBlank()) { + problems.add("assertion without a type"); + continue; + } + if (!allowed.contains(type)) { + problems.add("assertion type '" + type + "' is not valid for " + + (promptTask ? "prompt" : "script") + " tasks (allowed: " + + String.join(", ", allowed) + ")"); + continue; + } + switch (type) { + case "exit_code" -> { + if (assertion.paramInt("equals") == null) { + problems.add("exit_code assertion requires an integer 'equals'"); + } + } + case "stdout_matches", "response_matches" -> { + String pattern = assertion.paramString("pattern"); + if (pattern == null || pattern.isBlank()) { + problems.add(type + " assertion requires a 'pattern'"); + } else if (!compiles(pattern)) { + problems.add(type + " assertion has an invalid regex: " + pattern); + } + } + case "stdout_contains", "response_contains" -> { + if (assertion.paramString("value") == null) { + problems.add(type + " assertion requires a 'value'"); + } + } + case "stdout_json", "response_json" -> { + if (assertion.paramString("pointer") == null) { + problems.add(type + " assertion requires a 'pointer'"); + } + if (assertion.paramString("equals") == null && assertion.paramInt("equals") == null) { + problems.add(type + " assertion requires an 'equals' value"); + } + } + case "artifact_exists" -> { + if (assertion.paramString("path") == null || assertion.paramString("path").isBlank()) { + problems.add("artifact_exists assertion requires a 'path'"); + } + } + case "tool_call_count" -> { + Integer min = assertion.paramInt("min"); + Integer max = assertion.paramInt("max"); + if (min == null && max == null) { + problems.add("tool_call_count assertion requires 'min' and/or 'max'"); + } + } + default -> problems.add("unknown assertion type: " + type); + } + } + return problems; + } + + /** Evaluates all assertions against the task result; empty list means all passed. */ + public List evaluate(List assertions, TaskResult result) { + List failures = new ArrayList<>(); + for (AssertionSpec assertion : assertions) { + String reason = evaluateOne(assertion, result); + if (reason != null) { + failures.add(new AssertionFailure(assertion, reason)); + } + } + return failures; + } + + private String evaluateOne(AssertionSpec assertion, TaskResult result) { + String type = assertion.type(); + return switch (type == null ? "" : type) { + case "exit_code" -> { + Integer expected = assertion.paramInt("equals"); + if (result.exitCode() == null) { + yield "task did not run to completion, no exit code"; + } + if (expected == null || result.exitCode() != expected) { + yield "expected exit code " + expected + " but got " + result.exitCode(); + } + yield null; + } + case "stdout_matches" -> matchPattern(assertion.paramString("pattern"), result.stdout(), "stdout"); + case "stdout_contains" -> containsValue(assertion.paramString("value"), result.stdout(), "stdout"); + case "response_matches" -> matchPattern(assertion.paramString("pattern"), result.response(), "response"); + case "response_contains" -> containsValue(assertion.paramString("value"), result.response(), "response"); + case "stdout_json" -> jsonPointer(assertion, result.stdout(), "stdout"); + case "response_json" -> jsonPointer(assertion, result.response(), "response"); + case "artifact_exists" -> artifactExists(assertion, result); + case "tool_call_count" -> toolCallCount(assertion, result); + default -> "unknown assertion type: " + type; + }; + } + + private String matchPattern(String patternText, String actual, String field) { + if (patternText == null) { + return "pattern is missing"; + } + if (actual == null) { + return field + " is empty"; + } + if (!Pattern.compile(patternText).matcher(actual).find()) { + return field + " does not match pattern: " + patternText; + } + return null; + } + + private String containsValue(String value, String actual, String field) { + if (value == null) { + return "value is missing"; + } + if (actual == null || !actual.contains(value)) { + return field + " does not contain: " + value; + } + return null; + } + + private String jsonPointer(AssertionSpec assertion, String actual, String field) { + String pointer = assertion.paramString("pointer"); + String expected = assertion.paramString("equals"); + if (pointer == null || expected == null) { + return "pointer or equals is missing"; + } + if (actual == null || actual.isBlank()) { + return field + " is empty"; + } + JsonNode root; + try { + root = OBJECT_MAPPER.readTree(actual); + } catch (Exception exception) { + return field + " is not valid JSON"; + } + JsonNode node = root.at(pointer); + if (node.isMissingNode()) { + return field + " has no value at pointer " + pointer; + } + String actualText = node.isTextual() ? node.asText() : node.toString(); + if (!expected.equals(actualText)) { + return "value at " + pointer + " is " + actualText + ", expected " + expected; + } + return null; + } + + private String artifactExists(AssertionSpec assertion, TaskResult result) { + String path = assertion.paramString("path"); + if (path == null) { + return "path is missing"; + } + byte[] content = result.artifacts().read(path).orElse(null); + if (content == null) { + return "artifact not found: " + path; + } + String expectedSha = assertion.paramString("sha256"); + if (expectedSha != null && !expectedSha.isBlank()) { + String actualSha = TaskResult.sha256Hex(content); + if (!expectedSha.toLowerCase().equals(actualSha)) { + return "artifact " + path + " sha256 is " + actualSha + ", expected " + expectedSha; + } + } + return null; + } + + private String toolCallCount(AssertionSpec assertion, TaskResult result) { + Integer min = assertion.paramInt("min"); + Integer max = assertion.paramInt("max"); + int actual = result.toolCallCount(); + if (min != null && actual < min) { + return "tool call count " + actual + " is below minimum " + min; + } + if (max != null && actual > max) { + return "tool call count " + actual + " exceeds maximum " + max; + } + return null; + } + + private boolean compiles(String pattern) { + try { + Pattern.compile(pattern); + return true; + } catch (PatternSyntaxException exception) { + return false; + } + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/runtime/AssertionFailure.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/runtime/AssertionFailure.java new file mode 100644 index 000000000..cf5bbd49f --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/runtime/AssertionFailure.java @@ -0,0 +1,13 @@ +package com.iflytek.skillhub.domain.authoring.runtime; + +import com.iflytek.skillhub.domain.authoring.spec.AssertionSpec; + +/** + * One assertion that did not hold, with a human-readable reason. + */ +public record AssertionFailure(AssertionSpec assertion, String reason) { + + public String describe() { + return "assertion '" + assertion.type() + "' failed: " + reason; + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/runtime/RuntimeEventSink.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/runtime/RuntimeEventSink.java new file mode 100644 index 000000000..3db676471 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/runtime/RuntimeEventSink.java @@ -0,0 +1,21 @@ +package com.iflytek.skillhub.domain.authoring.runtime; + +/** + * Callbacks a runtime adapter uses to stream execution progress into the persisted + * run event log. The orchestrator assigns sequence numbers and broadcasts to SSE + * subscribers; adapters only report what happened. + */ +public interface RuntimeEventSink { + + /** An agent-level message (model response, task narration). */ + void agentMessage(String taskName, String content); + + /** A tool invocation initiated by the runtime (script execution, HTTP tool call). */ + void toolCall(String taskName, String tool, String argumentsJson); + + /** The outcome of a tool invocation. */ + void toolResult(String taskName, String tool, String summaryJson); + + /** A raw log line from the runtime (stdout/stderr of a script, transport log). */ + void log(String taskName, String stream, String line); +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/runtime/RuntimeExecutionContext.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/runtime/RuntimeExecutionContext.java new file mode 100644 index 000000000..41eed79c5 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/runtime/RuntimeExecutionContext.java @@ -0,0 +1,50 @@ +package com.iflytek.skillhub.domain.authoring.runtime; + +import java.nio.file.Path; +import java.util.List; +import java.util.Map; + +/** + * Everything a runtime adapter needs to execute one validation run: the isolated + * working directory containing the draft files, the adapter-specific configuration, + * the tool allowlist, the declared MCP servers, and the cancellation signal. + * + * @param runId validation run identifier, for logging + * @param workingDirectory isolated directory holding the draft's files (read-write) + * @param adapterConfig adapter-specific configuration map from the runtime binding + * @param toolAllowlist allowed tool identifiers; empty means unrestricted + * @param mcpServers MCP server declarations from the runtime binding (may be empty) + * @param cancellation cooperative cancel signal + */ +public record RuntimeExecutionContext( + Long runId, + Path workingDirectory, + Map adapterConfig, + List toolAllowlist, + List> mcpServers, + TaskCancellation cancellation +) { + + /** Convenience constructor for runtimes that do not consume MCP servers. */ + public RuntimeExecutionContext(Long runId, Path workingDirectory, Map adapterConfig, + List toolAllowlist, TaskCancellation cancellation) { + this(runId, workingDirectory, adapterConfig, toolAllowlist, List.of(), cancellation); + } + + public Map safeAdapterConfig() { + return adapterConfig == null ? Map.of() : adapterConfig; + } + + public List safeToolAllowlist() { + return toolAllowlist == null ? List.of() : List.copyOf(toolAllowlist); + } + + public List> safeMcpServers() { + return mcpServers == null ? List.of() : List.copyOf(mcpServers); + } + + public String configString(String key) { + Object value = safeAdapterConfig().get(key); + return value == null ? null : value.toString(); + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/runtime/SkillRuntimeAdapter.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/runtime/SkillRuntimeAdapter.java new file mode 100644 index 000000000..82b20c446 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/runtime/SkillRuntimeAdapter.java @@ -0,0 +1,46 @@ +package com.iflytek.skillhub.domain.authoring.runtime; + +import com.iflytek.skillhub.domain.authoring.spec.TaskType; +import com.iflytek.skillhub.domain.authoring.spec.ValidationTaskSpec; + +/** + * SPI implemented by every agent runtime the authoring platform can bind to a draft. + * + *

Implementations live in the application layer (process execution, HTTP clients) + * and are selected by the runtime binding's agent type. The contract is deliberately + * minimal: execute one task, stream progress through the sink, return or throw. + * Assertion evaluation is handled by the orchestrator via {@link TaskResult}, not by + * adapters. + */ +public interface SkillRuntimeAdapter { + + /** The agent type identifier this adapter serves (see {@code AgentRuntimeType}). */ + String agentType(); + + /** Whether the adapter can execute the given task kind. */ + boolean supports(TaskType taskType); + + /** + * Whether the adapter is currently usable. Disabled adapters (missing server-side + * configuration, feature flags) are reported as RUNTIME_DISABLED findings instead of + * failed tasks when the orchestrator selects them. + */ + default boolean enabled() { + return true; + } + + /** + * Executes one validation task. Implementations must: + *

    + *
  • honor {@code context.cancellation()} while running;
  • + *
  • enforce the task timeout themselves;
  • + *
  • report progress through {@code sink};
  • + *
  • never modify draft state — the working directory is a disposable copy.
  • + *
+ * + * @return the raw task outcome for assertion evaluation + * @throws InterruptedException when cancelled mid-execution + */ + TaskResult execute(RuntimeExecutionContext context, ValidationTaskSpec task, + RuntimeEventSink sink) throws Exception; +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/runtime/TaskCancellation.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/runtime/TaskCancellation.java new file mode 100644 index 000000000..e4bef908e --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/runtime/TaskCancellation.java @@ -0,0 +1,12 @@ +package com.iflytek.skillhub.domain.authoring.runtime; + +/** + * Cooperative cancellation signal handed to runtime adapters. Adapters should poll + * {@link #isCancelRequested()} while executing (at least between output chunks) and + * abort as quickly as practical when it flips. + */ +@FunctionalInterface +public interface TaskCancellation { + + boolean isCancelRequested(); +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/runtime/TaskResult.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/runtime/TaskResult.java new file mode 100644 index 000000000..9fefcfd27 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/runtime/TaskResult.java @@ -0,0 +1,56 @@ +package com.iflytek.skillhub.domain.authoring.runtime; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HexFormat; +import java.util.Optional; + +/** + * The raw outcome of one behavior validation task, consumed by the assertion + * evaluator. Script tasks populate exit code and streams; prompt tasks populate + * the agent response. The artifact resolver reads files produced inside the + * working directory (used by artifact_exists assertions). + */ +public record TaskResult( + Integer exitCode, + String stdout, + String stderr, + String response, + int toolCallCount, + ArtifactResolver artifacts +) { + + /** Reads a file produced by the task, relative to the working directory. */ + public interface ArtifactResolver { + Optional read(String relativePath); + } + + public static TaskResult ofScript(int exitCode, String stdout, String stderr, + int toolCallCount, ArtifactResolver artifacts) { + return new TaskResult(exitCode, stdout, stderr, null, toolCallCount, artifacts); + } + + public static TaskResult ofPrompt(String response, int toolCallCount, ArtifactResolver artifacts) { + return new TaskResult(null, null, null, response, toolCallCount, artifacts); + } + + public String sha256OfArtifact(String relativePath) { + return artifacts.read(relativePath) + .map(TaskResult::sha256Hex) + .orElse(null); + } + + public static String sha256Hex(byte[] content) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + return HexFormat.of().formatHex(digest.digest(content)); + } catch (NoSuchAlgorithmException exception) { + throw new IllegalStateException("SHA-256 unavailable", exception); + } + } + + public static String sha256Hex(String content) { + return sha256Hex(content.getBytes(StandardCharsets.UTF_8)); + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/runtime/package-info.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/runtime/package-info.java new file mode 100644 index 000000000..c99d70d0b --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/runtime/package-info.java @@ -0,0 +1,5 @@ +/** + * Runtime adapter SPI and behavior-validation support types shared by the domain and + * application layers. + */ +package com.iflytek.skillhub.domain.authoring.runtime; diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/service/DraftStructureValidator.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/service/DraftStructureValidator.java new file mode 100644 index 000000000..72b75a0f3 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/service/DraftStructureValidator.java @@ -0,0 +1,206 @@ +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.runtime.TaskResult; +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.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import org.springframework.stereotype.Service; + +/** + * The STRUCTURE validation layer: package layout, SKILL.md frontmatter, file limits, + * and content/extension consistency. Mirrors the checks of the publish-time + * {@code SkillPackageValidator} rule by rule, but reports structured findings with + * machine-applicable fix suggestions instead of opaque error strings. + * + *

Package limits and path rules come from the shared {@link SkillPackagePolicy}, + * so draft-time and publish-time rules cannot drift. + */ +@Service +public class DraftStructureValidator { + + private final SkillMetadataParser metadataParser; + private final ComplianceMetadataService complianceMetadataService; + private final SkillScaffoldGenerator scaffoldGenerator; + + public DraftStructureValidator(SkillMetadataParser metadataParser, + SkillScaffoldGenerator scaffoldGenerator) { + this.metadataParser = metadataParser; + this.complianceMetadataService = new ComplianceMetadataService(); + this.scaffoldGenerator = scaffoldGenerator; + } + + public record StructureReport(List findings, boolean skillMdPresent) { + + public boolean hasErrors() { + return findings.stream().anyMatch(finding -> finding.severity() + == com.iflytek.skillhub.domain.authoring.validation.FindingSeverity.ERROR); + } + } + + /** + * @param draftName draft name, used to build fix suggestions (e.g. missing SKILL.md) + * @param requirement original requirement text, reused in scaffold suggestions + */ + public StructureReport validate(String draftName, String requirement, List entries) { + List findings = new ArrayList<>(); + Set normalizedPaths = new HashSet<>(); + PackageEntry skillMd = null; + long totalSize = 0; + + for (PackageEntry entry : entries) { + String normalizedPath; + try { + normalizedPath = SkillPackagePolicy.normalizeEntryPath(entry.path()); + } catch (IllegalArgumentException exception) { + findings.add(FindingDraft.error(ValidationLayer.STRUCTURE, "PATH_INVALID", + entry.path(), exception.getMessage(), null)); + continue; + } + normalizedPath = SkillPackagePolicy.canonicalizeSkillMdPath(normalizedPath); + + if (!normalizedPaths.add(normalizedPath)) { + findings.add(FindingDraft.error(ValidationLayer.STRUCTURE, "PATH_DUPLICATE", + normalizedPath, "Duplicate file path: " + normalizedPath, null)); + } + if (!SkillPackagePolicy.hasAllowedExtension(normalizedPath)) { + findings.add(FindingDraft.warning(ValidationLayer.STRUCTURE, "EXTENSION_DISALLOWED", + normalizedPath, "Disallowed file extension: " + normalizedPath)); + } + String contentMismatch = SkillPackagePolicy.validateContentMatchesExtension( + normalizedPath, entry.content()); + if (contentMismatch != null) { + findings.add(FindingDraft.error(ValidationLayer.STRUCTURE, "CONTENT_MISMATCH", + normalizedPath, contentMismatch, null)); + } + if (entry.size() > SkillPackagePolicy.MAX_SINGLE_FILE_SIZE) { + findings.add(FindingDraft.error(ValidationLayer.STRUCTURE, "FILE_TOO_LARGE", + normalizedPath, "File too large: " + normalizedPath + " (" + + entry.size() + " bytes, max " + SkillPackagePolicy.MAX_SINGLE_FILE_SIZE + ")", + null)); + } + if (SkillPackagePolicy.SKILL_MD_PATH.equals(normalizedPath) && skillMd == null) { + skillMd = entry; + } + totalSize += entry.size(); + } + + if (entries.size() > SkillPackagePolicy.MAX_FILE_COUNT) { + findings.add(FindingDraft.error(ValidationLayer.STRUCTURE, "FILE_COUNT_EXCEEDED", null, + "Too many files: " + entries.size() + " (max " + SkillPackagePolicy.MAX_FILE_COUNT + ")", + null)); + } + if (totalSize > SkillPackagePolicy.MAX_TOTAL_PACKAGE_SIZE) { + findings.add(FindingDraft.error(ValidationLayer.STRUCTURE, "PACKAGE_TOO_LARGE", null, + "Package too large: " + totalSize + " bytes (max " + + SkillPackagePolicy.MAX_TOTAL_PACKAGE_SIZE + ")", null)); + } + + if (skillMd == null) { + String scaffold = scaffoldGenerator.generateSkillMd(draftName, requirement); + findings.add(FindingDraft.error(ValidationLayer.STRUCTURE, "SKILL_MD_MISSING", null, + "Missing required file: SKILL.md at root", + new FixSuggestion("Create an initial SKILL.md scaffold", + List.of(new FilePatch(SkillPackagePolicy.SKILL_MD_PATH, null, null, scaffold))))); + return new StructureReport(findings, false); + } + + String skillMdContent = new String(skillMd.content(), StandardCharsets.UTF_8); + try { + var metadata = metadataParser.parse(skillMdContent); + findings.addAll(complianceFindings(metadata.frontmatter(), entries)); + } catch (LocalizedDomainException exception) { + findings.add(frontmatterFinding(exception, skillMdContent, draftName)); + } + + return new StructureReport(findings, true); + } + + private List complianceFindings(Map frontmatter, + List entries) { + List findings = new ArrayList<>(); + for (String problem : complianceMetadataService.validate(frontmatter, entries)) { + findings.add(FindingDraft.error(ValidationLayer.STRUCTURE, "COMPLIANCE_INVALID", + SkillPackagePolicy.SKILL_MD_PATH, problem, null)); + } + return findings; + } + + private FindingDraft frontmatterFinding(LocalizedDomainException exception, + String skillMdContent, String draftName) { + return switch (exception.messageCode()) { + case "error.skill.metadata.requiredField.missing" -> { + String field = String.valueOf(exception.messageArgs()[0]); + String replacement = insertFrontmatterField(skillMdContent, field, + suggestionValue(field, draftName)); + yield FindingDraft.error(ValidationLayer.STRUCTURE, "FRONTMATTER_FIELD_MISSING", + SkillPackagePolicy.SKILL_MD_PATH, + "SKILL.md frontmatter is missing required field: " + field, + new FixSuggestion("Add required field '" + field + "'", + List.of(new FilePatch(SkillPackagePolicy.SKILL_MD_PATH, + TaskResult.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(); + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/service/DraftSubmitService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/service/DraftSubmitService.java new file mode 100644 index 000000000..7d74b8f73 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/service/DraftSubmitService.java @@ -0,0 +1,94 @@ +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.ValidationRun; +import com.iflytek.skillhub.domain.authoring.validation.ValidationRunStatus; +import com.iflytek.skillhub.domain.shared.exception.DomainConflictException; +import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException; +import com.iflytek.skillhub.domain.skill.SkillVisibility; +import com.iflytek.skillhub.domain.skill.service.SkillPublishService; +import com.iflytek.skillhub.domain.skill.validation.PackageEntry; +import java.time.Clock; +import java.util.Set; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * Submits a validated draft into the existing SkillHub publish pipeline. The gate is + * strict: the draft's current revision must be bound to a validation run that + * succeeded with zero errors, so content can never be published without the exact + * bytes that were validated. Publishing reuses {@link SkillPublishService}, which + * re-validates the package, triggers the security scan, and opens the review flow. + */ +@Service +public class DraftSubmitService { + + private static final Logger log = LoggerFactory.getLogger(DraftSubmitService.class); + private static final String SUPER_ADMIN = "SUPER_ADMIN"; + + public record SubmitOutcome(Long skillId, Long versionId, String slug, String version, + SkillPublishService.PublishResult publishResult) {} + + private final SkillDraftRepository draftRepository; + private final SkillDraftService draftService; + private final ValidationRunService runService; + private final SkillPublishService publishService; + private final Clock clock; + + public DraftSubmitService(SkillDraftRepository draftRepository, + SkillDraftService draftService, + ValidationRunService runService, + SkillPublishService publishService, + Clock clock) { + this.draftRepository = draftRepository; + this.draftService = draftService; + this.runService = runService; + this.publishService = publishService; + this.clock = clock; + } + + /** + * Publishes the draft's current revision as a new skill version. Warnings that the + * validation run already surfaced to the author are confirmed here; errors were a + * hard gate and cannot reach this point. + */ + @Transactional + public SubmitOutcome submit(Long draftId, String userId, SkillVisibility visibility, + Set platformRoles) { + SkillDraft draft = draftRepository.findById(draftId) + .orElseThrow(() -> new DomainNotFoundException("error.authoring.draft.notFound", draftId)); + assertOwner(draft, userId, platformRoles); + + if (runService.listRuns(draftId).stream().anyMatch(ValidationRun::isActive)) { + throw new DomainConflictException("error.authoring.submit.runActive", draftId); + } + ValidationRun passingRun = runService.requirePassingRunForRevision( + draftId, draft.getRevision()); + + String namespaceSlug = draftService.resolveNamespaceSlug(draft); + java.util.List entries = draftService.materializeEntries(draftId); + + SkillPublishService.PublishResult result = publishService.publishFromEntries( + namespaceSlug, entries, userId, visibility, platformRoles, true); + + draft.markSubmitted(result.skillId(), result.version().getId(), clock.instant()); + draftRepository.save(draft); + log.info("Draft {} revision {} submitted as skill {} version {} (validation run {})", + draftId, draft.getRevision(), result.skillId(), + result.version().getVersion(), passingRun.getId()); + return new SubmitOutcome(result.skillId(), result.version().getId(), result.slug(), + result.version().getVersion(), result); + } + + private void assertOwner(SkillDraft draft, String userId, Set platformRoles) { + if (draft.getOwnerId().equals(userId) + || (platformRoles != null && platformRoles.contains(SUPER_ADMIN))) { + return; + } + throw new com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException( + "error.authoring.draft.forbidden", draft.getId()); + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/service/FindingFixService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/service/FindingFixService.java new file mode 100644 index 000000000..8bc9a2c57 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/service/FindingFixService.java @@ -0,0 +1,94 @@ +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.FindingStatus; +import com.iflytek.skillhub.domain.authoring.validation.ValidationFinding; +import com.iflytek.skillhub.domain.authoring.validation.ValidationFindingRepository; +import com.iflytek.skillhub.domain.authoring.validation.ValidationRun; +import com.iflytek.skillhub.domain.authoring.validation.ValidationRunRepository; +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 java.util.Set; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * Closes the fix loop for validation findings: an author previews a finding's + * machine-applicable suggestion, then confirms it. Applying the patch produces a new + * draft revision through {@link SkillDraftService#applyPatch} (which re-validates all + * sha256 guards), marks the finding APPLIED, and thereby invalidates the previous + * validation verdict — re-validation is always required afterwards. + */ +@Service +public class FindingFixService { + + private static final String SUPER_ADMIN = "SUPER_ADMIN"; + + private final ValidationFindingRepository findingRepository; + private final ValidationRunRepository runRepository; + private final SkillDraftRepository draftRepository; + private final SkillDraftService draftService; + + public FindingFixService(ValidationFindingRepository findingRepository, + ValidationRunRepository runRepository, + SkillDraftRepository draftRepository, + SkillDraftService draftService) { + this.findingRepository = findingRepository; + this.runRepository = runRepository; + this.draftRepository = draftRepository; + this.draftService = draftService; + } + + public record FixOutcome(ValidationFinding finding, SkillDraft draft) {} + + /** + * Applies the finding's fix suggestion to the draft. The whole operation is atomic: + * either every patch lands and the finding is marked APPLIED, or nothing changes. + */ + @Transactional + public FixOutcome applyFix(Long findingId, String userId, Set platformRoles) { + OwnedFinding owned = loadOwnedFinding(findingId, userId, platformRoles); + ValidationFinding finding = owned.finding(); + if (!finding.hasSuggestion()) { + throw new DomainBadRequestException("error.authoring.finding.noSuggestion", findingId); + } + if (finding.getStatus() == FindingStatus.APPLIED) { + throw new DomainConflictException("error.authoring.finding.applied", findingId); + } + + SkillDraft draft = draftService.applyPatch( + owned.draftId(), userId, finding.getSuggestion().safePatches(), null, platformRoles); + finding.markApplied(draft.getRevision()); + findingRepository.save(finding); + return new FixOutcome(finding, draft); + } + + /** Dismisses a finding the author considers not applicable. */ + @Transactional + public ValidationFinding dismissFinding(Long findingId, String userId, Set platformRoles) { + ValidationFinding finding = loadOwnedFinding(findingId, userId, platformRoles).finding(); + finding.markDismissed(); + return findingRepository.save(finding); + } + + private record OwnedFinding(ValidationFinding finding, Long draftId) {} + + private OwnedFinding loadOwnedFinding(Long findingId, String userId, Set platformRoles) { + ValidationFinding finding = findingRepository.findById(findingId) + .orElseThrow(() -> new DomainNotFoundException("error.authoring.finding.notFound", findingId)); + ValidationRun run = runRepository.findById(finding.getRunId()) + .orElseThrow(() -> new DomainNotFoundException( + "error.authoring.run.notFound", finding.getRunId())); + SkillDraft draft = draftRepository.findById(run.getDraftId()) + .orElseThrow(() -> new DomainNotFoundException( + "error.authoring.draft.notFound", run.getDraftId())); + if (draft.getOwnerId().equals(userId) + || (platformRoles != null && platformRoles.contains(SUPER_ADMIN))) { + return new OwnedFinding(finding, draft.getId()); + } + throw new DomainForbiddenException("error.authoring.draft.forbidden", draft.getId()); + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/service/RuntimeBindingService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/service/RuntimeBindingService.java new file mode 100644 index 000000000..90153578f --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/service/RuntimeBindingService.java @@ -0,0 +1,101 @@ +package com.iflytek.skillhub.domain.authoring.service; + +import com.iflytek.skillhub.domain.authoring.AgentRuntimeType; +import com.iflytek.skillhub.domain.authoring.RuntimeBinding; +import com.iflytek.skillhub.domain.authoring.RuntimeBindingRepository; +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.FindingSeverity; +import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; +import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; +import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException; +import java.util.List; +import java.util.Map; +import java.util.Set; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * Domain service for a draft's runtime binding: persists the agent type, adapter + * configuration, tool allowlist, and MCP server declarations. Validation findings are + * the responsibility of {@link RuntimeBindingValidator}; this service only rejects + * structurally invalid input at save time. + */ +@Service +public class RuntimeBindingService { + + private static final String SUPER_ADMIN = "SUPER_ADMIN"; + + private final RuntimeBindingRepository bindingRepository; + private final SkillDraftRepository draftRepository; + private final RuntimeBindingValidator bindingValidator; + + public RuntimeBindingService(RuntimeBindingRepository bindingRepository, + SkillDraftRepository draftRepository, + RuntimeBindingValidator bindingValidator) { + this.bindingRepository = bindingRepository; + this.draftRepository = draftRepository; + this.bindingValidator = bindingValidator; + } + + /** + * Saves the runtime binding. Structurally invalid input (unknown agent type, + * embedded credentials) is rejected immediately; the full validation report is + * produced again as findings whenever a run starts. + */ + @Transactional + public RuntimeBinding saveBinding(Long draftId, String userId, String agentType, + Map adapterConfig, + List toolAllowlist, + List> mcpServers, + Set platformRoles) { + SkillDraft draft = draftRepository.findById(draftId) + .orElseThrow(() -> new DomainNotFoundException("error.authoring.draft.notFound", draftId)); + assertOwner(draft, userId, platformRoles); + + AgentRuntimeType type; + try { + type = AgentRuntimeType.fromIdentifier(agentType == null ? "" : agentType); + } catch (IllegalArgumentException exception) { + throw new DomainBadRequestException("error.authoring.binding.agentType.unknown", agentType); + } + + List findings = bindingValidator.validate( + type.identifier(), adapterConfig, toolAllowlist, mcpServers); + boolean hasErrors = findings.stream() + .anyMatch(finding -> finding.severity() == FindingSeverity.ERROR); + if (hasErrors) { + String first = findings.stream() + .filter(finding -> finding.severity() == FindingSeverity.ERROR) + .findFirst().map(FindingDraft::message).orElse("invalid binding"); + throw new DomainBadRequestException("error.authoring.binding.invalid", first); + } + + RuntimeBinding binding = bindingRepository.findByDraftId(draftId) + .orElseGet(() -> new RuntimeBinding(draftId, type)); + binding.update(type, adapterConfig, toolAllowlist, mcpServers, userId); + return bindingRepository.save(binding); + } + + @Transactional(readOnly = true) + public RuntimeBinding getBinding(Long draftId) { + return bindingRepository.findByDraftId(draftId) + .orElseThrow(() -> new DomainNotFoundException( + "error.authoring.binding.notFound", draftId)); + } + + /** Optional accessor for callers that treat "not configured yet" as normal state. */ + @Transactional(readOnly = true) + public java.util.Optional findBinding(Long draftId) { + return bindingRepository.findByDraftId(draftId); + } + + private void assertOwner(SkillDraft draft, String userId, Set platformRoles) { + if (draft.getOwnerId().equals(userId) + || (platformRoles != null && platformRoles.contains(SUPER_ADMIN))) { + return; + } + throw new DomainForbiddenException("error.authoring.draft.forbidden", draft.getId()); + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/service/RuntimeBindingValidator.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/service/RuntimeBindingValidator.java new file mode 100644 index 000000000..9a9345105 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/service/RuntimeBindingValidator.java @@ -0,0 +1,203 @@ +package com.iflytek.skillhub.domain.authoring.service; + +import com.iflytek.skillhub.domain.authoring.validation.FindingDraft; +import com.iflytek.skillhub.domain.authoring.validation.ValidationLayer; +import java.net.URI; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.regex.Pattern; +import org.springframework.stereotype.Service; + +/** + * The CONFIG validation layer: checks the runtime binding (agent type, adapter + * configuration, tool allowlist, MCP server declarations) plus the syntactic validity + * of validation task assertions. Rejects embedded plaintext credentials — bindings may + * only reference server-side credential names. + */ +@Service +public class RuntimeBindingValidator { + + private static final Pattern TOOL_NAME = Pattern.compile("^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$"); + private static final Pattern MCP_NAME = Pattern.compile("^[a-z0-9][a-z0-9-]{0,63}$"); + private static final Pattern CREDENTIAL_KEY = Pattern.compile( + "(?i).*(secret|password|passwd|api[_-]?key|token|credential).*"); + private static final Pattern ENV_REF_NAME = Pattern.compile("^[A-Z][A-Z0-9_]{0,63}$"); + private static final List TRANSPORTS = List.of("http", "sse", "stdio"); + private static final List INTERPRETERS = List.of("sh", "bash", "python3", "node"); + + /** + * @param agentType agent type identifier (may be invalid — reported as finding) + * @param adapterConfig adapter-specific configuration map + * @param toolAllowlist tool identifiers (may be null = unrestricted) + * @param mcpServers MCP server declarations (may be null) + */ + public List validate(String agentType, Map adapterConfig, + List toolAllowlist, List> mcpServers) { + List findings = new ArrayList<>(); + String resolvedAgentType = agentType == null ? "" : agentType; + + switch (resolvedAgentType) { + case "local-script" -> validateLocalScriptConfig(adapterConfig, findings); + case "openai-compatible" -> validateOpenAiCompatibleConfig(adapterConfig, findings); + default -> findings.add(FindingDraft.error(ValidationLayer.CONFIG, "AGENT_TYPE_UNKNOWN", + "Unknown agent runtime type: " + resolvedAgentType + + " (supported: local-script, openai-compatible)")); + } + + if (toolAllowlist != null) { + for (String tool : toolAllowlist) { + if (tool == null || !TOOL_NAME.matcher(tool).matches()) { + findings.add(FindingDraft.error(ValidationLayer.CONFIG, "TOOL_ALLOWLIST_INVALID", + "Invalid tool allowlist entry: " + tool)); + } + } + } + + if (mcpServers != null) { + for (Map server : mcpServers) { + validateMcpServer(server, findings); + } + } + + scanForEmbeddedSecrets(agentType, adapterConfig, toolAllowlist, mcpServers, findings); + return findings; + } + + private void validateLocalScriptConfig(Map config, List findings) { + if (config == null) { + return; + } + Object interpreter = config.get("interpreter"); + if (interpreter != null && !INTERPRETERS.contains(interpreter.toString())) { + findings.add(FindingDraft.error(ValidationLayer.CONFIG, "INTERPRETER_UNSUPPORTED", + "Unsupported interpreter: " + interpreter + " (supported: " + + String.join(", ", INTERPRETERS) + ")")); + } + Object envAllowlist = config.get("envAllowlist"); + if (envAllowlist instanceof List entries) { + for (Object entry : entries) { + if (entry == null || !ENV_REF_NAME.matcher(entry.toString()).matches()) { + findings.add(FindingDraft.error(ValidationLayer.CONFIG, "ENV_ALLOWLIST_INVALID", + "Invalid environment variable name: " + entry)); + } + } + } else if (envAllowlist != null) { + findings.add(FindingDraft.error(ValidationLayer.CONFIG, "ENV_ALLOWLIST_INVALID", + "envAllowlist must be a list of variable names")); + } + } + + private void validateOpenAiCompatibleConfig(Map config, List findings) { + if (config == null) { + findings.add(FindingDraft.error(ValidationLayer.CONFIG, "LLM_CONFIG_MISSING", + "openai-compatible runtime requires a config object with endpoint and model")); + return; + } + Object endpoint = config.get("endpoint"); + if (endpoint == null || endpoint.toString().isBlank()) { + findings.add(FindingDraft.error(ValidationLayer.CONFIG, "LLM_ENDPOINT_MISSING", + "openai-compatible runtime requires an 'endpoint' URL")); + } else if (!isHttpUrl(endpoint.toString())) { + findings.add(FindingDraft.error(ValidationLayer.CONFIG, "LLM_ENDPOINT_INVALID", + "endpoint must be an http(s) URL: " + endpoint)); + } + Object model = config.get("model"); + if (model == null || model.toString().isBlank()) { + findings.add(FindingDraft.error(ValidationLayer.CONFIG, "LLM_MODEL_MISSING", + "openai-compatible runtime requires a 'model' name")); + } + Object temperature = config.get("temperature"); + if (temperature instanceof Number number + && (number.doubleValue() < 0 || number.doubleValue() > 2)) { + findings.add(FindingDraft.error(ValidationLayer.CONFIG, "LLM_TEMPERATURE_INVALID", + "temperature must be between 0 and 2")); + } + // the API key is resolved from server configuration, never from the binding + if (config.containsKey("apiKey") || config.containsKey("api_key")) { + findings.add(FindingDraft.error(ValidationLayer.CONFIG, "CREDENTIAL_EMBEDDED", + "apiKey must not be stored in the binding; configure it server-side")); + } + } + + private void validateMcpServer(Map server, List findings) { + Object name = server.get("name"); + if (name == null || !MCP_NAME.matcher(name.toString()).matches()) { + findings.add(FindingDraft.error(ValidationLayer.CONFIG, "MCP_NAME_INVALID", + "MCP server name must be lowercase alphanumerics/hyphens: " + name)); + } + Object transport = server.get("transport"); + if (transport == null || !TRANSPORTS.contains(transport.toString())) { + findings.add(FindingDraft.error(ValidationLayer.CONFIG, "MCP_TRANSPORT_INVALID", + "MCP server " + name + " has unknown transport: " + transport + + " (supported: " + String.join(", ", TRANSPORTS) + ")")); + } else if ("http".equals(transport.toString()) || "sse".equals(transport.toString())) { + Object endpoint = server.get("endpoint"); + if (endpoint == null || !isHttpUrl(endpoint.toString())) { + findings.add(FindingDraft.error(ValidationLayer.CONFIG, "MCP_ENDPOINT_INVALID", + "MCP server " + name + " requires an http(s) endpoint")); + } + } else { + Object command = server.get("command"); + if (command == null || command.toString().isBlank()) { + findings.add(FindingDraft.error(ValidationLayer.CONFIG, "MCP_COMMAND_MISSING", + "MCP server " + name + " (stdio) requires a command")); + } + } + Object toolFilters = server.get("toolFilters"); + if (toolFilters instanceof List filters) { + for (Object filter : filters) { + if (filter == null || !TOOL_NAME.matcher(filter.toString()).matches()) { + findings.add(FindingDraft.error(ValidationLayer.CONFIG, "MCP_TOOL_FILTER_INVALID", + "MCP server " + name + " has an invalid tool filter: " + filter)); + } + } + } + Object envRefs = server.get("envRefs"); + if (envRefs instanceof List refs) { + for (Object ref : refs) { + if (ref == null || !ENV_REF_NAME.matcher(ref.toString()).matches()) { + findings.add(FindingDraft.error(ValidationLayer.CONFIG, "MCP_ENV_REF_INVALID", + "MCP server " + name + " has an invalid env reference: " + ref)); + } + } + } + } + + /** Rejects secret-looking keys anywhere in the binding payload. */ + private void scanForEmbeddedSecrets(String agentType, Map adapterConfig, + List toolAllowlist, + List> mcpServers, + List findings) { + if (adapterConfig != null) { + scanMap(adapterConfig, findings); + } + if (mcpServers != null) { + for (Map server : mcpServers) { + scanMap(server, findings); + } + } + } + + private void scanMap(Map map, List findings) { + for (Map.Entry entry : map.entrySet()) { + if (CREDENTIAL_KEY.matcher(entry.getKey()).matches() + && entry.getValue() != null && !entry.getValue().toString().isBlank()) { + findings.add(FindingDraft.error(ValidationLayer.CONFIG, "CREDENTIAL_EMBEDDED", + "Binding must not embed credentials in field '" + entry.getKey() + + "'; reference server-side credentials instead")); + } + } + } + + private boolean isHttpUrl(String value) { + try { + URI uri = URI.create(value); + String scheme = uri.getScheme(); + return ("http".equalsIgnoreCase(scheme) || "https".equalsIgnoreCase(scheme)) + && uri.getHost() != null; + } catch (IllegalArgumentException exception) { + return false; + } + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/service/SkillDraftService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/service/SkillDraftService.java new file mode 100644 index 000000000..174748591 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/service/SkillDraftService.java @@ -0,0 +1,463 @@ +package com.iflytek.skillhub.domain.authoring.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.iflytek.skillhub.domain.authoring.DraftFile; +import com.iflytek.skillhub.domain.authoring.FilePatch; +import com.iflytek.skillhub.domain.authoring.SkillDraft; +import com.iflytek.skillhub.domain.authoring.SkillDraftRepository; +import com.iflytek.skillhub.domain.authoring.DraftFileRepository; +import com.iflytek.skillhub.domain.namespace.Namespace; +import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; +import com.iflytek.skillhub.domain.namespace.NamespaceRepository; +import com.iflytek.skillhub.domain.namespace.NamespaceStatus; +import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; +import com.iflytek.skillhub.domain.shared.exception.DomainConflictException; +import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; +import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException; +import com.iflytek.skillhub.domain.skill.validation.PackageEntry; +import com.iflytek.skillhub.domain.skill.validation.SkillPackagePolicy; +import com.iflytek.skillhub.storage.ObjectStorageService; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HexFormat; +import java.util.List; +import java.util.Locale; +import java.util.Optional; +import java.util.Set; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * Domain service for skill authoring drafts: creation with a scaffold, content-addressed + * file storage, revision and digest bookkeeping, patch application, and materialization + * into package entries for validation and submission. + * + *

Revisions advance only when content actually changes; a no-op save keeps the + * revision (and therefore a prior validation verdict) intact. Every content change + * recomputes the digest, which binds validation runs to exact draft content. + */ +@Service +public class SkillDraftService { + + private static final Logger log = LoggerFactory.getLogger(SkillDraftService.class); + private static final int MAX_NAME_LENGTH = 128; + private static final int MAX_REQUIREMENT_LENGTH = 8_000; + private static final String SUPER_ADMIN = "SUPER_ADMIN"; + + public record SaveFileOutcome(SkillDraft draft, DraftFile file, boolean created, boolean revisionAdvanced) {} + + private final SkillDraftRepository draftRepository; + private final DraftFileRepository fileRepository; + private final NamespaceRepository namespaceRepository; + private final NamespaceMemberRepository namespaceMemberRepository; + private final ObjectStorageService objectStorageService; + private final SkillScaffoldGenerator scaffoldGenerator; + private final ObjectMapper objectMapper; + + public SkillDraftService(SkillDraftRepository draftRepository, + DraftFileRepository fileRepository, + NamespaceRepository namespaceRepository, + NamespaceMemberRepository namespaceMemberRepository, + ObjectStorageService objectStorageService, + SkillScaffoldGenerator scaffoldGenerator, + ObjectMapper objectMapper) { + this.draftRepository = draftRepository; + this.fileRepository = fileRepository; + this.namespaceRepository = namespaceRepository; + this.namespaceMemberRepository = namespaceMemberRepository; + this.objectStorageService = objectStorageService; + this.scaffoldGenerator = scaffoldGenerator; + this.objectMapper = objectMapper; + } + + // ---------------------------------------------------------------- draft lifecycle + + /** + * Creates a draft in the target namespace and seeds it with a SKILL.md scaffold + * derived from the requirement description. + */ + @Transactional + public SkillDraft createDraft(String namespaceSlug, String ownerId, String name, + String requirement, Set platformRoles) { + validateName(name); + if (requirement != null && requirement.length() > MAX_REQUIREMENT_LENGTH) { + throw new DomainBadRequestException("error.authoring.draft.requirement.tooLong", + MAX_REQUIREMENT_LENGTH); + } + + Namespace namespace = namespaceRepository.findBySlug(namespaceSlug) + .orElseThrow(() -> new DomainNotFoundException("error.authoring.namespace.notFound", namespaceSlug)); + assertNamespaceWritable(namespace); + assertNamespaceMember(namespace, ownerId, platformRoles); + + if (draftRepository.findByOwnerIdAndNameIgnoreCase(ownerId, name).isPresent()) { + throw new DomainConflictException("error.authoring.draft.name.duplicate", name); + } + + String scaffold = scaffoldGenerator.generateSkillMd(name, requirement); + SkillDraft draft = draftRepository.save( + new SkillDraft(namespace.getId(), ownerId, name, requirement, "pending")); + DraftFile scaffoldFile = storeFile(draft.getId(), SkillPackagePolicy.SKILL_MD_PATH, + scaffold.getBytes(StandardCharsets.UTF_8), "text/markdown"); + fileRepository.save(scaffoldFile); + // the scaffold must be readable immediately: editor, structure validation, + // and submit all read it back from object storage + putContent(scaffoldFile.getStorageKey(), scaffold.getBytes(StandardCharsets.UTF_8), + "text/markdown"); + draft.applyContentChange(1, computeDigest(draft.getId())); + return draftRepository.save(draft); + } + + @Transactional(readOnly = true) + public List listDrafts(String ownerId) { + return draftRepository.findByOwnerIdOrderByUpdatedAtDesc(ownerId); + } + + @Transactional(readOnly = true) + public SkillDraft getOwnedDraft(Long draftId, String userId, Set platformRoles) { + SkillDraft draft = draftRepository.findById(draftId) + .orElseThrow(() -> new DomainNotFoundException("error.authoring.draft.notFound", draftId)); + assertOwner(draft, userId, platformRoles); + return draft; + } + + /** Trusted accessor for internal executors (validation pipeline, submit flow). */ + @Transactional(readOnly = true) + public SkillDraft getDraft(Long draftId) { + return draftRepository.findById(draftId) + .orElseThrow(() -> new DomainNotFoundException("error.authoring.draft.notFound", draftId)); + } + + @Transactional + public void deleteDraft(Long draftId, String userId, Set platformRoles) { + SkillDraft draft = getOwnedDraft(draftId, userId, platformRoles); + List storageKeys = fileRepository.findByDraftIdOrderByFilePath(draftId).stream() + .map(DraftFile::getStorageKey) + .toList(); + if (!storageKeys.isEmpty()) { + try { + objectStorageService.deleteObjects(storageKeys); + } catch (Exception exception) { + // storage garbage is not worth blocking a draft deletion; keys are + // namespaced under drafts/{draftId} and become unreachable + log.warn("Failed to clean up draft storage for draft {}: {}", + draftId, exception.getMessage()); + } + } + draftRepository.delete(draft); + } + + // ---------------------------------------------------------------- file editing + + /** + * Saves one file. When {@code expectedRevision} is provided it must match the current + * revision, otherwise the save is rejected with a conflict (optimistic concurrency). + */ + @Transactional + public SaveFileOutcome saveFile(Long draftId, String userId, String rawPath, byte[] content, + String contentType, Integer expectedRevision, + Set platformRoles) { + SkillDraft draft = getOwnedDraft(draftId, userId, platformRoles); + assertRevisionMatches(draft, expectedRevision); + String path = normalizeAndCheckPath(rawPath); + + if (content == null) { + throw new DomainBadRequestException("error.authoring.file.content.required"); + } + if (content.length > SkillPackagePolicy.MAX_SINGLE_FILE_SIZE) { + throw new DomainBadRequestException("error.authoring.file.tooLarge", path, + SkillPackagePolicy.MAX_SINGLE_FILE_SIZE); + } + String mismatch = SkillPackagePolicy.validateContentMatchesExtension(path, content); + if (mismatch != null) { + throw new DomainBadRequestException("error.authoring.file.contentMismatch", path, mismatch); + } + + Optional existing = fileRepository.findByDraftIdAndFilePath(draftId, path); + if (existing.isEmpty() && fileRepository.countByDraftId(draftId) >= SkillPackagePolicy.MAX_FILE_COUNT) { + throw new DomainBadRequestException("error.authoring.file.countExceeded", + SkillPackagePolicy.MAX_FILE_COUNT); + } + long currentTotal = fileRepository.sumSizeByDraftId(draftId) + - existing.map(DraftFile::getSize).orElse(0L); + if (currentTotal + content.length > SkillPackagePolicy.MAX_TOTAL_PACKAGE_SIZE) { + throw new DomainBadRequestException("error.authoring.package.tooLarge", + SkillPackagePolicy.MAX_TOTAL_PACKAGE_SIZE); + } + + DraftFile file = existing + .map(current -> { + current.updateContent(sha256Hex(content), (long) content.length, + resolveContentType(path, contentType), storageKey(draftId, content)); + return current; + }) + .orElseGet(() -> storeFile(draftId, path, content, resolveContentType(path, contentType))); + file = fileRepository.save(file); + // write content after resolving the key so the same digest always lands in the same object + putContent(storageKey(draftId, content), content, resolveContentType(path, contentType)); + + boolean revisionAdvanced = advanceRevisionIfContentChanged(draft); + return new SaveFileOutcome(draft, file, existing.isEmpty(), revisionAdvanced); + } + + @Transactional + public void deleteFile(Long draftId, String userId, String rawPath, Integer expectedRevision, + Set platformRoles) { + SkillDraft draft = getOwnedDraft(draftId, userId, platformRoles); + assertRevisionMatches(draft, expectedRevision); + String path = normalizeAndCheckPath(rawPath); + DraftFile file = fileRepository.findByDraftIdAndFilePath(draftId, path) + .orElseThrow(() -> new DomainNotFoundException("error.authoring.file.notFound", path)); + fileRepository.delete(file); + advanceRevisionIfContentChanged(draft); + } + + @Transactional(readOnly = true) + public List listFiles(Long draftId) { + return fileRepository.findByDraftIdOrderByFilePath(draftId); + } + + @Transactional(readOnly = true) + public FileContent readFile(Long draftId, String userId, String rawPath, + Set platformRoles) { + getOwnedDraft(draftId, userId, platformRoles); + String path; + try { + path = SkillPackagePolicy.canonicalizeSkillMdPath( + SkillPackagePolicy.normalizeEntryPath(rawPath)); + } catch (IllegalArgumentException exception) { + throw new DomainBadRequestException("error.authoring.file.path.invalid", rawPath); + } + DraftFile file = fileRepository.findByDraftIdAndFilePath(draftId, path) + .orElseThrow(() -> new DomainNotFoundException("error.authoring.file.notFound", path)); + byte[] content; + try (InputStream input = objectStorageService.getObject(file.getStorageKey())) { + content = input.readAllBytes(); + } catch (IOException exception) { + throw new IllegalStateException("Failed to read draft file: " + path, exception); + } + return new FileContent(file, content); + } + + public record FileContent(DraftFile file, byte[] content) { + public String asText() { + return new String(content, StandardCharsets.UTF_8); + } + } + + // ---------------------------------------------------------------- patches + + /** + * Applies a confirmed set of file patches atomically: every patch must target the + * exact current content (sha256 match), and the whole batch produces exactly one + * new draft revision. + */ + @Transactional + public SkillDraft applyPatch(Long draftId, String userId, List patches, + Integer expectedRevision, Set platformRoles) { + SkillDraft draft = getOwnedDraft(draftId, userId, platformRoles); + assertRevisionMatches(draft, expectedRevision); + if (patches == null || patches.isEmpty()) { + throw new DomainBadRequestException("error.authoring.patch.empty"); + } + + for (FilePatch patch : patches) { + String path = normalizeAndCheckPath(patch.filePath()); + byte[] content = patch.newValue().getBytes(StandardCharsets.UTF_8); + String contentType = resolveContentType(path, null); + Optional existing = fileRepository.findByDraftIdAndFilePath(draftId, path); + if (patch.isCreation()) { + if (existing.isPresent()) { + throw new DomainConflictException("error.authoring.patch.fileExists", path); + } + fileRepository.save(storeFile(draftId, path, content, contentType)); + } else { + DraftFile current = existing.orElseThrow(() -> + new DomainConflictException("error.authoring.patch.fileMissing", path)); + String currentSha = current.getSha256(); + if (patch.oldSha256() == null || !patch.oldSha256().equalsIgnoreCase(currentSha)) { + throw new DomainConflictException("error.authoring.patch.stale", path); + } + current.updateContent(sha256Hex(content), (long) content.length, + contentType, storageKey(draftId, content)); + fileRepository.save(current); + } + putContent(storageKey(draftId, content), content, contentType); + } + + boolean advanced = advanceRevisionIfContentChanged(draft); + if (!advanced) { + // a patch that does not change the digest is a programming error upstream + throw new DomainBadRequestException("error.authoring.patch.noChange"); + } + return draft; + } + + // ---------------------------------------------------------------- materialization + + /** + * Builds streaming package entries from the draft's current files. Used by the + * validation orchestrator (snapshot) and the submit flow (publish input). + */ + @Transactional(readOnly = true) + public List materializeEntries(Long draftId) { + List entries = new ArrayList<>(); + for (DraftFile file : fileRepository.findByDraftIdOrderByFilePath(draftId)) { + entries.add(PackageEntry.streaming( + file.getFilePath(), + file.getSize(), + file.getContentType(), + () -> objectStorageService.getObject(file.getStorageKey()))); + } + return entries; + } + + @Transactional(readOnly = true) + public String computeDigest(Long draftId) { + List files = new ArrayList<>(fileRepository.findByDraftIdOrderByFilePath(draftId)); + files.sort(Comparator.comparing(DraftFile::getFilePath)); + StringBuilder manifest = new StringBuilder(); + for (DraftFile file : files) { + manifest.append(file.getFilePath()).append('\u0000') + .append(file.getSha256()).append('\n'); + } + return sha256Hex(manifest.toString()); + } + + @Transactional(readOnly = true) + public String resolveNamespaceSlug(SkillDraft draft) { + return namespaceRepository.findById(draft.getNamespaceId()) + .map(Namespace::getSlug) + .orElseThrow(() -> new DomainNotFoundException( + "error.authoring.namespace.notFound", draft.getNamespaceId())); + } + + // ---------------------------------------------------------------- internals + + private DraftFile storeFile(Long draftId, String path, byte[] content, String contentType) { + return new DraftFile(draftId, path, sha256Hex(content), (long) content.length, + contentType, storageKey(draftId, content)); + } + + private void putContent(String key, byte[] content, String contentType) { + if (!objectStorageService.exists(key)) { + objectStorageService.putObject(key, new ByteArrayInputStream(content), + content.length, contentType); + } + } + + private boolean advanceRevisionIfContentChanged(SkillDraft draft) { + String newDigest = computeDigest(draft.getId()); + if (newDigest.equals(draft.getContentDigest())) { + return false; + } + draft.applyContentChange(draft.getRevision() + 1, newDigest); + draftRepository.save(draft); + return true; + } + + private String storageKey(Long draftId, byte[] content) { + return "drafts/" + draftId + "/" + sha256Hex(content); + } + + private String resolveContentType(String path, String provided) { + if (provided != null && !provided.isBlank()) { + return provided; + } + String lower = path.toLowerCase(Locale.ROOT); + if (lower.endsWith(".md")) { + return "text/markdown"; + } + if (lower.endsWith(".yaml") || lower.endsWith(".yml")) { + return "application/yaml"; + } + if (lower.endsWith(".json")) { + return "application/json"; + } + if (lower.endsWith(".py")) { + return "text/x-python"; + } + if (lower.endsWith(".sh")) { + return "text/x-shellscript"; + } + return "application/octet-stream"; + } + + private String normalizeAndCheckPath(String rawPath) { + String normalized; + try { + normalized = SkillPackagePolicy.canonicalizeSkillMdPath( + SkillPackagePolicy.normalizeEntryPath(rawPath)); + } catch (IllegalArgumentException exception) { + throw new DomainBadRequestException("error.authoring.file.path.invalid", rawPath); + } + if (!SkillPackagePolicy.hasAllowedExtension(normalized)) { + throw new DomainBadRequestException("error.authoring.file.extension.disallowed", normalized); + } + return normalized; + } + + private void assertRevisionMatches(SkillDraft draft, Integer expectedRevision) { + if (expectedRevision != null && !expectedRevision.equals(draft.getRevision())) { + throw new DomainConflictException("error.authoring.draft.revision.conflict", + expectedRevision, draft.getRevision()); + } + } + + private void validateName(String name) { + if (name == null || name.isBlank()) { + throw new DomainBadRequestException("error.authoring.draft.name.required"); + } + if (name.length() > MAX_NAME_LENGTH) { + throw new DomainBadRequestException("error.authoring.draft.name.tooLong", MAX_NAME_LENGTH); + } + } + + private void assertNamespaceWritable(Namespace namespace) { + if (namespace.getStatus() == NamespaceStatus.FROZEN) { + throw new DomainForbiddenException("error.authoring.namespace.frozen", namespace.getSlug()); + } + if (namespace.getStatus() == NamespaceStatus.ARCHIVED) { + throw new DomainForbiddenException("error.authoring.namespace.archived", namespace.getSlug()); + } + } + + private void assertNamespaceMember(Namespace namespace, String userId, Set platformRoles) { + if (platformRoles != null && platformRoles.contains(SUPER_ADMIN)) { + return; + } + if (namespaceMemberRepository + .findByNamespaceIdAndUserId(namespace.getId(), userId).isEmpty()) { + throw new DomainForbiddenException("error.authoring.draft.notMember", namespace.getSlug()); + } + } + + private void assertOwner(SkillDraft draft, String userId, Set platformRoles) { + if (draft.getOwnerId().equals(userId) + || (platformRoles != null && platformRoles.contains(SUPER_ADMIN))) { + return; + } + throw new DomainForbiddenException("error.authoring.draft.forbidden", draft.getId()); + } + + public static String sha256Hex(byte[] content) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + return HexFormat.of().formatHex(digest.digest(content)); + } catch (NoSuchAlgorithmException exception) { + throw new IllegalStateException("SHA-256 unavailable", exception); + } + } + + public static String sha256Hex(String content) { + return sha256Hex(content.getBytes(StandardCharsets.UTF_8)); + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/service/SkillScaffoldGenerator.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/service/SkillScaffoldGenerator.java new file mode 100644 index 000000000..d0316acfe --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/service/SkillScaffoldGenerator.java @@ -0,0 +1,78 @@ +package com.iflytek.skillhub.domain.authoring.service; + +import com.iflytek.skillhub.domain.namespace.SlugValidator; +import org.springframework.stereotype.Service; + +/** + * Generates the initial SKILL.md scaffold for a new draft from the author's name and + * requirement description. Template-based by design: scaffolds are a starting point + * the author edits and validates; model-assisted generation remains an optional + * extension layered on top of this deterministic baseline. + */ +@Service +public class SkillScaffoldGenerator { + + /** + * Builds the scaffold SKILL.md content. + * + * @param draftName the draft name chosen by the author + * @param requirement the natural-language requirement description + */ + public String generateSkillMd(String draftName, String requirement) { + String skillName = safeName(draftName); + String overview = requirement == null || requirement.isBlank() + ? "Describe what this skill does and when an agent should use it." + : requirement.strip(); + return """ + --- + name: %s + description: %s + --- + + # %s + + ## Overview + + %s + + ## Usage + + Describe step by step how an agent should apply this skill. + + ## Resources + + - `references/` — background material the skill can cite + - `scripts/` — executable helpers invoked during task execution + """.formatted(skillName, singleLine(overview), skillName, overview); + } + + /** Builds a starter validation.yaml with one script task template. */ + public String generateValidationYaml() { + return """ + version: 1 + tasks: + - name: smoke + description: Replace with a real check for this skill + type: script + script: scripts/check.sh + args: [] + timeoutMs: 30000 + assertions: + - type: exit_code + equals: 0 + """; + } + + private String safeName(String draftName) { + try { + String slug = SlugValidator.slugify(draftName); + return slug == null || slug.isBlank() ? "my-skill" : slug; + } catch (Exception exception) { + return "my-skill"; + } + } + + private String singleLine(String text) { + return text.replaceAll("\\s+", " ").trim(); + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/service/ValidationRunService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/service/ValidationRunService.java new file mode 100644 index 000000000..f2e21c963 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/service/ValidationRunService.java @@ -0,0 +1,256 @@ +package com.iflytek.skillhub.domain.authoring.service; + +import com.iflytek.skillhub.domain.authoring.SkillDraft; +import com.iflytek.skillhub.domain.authoring.SkillDraftRepository; +import com.iflytek.skillhub.domain.authoring.validation.FindingDraft; +import com.iflytek.skillhub.domain.authoring.validation.ValidationEvent; +import com.iflytek.skillhub.domain.authoring.validation.ValidationEventType; +import com.iflytek.skillhub.domain.authoring.validation.ValidationFinding; +import com.iflytek.skillhub.domain.authoring.validation.ValidationFindingRepository; +import com.iflytek.skillhub.domain.authoring.validation.ValidationEventRepository; +import com.iflytek.skillhub.domain.authoring.validation.ValidationRun; +import com.iflytek.skillhub.domain.authoring.validation.ValidationRunRepository; +import com.iflytek.skillhub.domain.authoring.validation.ValidationRunStatus; +import com.iflytek.skillhub.domain.shared.exception.DomainConflictException; +import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; +import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException; +import java.time.Clock; +import java.time.Instant; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +/** + * Domain service for validation run lifecycle: starting runs bound to a frozen draft + * revision, appending ordered events, recording findings, and settling runs into + * terminal states. Events and findings commit in their own transactions so partial + * progress is always visible to SSE subscribers and polling clients. + */ +@Service +public class ValidationRunService { + + private static final String SUPER_ADMIN = "SUPER_ADMIN"; + private static final List ACTIVE_STATUSES = List.of( + ValidationRunStatus.QUEUED, ValidationRunStatus.PREPARING, ValidationRunStatus.RUNNING); + + private final ValidationRunRepository runRepository; + private final ValidationEventRepository eventRepository; + private final ValidationFindingRepository findingRepository; + private final SkillDraftRepository draftRepository; + private final Clock clock; + + public ValidationRunService(ValidationRunRepository runRepository, + ValidationEventRepository eventRepository, + ValidationFindingRepository findingRepository, + SkillDraftRepository draftRepository, + Clock clock) { + this.runRepository = runRepository; + this.eventRepository = eventRepository; + this.findingRepository = findingRepository; + this.draftRepository = draftRepository; + this.clock = clock; + } + + // ---------------------------------------------------------------- lifecycle + + /** + * Creates a QUEUED run bound to the draft's current revision. Only one active run + * per draft is allowed. + */ + @Transactional + public ValidationRun startRun(Long draftId, String userId, Set platformRoles) { + SkillDraft draft = draftRepository.findById(draftId) + .orElseThrow(() -> new DomainNotFoundException("error.authoring.draft.notFound", draftId)); + assertOwner(draft, userId, platformRoles); + if (runRepository.findByDraftIdOrderByCreatedAtDesc(draftId).stream() + .anyMatch(ValidationRun::isActive)) { + throw new DomainConflictException("error.authoring.run.activeExists", draftId); + } + return runRepository.save( + new ValidationRun(draftId, draft.getRevision(), userId)); + } + + /** + * Transitions a QUEUED run through PREPARING to RUNNING under a row lock, so + * cancellation and execution cannot interleave. Returns the refreshed run. + */ + @Transactional + public ValidationRun claimForExecution(Long runId) { + ValidationRun run = runRepository.findByIdForUpdate(runId) + .orElseThrow(() -> new DomainNotFoundException("error.authoring.run.notFound", runId)); + if (run.getStatus() == ValidationRunStatus.CANCELLED) { + throw new DomainConflictException("error.authoring.run.cancelled", runId); + } + if (run.getStatus() != ValidationRunStatus.QUEUED) { + throw new DomainConflictException("error.authoring.run.notQueued", runId); + } + if (run.isCancelRequested()) { + run.finish(ValidationRunStatus.CANCELLED, 0, 0, null, clock.instant()); + return runRepository.save(run); + } + run.markPreparing(); + run.markRunning(clock.instant()); + return runRepository.save(run); + } + + /** + * Idempotent cancel request. Sets the cooperative flag and settles QUEUED runs + * immediately; RUNNING/PREPARING runs are finished by their executor thread when + * it observes the flag (or by the maintenance sweep). + */ + @Transactional + public ValidationRun requestCancel(Long runId, String userId, Set platformRoles) { + ValidationRun run = runRepository.findByIdForUpdate(runId) + .orElseThrow(() -> new DomainNotFoundException("error.authoring.run.notFound", runId)); + SkillDraft draft = draftRepository.findById(run.getDraftId()) + .orElseThrow(() -> new DomainNotFoundException( + "error.authoring.draft.notFound", run.getDraftId())); + assertOwner(draft, userId, platformRoles); + + if (!run.isActive()) { + return run; // already terminal: cancel is idempotent + } + run.requestCancel(); + if (run.getStatus() == ValidationRunStatus.QUEUED) { + run.finish(ValidationRunStatus.CANCELLED, 0, 0, + Map.of("reason", "cancelled before start"), clock.instant()); + } + return runRepository.save(run); + } + + /** + * Settles a run into a terminal status if it is still active; returns empty when the + * run was already terminal (idempotent settle — the first terminal state wins). When + * the run succeeds with zero errors and the draft has not moved on, the draft is + * marked validated for this revision. + */ + @Transactional + public Optional settleIfActive(Long runId, ValidationRunStatus terminalStatus, + int errorCount, int warningCount, + Map summary) { + ValidationRun run = runRepository.findByIdForUpdate(runId) + .orElseThrow(() -> new DomainNotFoundException("error.authoring.run.notFound", runId)); + if (run.getStatus().isTerminal()) { + return Optional.empty(); + } + run.finish(terminalStatus, errorCount, warningCount, summary, clock.instant()); + ValidationRun settled = runRepository.save(run); + + if (terminalStatus == ValidationRunStatus.SUCCEEDED && errorCount == 0) { + draftRepository.findById(settled.getDraftId()).ifPresent(draft -> { + if (draft.getRevision().equals(settled.getDraftRevision())) { + draft.markValidated(settled.getDraftRevision(), settled.getId()); + draftRepository.save(draft); + } + }); + } + return Optional.of(settled); + } + + // ---------------------------------------------------------------- events & findings + + /** Appends one event with the next sequence number; commits independently. */ + @Transactional(propagation = Propagation.REQUIRES_NEW) + public ValidationEvent appendEvent(Long runId, ValidationEventType type, String phase, + Map payload) { + int nextSeq = eventRepository.findMaxSeqByRunId(runId).map(seq -> seq + 1).orElse(1); + return eventRepository.save( + new ValidationEvent(runId, nextSeq, type, phase, payload)); + } + + /** Records one finding and returns it; commits independently. */ + @Transactional(propagation = Propagation.REQUIRES_NEW) + public ValidationFinding recordFinding(Long runId, FindingDraft draft) { + return findingRepository.save(new ValidationFinding( + runId, draft.layer(), draft.ruleCode(), draft.severity(), + draft.filePath(), draft.location(), draft.message(), draft.suggestion())); + } + + // ---------------------------------------------------------------- queries + + @Transactional(readOnly = true) + public ValidationRun getOwnedRun(Long runId, String userId, Set platformRoles) { + ValidationRun run = runRepository.findById(runId) + .orElseThrow(() -> new DomainNotFoundException("error.authoring.run.notFound", runId)); + SkillDraft draft = draftRepository.findById(run.getDraftId()) + .orElseThrow(() -> new DomainNotFoundException( + "error.authoring.draft.notFound", run.getDraftId())); + assertOwner(draft, userId, platformRoles); + return run; + } + + /** Trusted accessor for internal executors (validation pipeline, maintenance sweep). */ + @Transactional(readOnly = true) + public ValidationRun getRun(Long runId) { + return runRepository.findById(runId) + .orElseThrow(() -> new DomainNotFoundException("error.authoring.run.notFound", runId)); + } + + @Transactional(readOnly = true) + public List listRuns(Long draftId) { + return runRepository.findByDraftIdOrderByCreatedAtDesc(draftId); + } + + @Transactional(readOnly = true) + public List listEvents(Long runId, Integer afterSeq) { + if (afterSeq == null) { + return eventRepository.findByRunIdOrderBySeqAsc(runId); + } + return eventRepository.findByRunIdAndSeqGreaterThanOrderBySeqAsc(runId, afterSeq); + } + + @Transactional(readOnly = true) + public List listFindings(Long runId) { + List findings = findingRepository.findByRunId(runId); + findings.sort(Comparator + .comparing(ValidationFinding::getSeverity) + .thenComparing(ValidationFinding::getId)); + return findings; + } + + @Transactional(readOnly = true) + public ValidationFinding getFinding(Long findingId) { + return findingRepository.findById(findingId) + .orElseThrow(() -> new DomainNotFoundException( + "error.authoring.finding.notFound", findingId)); + } + + @Transactional(readOnly = true) + public List findActiveRuns() { + return runRepository.findByStatusIn(ACTIVE_STATUSES); + } + + @Transactional(readOnly = true) + public List findActiveRunsCreatedBefore(Instant cutoff) { + return runRepository.findByStatusInAndCreatedAtBefore(ACTIVE_STATUSES, cutoff); + } + + /** + * The submission gate: the latest run bound to the draft's current revision must + * have succeeded with zero errors. + */ + @Transactional(readOnly = true) + public ValidationRun requirePassingRunForRevision(Long draftId, Integer revision) { + ValidationRun run = runRepository + .findFirstByDraftIdAndDraftRevisionOrderByCreatedAtDesc(draftId, revision) + .orElseThrow(() -> new DomainConflictException( + "error.authoring.submit.notValidated", revision)); + if (run.getStatus() != ValidationRunStatus.SUCCEEDED || run.getErrorCount() > 0) { + throw new DomainConflictException("error.authoring.submit.notValidated", revision); + } + return run; + } + + private void assertOwner(SkillDraft draft, String userId, Set platformRoles) { + if (draft.getOwnerId().equals(userId) + || (platformRoles != null && platformRoles.contains(SUPER_ADMIN))) { + return; + } + throw new DomainForbiddenException("error.authoring.draft.forbidden", draft.getId()); + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/spec/AssertionSpec.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/spec/AssertionSpec.java new file mode 100644 index 000000000..43e6dd755 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/spec/AssertionSpec.java @@ -0,0 +1,36 @@ +package com.iflytek.skillhub.domain.authoring.spec; + +import java.util.Map; + +/** + * One assertion inside a validation task. Assertions are deliberately loosely typed: + * {@code type} selects the check and {@code params} carries its typed parameters, + * which the evaluator validates and coerces. Unknown types or missing parameters are + * reported as configuration-layer findings before behavior execution starts. + */ +public record AssertionSpec(String type, Map params) { + + public Map safeParams() { + return params == null ? Map.of() : params; + } + + public String paramString(String key) { + Object value = safeParams().get(key); + return value == null ? null : value.toString(); + } + + public Integer paramInt(String key) { + Object value = safeParams().get(key); + if (value instanceof Number number) { + return number.intValue(); + } + if (value instanceof String text && !text.isBlank()) { + try { + return Integer.parseInt(text.trim()); + } catch (NumberFormatException ignored) { + return null; + } + } + return null; + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/spec/TaskType.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/spec/TaskType.java new file mode 100644 index 000000000..58fc6ff96 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/spec/TaskType.java @@ -0,0 +1,13 @@ +package com.iflytek.skillhub.domain.authoring.spec; + +/** + * Task kinds supported by behavior validation. + */ +public enum TaskType { + + /** Executes a script shipped with the skill in the isolated working directory. */ + SCRIPT, + + /** Sends a prompt to the bound agent runtime with the skill as context. */ + PROMPT +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/spec/ValidationSpec.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/spec/ValidationSpec.java new file mode 100644 index 000000000..b1bc5b34d --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/spec/ValidationSpec.java @@ -0,0 +1,28 @@ +package com.iflytek.skillhub.domain.authoring.spec; + +import java.util.List; +import java.util.Optional; + +/** + * Parsed content of a draft's {@code validation.yaml}: the ordered list of behavior + * validation tasks executed by the BEHAVIOR layer. A draft without the file simply + * runs zero behavior tasks. + */ +public record ValidationSpec(int version, List tasks) { + + public static final String FILE_PATH = "validation.yaml"; + public static final int CURRENT_VERSION = 1; + public static final int DEFAULT_TASK_TIMEOUT_MS = 60_000; + + public static ValidationSpec empty() { + return new ValidationSpec(CURRENT_VERSION, List.of()); + } + + public List safeTasks() { + return tasks == null ? List.of() : List.copyOf(tasks); + } + + public Optional findTask(String name) { + return safeTasks().stream().filter(task -> task.name().equals(name)).findFirst(); + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/spec/ValidationSpecParser.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/spec/ValidationSpecParser.java new file mode 100644 index 000000000..60695ab12 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/spec/ValidationSpecParser.java @@ -0,0 +1,211 @@ +package com.iflytek.skillhub.domain.authoring.spec; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import org.yaml.snakeyaml.LoaderOptions; +import org.yaml.snakeyaml.Yaml; +import org.yaml.snakeyaml.constructor.SafeConstructor; + +/** + * Parses a draft's {@code validation.yaml} into {@link ValidationSpec}. + * + *

Follows the same defensive YAML loading rules as {@code SkillMetadataParser}: + * SnakeYAML safe constructor, bounded aliases, nesting depth, and code points. + * Parse problems are reported as structured spec errors (rule code + message) instead + * of exceptions so the configuration validation layer can surface them as findings. + */ +public class ValidationSpecParser { + + private static final int MAX_YAML_ALIASES = 20; + private static final int MAX_YAML_NESTING_DEPTH = 20; + private static final int MAX_YAML_CODE_POINTS = 100_000; + private static final int MAX_TASKS = 50; + private static final int MAX_TASK_TIMEOUT_MS = 600_000; + + /** A structured parse or semantic error detected while reading the spec. */ + public record SpecError(String ruleCode, String message) {} + + public record ParseOutcome(ValidationSpec spec, List errors) { + + public boolean isValid() { + return errors.isEmpty(); + } + } + + public ParseOutcome parse(String content) { + if (content == null || content.isBlank()) { + return failure("SPEC_EMPTY", "validation.yaml is empty"); + } + Object parsed; + try { + LoaderOptions loaderOptions = new LoaderOptions(); + loaderOptions.setAllowDuplicateKeys(false); + loaderOptions.setMaxAliasesForCollections(MAX_YAML_ALIASES); + loaderOptions.setNestingDepthLimit(MAX_YAML_NESTING_DEPTH); + loaderOptions.setCodePointLimit(MAX_YAML_CODE_POINTS); + Yaml yaml = new Yaml(new SafeConstructor(loaderOptions)); + parsed = yaml.load(content); + } catch (Exception exception) { + return failure("SPEC_YAML_INVALID", "validation.yaml is not valid YAML: " + + exception.getMessage()); + } + if (!(parsed instanceof Map root)) { + return failure("SPEC_NOT_MAP", "validation.yaml must be a YAML object"); + } + + List errors = new ArrayList<>(); + Object versionValue = root.get("version"); + int version = 1; + if (versionValue instanceof Number number) { + version = number.intValue(); + } else if (versionValue != null) { + try { + version = Integer.parseInt(versionValue.toString().trim()); + } catch (NumberFormatException e) { + errors.add(new SpecError("SPEC_VERSION_INVALID", + "version must be an integer, got: " + versionValue)); + } + } + if (version != ValidationSpec.CURRENT_VERSION) { + errors.add(new SpecError("SPEC_VERSION_INVALID", + "unsupported spec version: " + version + " (expected " + + ValidationSpec.CURRENT_VERSION + ")")); + } + + List tasks = new ArrayList<>(); + java.util.Set seenNames = new java.util.HashSet<>(); + Object tasksValue = root.get("tasks"); + if (tasksValue == null) { + errors.add(new SpecError("SPEC_TASKS_MISSING", "validation.yaml must define a tasks list")); + } else if (!(tasksValue instanceof List rawTasks)) { + errors.add(new SpecError("SPEC_TASKS_INVALID", "tasks must be a list")); + } else { + if (rawTasks.size() > MAX_TASKS) { + errors.add(new SpecError("SPEC_TASKS_TOO_MANY", + "too many tasks: " + rawTasks.size() + " (max " + MAX_TASKS + ")")); + } + for (int i = 0; i < rawTasks.size(); i++) { + ValidationTaskSpec task = parseTask(rawTasks.get(i), i, errors, seenNames); + if (task != null) { + tasks.add(task); + } + } + } + + if (!errors.isEmpty()) { + return new ParseOutcome(ValidationSpec.empty(), List.copyOf(errors)); + } + return new ParseOutcome(new ValidationSpec(version, List.copyOf(tasks)), List.of()); + } + + private ValidationTaskSpec parseTask(Object rawTask, int index, List errors, + java.util.Set seenNames) { + String where = "tasks[" + index + "]"; + if (!(rawTask instanceof Map taskMap)) { + errors.add(new SpecError("TASK_NOT_MAP", where + " must be a YAML object")); + return null; + } + + String name = stringField(taskMap, "name"); + if (name == null || name.isBlank()) { + errors.add(new SpecError("TASK_NAME_MISSING", where + " is missing a name")); + } else if (!name.matches("[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}")) { + errors.add(new SpecError("TASK_NAME_INVALID", + where + " has an invalid name: " + name)); + } else if (!seenNames.add(name)) { + errors.add(new SpecError("TASK_NAME_DUPLICATE", where + " duplicates task name: " + name)); + } + + String typeRaw = stringField(taskMap, "type"); + TaskType type; + if (typeRaw == null) { + errors.add(new SpecError("TASK_TYPE_MISSING", where + " is missing a type")); + return null; + } + try { + type = TaskType.valueOf(typeRaw.trim().toUpperCase()); + } catch (IllegalArgumentException e) { + errors.add(new SpecError("TASK_TYPE_INVALID", + where + " has unknown type: " + typeRaw + " (expected script or prompt)")); + return null; + } + + String script = stringField(taskMap, "script"); + String prompt = stringField(taskMap, "prompt"); + if (type == TaskType.SCRIPT && (script == null || script.isBlank())) { + errors.add(new SpecError("TASK_SCRIPT_MISSING", + where + " (script task) must define script")); + } + if (type == TaskType.PROMPT && (prompt == null || prompt.isBlank())) { + errors.add(new SpecError("TASK_PROMPT_MISSING", + where + " (prompt task) must define prompt")); + } + + List args = new ArrayList<>(); + Object argsValue = taskMap.get("args"); + if (argsValue instanceof List rawArgs) { + for (Object arg : rawArgs) { + if (arg != null) { + args.add(arg.toString()); + } + } + } else if (argsValue != null) { + errors.add(new SpecError("TASK_ARGS_INVALID", where + " args must be a list")); + } + + int timeoutMs = ValidationSpec.DEFAULT_TASK_TIMEOUT_MS; + Object timeoutValue = taskMap.get("timeoutMs"); + if (timeoutValue instanceof Number number) { + timeoutMs = number.intValue(); + } else if (timeoutValue != null) { + errors.add(new SpecError("TASK_TIMEOUT_INVALID", + where + " timeoutMs must be an integer")); + } + if (timeoutMs <= 0 || timeoutMs > MAX_TASK_TIMEOUT_MS) { + errors.add(new SpecError("TASK_TIMEOUT_INVALID", + where + " timeoutMs must be between 1 and " + MAX_TASK_TIMEOUT_MS)); + } + + List assertions = new ArrayList<>(); + Object assertionsValue = taskMap.get("assertions"); + if (assertionsValue instanceof List rawAssertions) { + for (Object rawAssertion : rawAssertions) { + if (rawAssertion instanceof Map assertionMap) { + Object assertionType = assertionMap.get("type"); + Map params = new LinkedHashMap<>(); + for (Map.Entry entry : assertionMap.entrySet()) { + if (!"type".equals(entry.getKey()) && entry.getKey() != null) { + params.put(entry.getKey().toString(), entry.getValue()); + } + } + if (assertionType == null || assertionType.toString().isBlank()) { + errors.add(new SpecError("ASSERTION_TYPE_MISSING", + where + " has an assertion without a type")); + } else { + assertions.add(new AssertionSpec(assertionType.toString(), params)); + } + } else { + errors.add(new SpecError("ASSERTION_NOT_MAP", + where + " assertions must be objects")); + } + } + } else if (assertionsValue != null) { + errors.add(new SpecError("ASSERTIONS_INVALID", where + " assertions must be a list")); + } + + String description = stringField(taskMap, "description"); + return new ValidationTaskSpec(name == null ? where : name, description, type, script, + List.copyOf(args), prompt, timeoutMs, List.copyOf(assertions)); + } + + private String stringField(Map map, String key) { + Object value = map.get(key); + return value == null ? null : value.toString(); + } + + private ParseOutcome failure(String ruleCode, String message) { + return new ParseOutcome(ValidationSpec.empty(), List.of(new SpecError(ruleCode, message))); + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/spec/ValidationTaskSpec.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/spec/ValidationTaskSpec.java new file mode 100644 index 000000000..dce5ba21a --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/spec/ValidationTaskSpec.java @@ -0,0 +1,26 @@ +package com.iflytek.skillhub.domain.authoring.spec; + +import java.util.List; + +/** + * One behavior validation task parsed from {@code validation.yaml}. + */ +public record ValidationTaskSpec( + String name, + String description, + TaskType type, + String script, + List args, + String prompt, + int timeoutMs, + List assertions +) { + + public List safeArgs() { + return args == null ? List.of() : List.copyOf(args); + } + + public List safeAssertions() { + return assertions == null ? List.of() : List.copyOf(assertions); + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/spec/package-info.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/spec/package-info.java new file mode 100644 index 000000000..01f47fb37 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/spec/package-info.java @@ -0,0 +1,4 @@ +/** + * Behavior validation task specifications parsed from a draft's {@code validation.yaml}. + */ +package com.iflytek.skillhub.domain.authoring.spec; diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/validation/FindingDraft.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/validation/FindingDraft.java new file mode 100644 index 000000000..168fe36fc --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/validation/FindingDraft.java @@ -0,0 +1,36 @@ +package com.iflytek.skillhub.domain.authoring.validation; + +import com.iflytek.skillhub.domain.authoring.FixSuggestion; + +/** + * A finding produced by one validation layer, before it is persisted. The orchestrator + * converts these into {@link ValidationFinding} rows and emits FINDING events. + */ +public record FindingDraft( + ValidationLayer layer, + String ruleCode, + FindingSeverity severity, + String filePath, + String location, + String message, + FixSuggestion suggestion +) { + + public static FindingDraft error(ValidationLayer layer, String ruleCode, String message) { + return new FindingDraft(layer, ruleCode, FindingSeverity.ERROR, null, null, message, null); + } + + public static FindingDraft error(ValidationLayer layer, String ruleCode, String filePath, + String message, FixSuggestion suggestion) { + return new FindingDraft(layer, ruleCode, FindingSeverity.ERROR, filePath, null, message, suggestion); + } + + public static FindingDraft warning(ValidationLayer layer, String ruleCode, String filePath, + String message) { + return new FindingDraft(layer, ruleCode, FindingSeverity.WARNING, filePath, null, message, null); + } + + public static FindingDraft info(ValidationLayer layer, String ruleCode, String message) { + return new FindingDraft(layer, ruleCode, FindingSeverity.INFO, null, null, message, null); + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/validation/FindingSeverity.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/validation/FindingSeverity.java new file mode 100644 index 000000000..9dd8fbf8f --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/validation/FindingSeverity.java @@ -0,0 +1,10 @@ +package com.iflytek.skillhub.domain.authoring.validation; + +/** + * Severity of a validation finding. A run only fails when at least one ERROR finding exists. + */ +public enum FindingSeverity { + ERROR, + WARNING, + INFO +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/validation/FindingStatus.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/validation/FindingStatus.java new file mode 100644 index 000000000..08729c6b8 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/validation/FindingStatus.java @@ -0,0 +1,13 @@ +package com.iflytek.skillhub.domain.authoring.validation; + +/** + * Lifecycle of a fix suggestion attached to a finding. + */ +public enum FindingStatus { + /** Suggestion is available but has not been applied to the draft. */ + OPEN, + /** The suggestion's patch was applied and produced a new draft revision. */ + APPLIED, + /** The user explicitly dismissed the suggestion. */ + DISMISSED +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/validation/ValidationEvent.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/validation/ValidationEvent.java new file mode 100644 index 000000000..366c66e74 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/validation/ValidationEvent.java @@ -0,0 +1,98 @@ +package com.iflytek.skillhub.domain.authoring.validation; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.PrePersist; +import jakarta.persistence.Table; +import java.time.Clock; +import java.time.Instant; +import java.util.Map; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; + +/** + * One persisted run event. {@code seq} is a per-run monotonic counter used both for + * ordered display and for SSE resume (Last-Event-ID) and polling (afterSeq) cursors. + */ +@Entity +@Table(name = "validation_event", + uniqueConstraints = @jakarta.persistence.UniqueConstraint( + name = "uq_validation_event_seq", columnNames = {"run_id", "seq"})) +public class ValidationEvent { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "run_id", nullable = false) + private Long runId; + + @Column(nullable = false) + private Integer seq; + + @Enumerated(EnumType.STRING) + @Column(name = "event_type", nullable = false, length = 32) + private ValidationEventType eventType; + + @Column(length = 32) + private String phase; + + @JdbcTypeCode(SqlTypes.JSON) + @Column(name = "payload") + private Map payload; + + @Column(name = "created_at", nullable = false, updatable = false) + private Instant createdAt; + + protected ValidationEvent() { + } + + public ValidationEvent(Long runId, Integer seq, ValidationEventType eventType, + String phase, Map payload) { + this.runId = runId; + this.seq = seq; + this.eventType = eventType; + this.phase = phase; + this.payload = payload; + } + + @PrePersist + protected void onCreate() { + createdAt = Instant.now(Clock.systemUTC()); + } + + // Getters + + public Long getId() { + return id; + } + + public Long getRunId() { + return runId; + } + + public Integer getSeq() { + return seq; + } + + public ValidationEventType getEventType() { + return eventType; + } + + public String getPhase() { + return phase; + } + + public Map getPayload() { + return payload; + } + + public Instant getCreatedAt() { + return createdAt; + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/validation/ValidationEventRepository.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/validation/ValidationEventRepository.java new file mode 100644 index 000000000..a766e2665 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/validation/ValidationEventRepository.java @@ -0,0 +1,23 @@ +package com.iflytek.skillhub.domain.authoring.validation; + +import java.util.List; +import java.util.Optional; + +/** + * Domain repository contract for persisted validation events. + */ +public interface ValidationEventRepository { + + ValidationEvent save(ValidationEvent event); + + List findByRunIdOrderBySeqAsc(Long runId); + + /** Events with seq strictly greater than the cursor, ordered for replay. */ + List findByRunIdAndSeqGreaterThanOrderBySeqAsc(Long runId, int afterSeq); + + List findByRunIdAndSeqIn(Long runId, List seqs); + + Optional findMaxSeqByRunId(Long runId); + + void deleteByRunId(Long runId); +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/validation/ValidationEventType.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/validation/ValidationEventType.java new file mode 100644 index 000000000..db724dc84 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/validation/ValidationEventType.java @@ -0,0 +1,17 @@ +package com.iflytek.skillhub.domain.authoring.validation; + +/** + * Event types emitted during a validation run and persisted in order. The sequence + * number of each event enables SSE replay with Last-Event-ID resumption. + */ +public enum ValidationEventType { + RUN_STARTED, + PHASE_STARTED, + PHASE_FINISHED, + AGENT_MESSAGE, + TOOL_CALL, + TOOL_RESULT, + LOG, + FINDING, + RUN_FINISHED +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/validation/ValidationFinding.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/validation/ValidationFinding.java new file mode 100644 index 000000000..0d0d8d835 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/validation/ValidationFinding.java @@ -0,0 +1,152 @@ +package com.iflytek.skillhub.domain.authoring.validation; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.PrePersist; +import jakarta.persistence.Table; +import java.time.Clock; +import java.time.Instant; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; +import com.iflytek.skillhub.domain.authoring.FixSuggestion; + +/** + * A single problem or observation produced by one validation layer, optionally carrying + * a machine-applicable fix suggestion (patch payload) that the author can preview, + * confirm, and apply to produce a new draft revision. + */ +@Entity +@Table(name = "validation_finding") +public class ValidationFinding { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "run_id", nullable = false) + private Long runId; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 16) + private ValidationLayer layer; + + @Column(name = "rule_code", nullable = false, length = 64) + private String ruleCode; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 16) + private FindingSeverity severity; + + @Column(name = "file_path", length = 512) + private String filePath; + + @Column(length = 128) + private String location; + + @Column(nullable = false, columnDefinition = "TEXT") + private String message; + + /** Machine-applicable fix payload; null when no machine-applicable fix exists. */ + @JdbcTypeCode(SqlTypes.JSON) + @Column(name = "suggestion") + private FixSuggestion suggestion; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 16) + private FindingStatus status = FindingStatus.OPEN; + + @Column(name = "applied_revision") + private Integer appliedRevision; + + @Column(name = "created_at", nullable = false, updatable = false) + private Instant createdAt; + + protected ValidationFinding() { + } + + public ValidationFinding(Long runId, ValidationLayer layer, String ruleCode, + FindingSeverity severity, String filePath, String location, + String message, FixSuggestion suggestion) { + this.runId = runId; + this.layer = layer; + this.ruleCode = ruleCode; + this.severity = severity; + this.filePath = filePath; + this.location = location; + this.message = message; + this.suggestion = suggestion; + } + + @PrePersist + protected void onCreate() { + createdAt = Instant.now(Clock.systemUTC()); + } + + public void markApplied(int appliedRevision) { + this.status = FindingStatus.APPLIED; + this.appliedRevision = appliedRevision; + } + + public void markDismissed() { + this.status = FindingStatus.DISMISSED; + } + + public boolean hasSuggestion() { + return suggestion != null && !suggestion.safePatches().isEmpty(); + } + + // Getters + + public Long getId() { + return id; + } + + public Long getRunId() { + return runId; + } + + public ValidationLayer getLayer() { + return layer; + } + + public String getRuleCode() { + return ruleCode; + } + + public FindingSeverity getSeverity() { + return severity; + } + + public String getFilePath() { + return filePath; + } + + public String getLocation() { + return location; + } + + public String getMessage() { + return message; + } + + public FixSuggestion getSuggestion() { + return suggestion; + } + + public FindingStatus getStatus() { + return status; + } + + public Integer getAppliedRevision() { + return appliedRevision; + } + + public Instant getCreatedAt() { + return createdAt; + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/validation/ValidationFindingRepository.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/validation/ValidationFindingRepository.java new file mode 100644 index 000000000..9fb21b422 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/validation/ValidationFindingRepository.java @@ -0,0 +1,24 @@ +package com.iflytek.skillhub.domain.authoring.validation; + +import java.util.List; +import java.util.Optional; + +/** + * Domain repository contract for validation findings. + */ +public interface ValidationFindingRepository { + + ValidationFinding save(ValidationFinding finding); + + Optional findById(Long id); + + List findByRunId(Long runId); + + List findByRunIdAndStatus(Long runId, FindingStatus status); + + List findByRunIdAndStatusAndLayer(Long runId, FindingStatus status, ValidationLayer layer); + + List findByRunIdIn(List runIds); + + void deleteByRunId(Long runId); +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/validation/ValidationLayer.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/validation/ValidationLayer.java new file mode 100644 index 000000000..4ca2694d7 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/validation/ValidationLayer.java @@ -0,0 +1,10 @@ +package com.iflytek.skillhub.domain.authoring.validation; + +/** + * The three validation layers executed by every run, in order. + */ +public enum ValidationLayer { + STRUCTURE, + CONFIG, + BEHAVIOR +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/validation/ValidationRun.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/validation/ValidationRun.java new file mode 100644 index 000000000..ecb723960 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/validation/ValidationRun.java @@ -0,0 +1,191 @@ +package com.iflytek.skillhub.domain.authoring.validation; + +import com.iflytek.skillhub.domain.shared.exception.DomainConflictException; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.PrePersist; +import jakarta.persistence.Table; +import jakarta.persistence.Version; +import java.time.Clock; +import java.time.Instant; +import java.util.Map; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; + +/** + * One immutable validation execution bound to a specific draft revision. + * + *

Transitions are guarded: only the executor moves a run forward and only + * active runs can be cancelled or timed out. {@code cancelRequested} is a + * cooperative flag set by the cancel API and honored between tasks. + */ +@Entity +@Table(name = "validation_run") +public class ValidationRun { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "draft_id", nullable = false) + private Long draftId; + + @Column(name = "draft_revision", nullable = false) + private Integer draftRevision; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + private ValidationRunStatus status = ValidationRunStatus.QUEUED; + + @Column(name = "cancel_requested", nullable = false) + private boolean cancelRequested; + + @Column(name = "error_count", nullable = false) + private Integer errorCount = 0; + + @Column(name = "warning_count", nullable = false) + private Integer warningCount = 0; + + @JdbcTypeCode(SqlTypes.JSON) + @Column(name = "summary") + private Map summary; + + @Column(name = "triggered_by", nullable = false, length = 128) + private String triggeredBy; + + @Column(name = "started_at") + private Instant startedAt; + + @Column(name = "finished_at") + private Instant finishedAt; + + @Column(name = "created_at", nullable = false, updatable = false) + private Instant createdAt; + + @Version + @Column(name = "version", nullable = false) + private Long optimisticVersion; + + protected ValidationRun() { + } + + public ValidationRun(Long draftId, Integer draftRevision, String triggeredBy) { + this.draftId = draftId; + this.draftRevision = draftRevision; + this.triggeredBy = triggeredBy; + this.status = ValidationRunStatus.QUEUED; + } + + @PrePersist + protected void onCreate() { + createdAt = Instant.now(Clock.systemUTC()); + } + + public void markPreparing() { + requireActive("markPreparing"); + if (status != ValidationRunStatus.QUEUED) { + throw new DomainConflictException("error.authoring.run.notQueued", id); + } + status = ValidationRunStatus.PREPARING; + } + + public void markRunning(Instant now) { + requireActive("markRunning"); + status = ValidationRunStatus.RUNNING; + startedAt = now; + } + + /** + * Settles the run into a terminal status and records finding counters plus a + * machine-readable summary. The status must be one of the natural outcomes + * (SUCCEEDED / FAILED / CANCELLED / TIMED_OUT). + */ + public void finish(ValidationRunStatus terminalStatus, int errorCount, int warningCount, + Map summary, Instant now) { + if (terminalStatus == null || !terminalStatus.isTerminal()) { + throw new IllegalArgumentException("Terminal status required: " + terminalStatus); + } + requireActive("finish"); + status = terminalStatus; + this.errorCount = errorCount; + this.warningCount = warningCount; + this.summary = summary; + this.finishedAt = now; + } + + /** Idempotent cooperative cancel request; repeated calls keep the first state. */ + public void requestCancel() { + if (isActive()) { + cancelRequested = true; + } + } + + public boolean isCancelRequested() { + return cancelRequested; + } + + public boolean isActive() { + return status.isActive(); + } + + private void requireActive(String action) { + if (status.isTerminal()) { + throw new DomainConflictException("error.authoring.run.terminal", id, status); + } + } + + // Getters + + public Long getId() { + return id; + } + + public Long getDraftId() { + return draftId; + } + + public Integer getDraftRevision() { + return draftRevision; + } + + public ValidationRunStatus getStatus() { + return status; + } + + public Integer getErrorCount() { + return errorCount; + } + + public Integer getWarningCount() { + return warningCount; + } + + public Map getSummary() { + return summary; + } + + public String getTriggeredBy() { + return triggeredBy; + } + + public Instant getStartedAt() { + return startedAt; + } + + public Instant getFinishedAt() { + return finishedAt; + } + + public Instant getCreatedAt() { + return createdAt; + } + + public Long getOptimisticVersion() { + return optimisticVersion; + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/validation/ValidationRunRepository.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/validation/ValidationRunRepository.java new file mode 100644 index 000000000..689f464d8 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/validation/ValidationRunRepository.java @@ -0,0 +1,28 @@ +package com.iflytek.skillhub.domain.authoring.validation; + +import java.time.Instant; +import java.util.List; +import java.util.Optional; + +/** + * Domain repository contract for validation runs. + */ +public interface ValidationRunRepository { + + ValidationRun save(ValidationRun run); + + Optional findById(Long id); + + Optional findByIdForUpdate(Long id); + + List findByDraftIdOrderByCreatedAtDesc(Long draftId); + + /** Latest run recorded for an exact draft revision, used by the submit gate. */ + Optional findFirstByDraftIdAndDraftRevisionOrderByCreatedAtDesc( + Long draftId, Integer draftRevision); + + /** Runs still in a non-terminal state, used by the maintenance sweep. */ + List findByStatusIn(List statuses); + + List findByStatusInAndCreatedAtBefore(List statuses, Instant cutoff); +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/validation/ValidationRunStatus.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/validation/ValidationRunStatus.java new file mode 100644 index 000000000..e262099d7 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/validation/ValidationRunStatus.java @@ -0,0 +1,24 @@ +package com.iflytek.skillhub.domain.authoring.validation; + +/** + * Lifecycle of one validation run. Runs are immutable snapshots of a draft revision: + * once started they execute the structure, configuration, and behavior layers in order + * and settle into a terminal status. + */ +public enum ValidationRunStatus { + QUEUED, + PREPARING, + RUNNING, + SUCCEEDED, + FAILED, + CANCELLED, + TIMED_OUT; + + public boolean isTerminal() { + return this == SUCCEEDED || this == FAILED || this == CANCELLED || this == TIMED_OUT; + } + + public boolean isActive() { + return !isTerminal(); + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/validation/package-info.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/validation/package-info.java new file mode 100644 index 000000000..c33cc2b7f --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/validation/package-info.java @@ -0,0 +1,5 @@ +/** + * Validation runs, persisted event streams, and findings produced by the structure, + * configuration, and behavior validation layers. + */ +package com.iflytek.skillhub.domain.authoring.validation; diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/authoring/runtime/AssertionEvaluatorTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/authoring/runtime/AssertionEvaluatorTest.java new file mode 100644 index 000000000..d3760764e --- /dev/null +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/authoring/runtime/AssertionEvaluatorTest.java @@ -0,0 +1,138 @@ +package com.iflytek.skillhub.domain.authoring.runtime; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.iflytek.skillhub.domain.authoring.spec.AssertionSpec; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import org.junit.jupiter.api.Test; + +class AssertionEvaluatorTest { + + private final AssertionEvaluator evaluator = new AssertionEvaluator(); + + private static TaskResult scriptResult(int exitCode, String stdout, String stderr) { + return TaskResult.ofScript(exitCode, stdout, stderr, 1, path -> Optional.empty()); + } + + @Test + void exitCodeAssertionPassesAndFails() { + AssertionSpec assertion = new AssertionSpec("exit_code", Map.of("equals", 0)); + + assertThat(evaluator.evaluate(List.of(assertion), scriptResult(0, "", ""))).isEmpty(); + assertThat(evaluator.evaluate(List.of(assertion), scriptResult(1, "", ""))) + .singleElement() + .satisfies(failure -> assertThat(failure.describe()).contains("exit code")); + } + + @Test + void stdoutContainsChecksValue() { + AssertionSpec assertion = new AssertionSpec("stdout_contains", Map.of("value", "OK")); + + assertThat(evaluator.evaluate(List.of(assertion), scriptResult(0, "everything OK here", ""))) + .isEmpty(); + assertThat(evaluator.evaluate(List.of(assertion), scriptResult(0, "failed", ""))) + .hasSize(1); + } + + @Test + void stdoutMatchesUsesRegexFindSemantics() { + AssertionSpec assertion = + new AssertionSpec("stdout_matches", Map.of("pattern", "duration: \\d+ms")); + + assertThat(evaluator.evaluate(List.of(assertion), scriptResult(0, "run finished, duration: 42ms", ""))) + .isEmpty(); + assertThat(evaluator.evaluate(List.of(assertion), scriptResult(0, "no timing info", ""))) + .hasSize(1); + } + + @Test + void jsonPointerAssertionComparesValue() { + AssertionSpec assertion = new AssertionSpec("stdout_json", + Map.of("pointer", "/status", "equals", "healthy")); + + assertThat(evaluator.evaluate(List.of(assertion), + scriptResult(0, "{\"status\":\"healthy\"}", ""))).isEmpty(); + assertThat(evaluator.evaluate(List.of(assertion), + scriptResult(0, "{\"status\":\"unhealthy\"}", ""))).hasSize(1); + assertThat(evaluator.evaluate(List.of(assertion), scriptResult(0, "not json", ""))) + .singleElement() + .satisfies(failure -> assertThat(failure.describe()).contains("not valid JSON")); + } + + @Test + void artifactExistsChecksContentHash() { + byte[] content = "artifact-bytes".getBytes(); + String sha = TaskResult.sha256Hex(content); + TaskResult result = TaskResult.ofScript(0, "", "", 0, path -> + "out/report.txt".equals(path) ? Optional.of(content) : Optional.empty()); + + AssertionSpec exists = new AssertionSpec("artifact_exists", Map.of("path", "out/report.txt")); + AssertionSpec missing = new AssertionSpec("artifact_exists", Map.of("path", "out/none.txt")); + AssertionSpec wrongHash = new AssertionSpec("artifact_exists", + Map.of("path", "out/report.txt", "sha256", "deadbeef")); + + assertThat(evaluator.evaluate(List.of(exists), result)).isEmpty(); + assertThat(evaluator.evaluate(List.of(missing), result)).hasSize(1); + assertThat(evaluator.evaluate(List.of(wrongHash), result)) + .singleElement() + .satisfies(failure -> assertThat(failure.describe()).contains("sha256")); + } + + @Test + void toolCallCountHonorsMinAndMax() { + TaskResult result = TaskResult.ofScript(0, "", "", 2, path -> Optional.empty()); + + assertThat(evaluator.evaluate( + List.of(new AssertionSpec("tool_call_count", Map.of("min", 1, "max", 3))), result)) + .isEmpty(); + assertThat(evaluator.evaluate( + List.of(new AssertionSpec("tool_call_count", Map.of("min", 3))), result)) + .hasSize(1); + assertThat(evaluator.evaluate( + List.of(new AssertionSpec("tool_call_count", Map.of("max", 1))), result)) + .hasSize(1); + } + + @Test + void responseAssertionsApplyToPromptResults() { + TaskResult result = TaskResult.ofPrompt("the skill summarizes documents", 0, + path -> Optional.empty()); + + assertThat(evaluator.evaluate(List.of( + new AssertionSpec("response_contains", Map.of("value", "summarizes"))), result)) + .isEmpty(); + assertThat(evaluator.evaluate(List.of( + new AssertionSpec("response_contains", Map.of("value", "translates"))), result)) + .hasSize(1); + } + + @Test + void unknownAssertionTypeFailsEvaluation() { + List assertions = List.of(new AssertionSpec("mind_read", Map.of())); + + assertThat(evaluator.evaluate(assertions, scriptResult(0, "", ""))) + .singleElement() + .satisfies(failure -> assertThat(failure.describe()).contains("unknown assertion type")); + } + + @Test + void validateSyntaxAcceptsKnownTypesPerTaskKind() { + List scriptAssertions = List.of( + new AssertionSpec("exit_code", Map.of("equals", 0)), + new AssertionSpec("stdout_contains", Map.of("value", "OK"))); + List promptAssertions = List.of( + new AssertionSpec("response_contains", Map.of("value", "OK")), + new AssertionSpec("tool_call_count", Map.of("min", 0))); + + assertThat(evaluator.validateSyntax(scriptAssertions, false)).isEmpty(); + assertThat(evaluator.validateSyntax(promptAssertions, true)).isEmpty(); + + List crossKind = List.of( + new AssertionSpec("exit_code", Map.of("equals", 0)), + new AssertionSpec("response_contains", Map.of("value", "OK"))); + assertThat(evaluator.validateSyntax(crossKind, true)).hasSize(1); + assertThat(evaluator.validateSyntax(crossKind, false)).hasSize(1); + } +} diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/authoring/service/DraftStructureValidatorTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/authoring/service/DraftStructureValidatorTest.java new file mode 100644 index 000000000..1bddf7f4d --- /dev/null +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/authoring/service/DraftStructureValidatorTest.java @@ -0,0 +1,112 @@ +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( + com.iflytek.skillhub.domain.authoring.runtime.TaskResult.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"; + } +} diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/authoring/service/SkillScaffoldGeneratorTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/authoring/service/SkillScaffoldGeneratorTest.java new file mode 100644 index 000000000..9009728e4 --- /dev/null +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/authoring/service/SkillScaffoldGeneratorTest.java @@ -0,0 +1,44 @@ +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 validationYamlScaffoldParses() { + String yaml = generator.generateValidationYaml(); + + var outcome = new com.iflytek.skillhub.domain.authoring.spec.ValidationSpecParser().parse(yaml); + assertThat(outcome.isValid()).as("scaffold validation.yaml must parse cleanly: %s", outcome.errors()) + .isTrue(); + assertThat(outcome.spec().safeTasks()).singleElement().satisfies(task -> { + assertThat(task.name()).isEqualTo("smoke"); + assertThat(task.script()).isEqualTo("scripts/check.sh"); + }); + } +} diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/authoring/spec/ValidationSpecParserTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/authoring/spec/ValidationSpecParserTest.java new file mode 100644 index 000000000..c1cda0bb1 --- /dev/null +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/authoring/spec/ValidationSpecParserTest.java @@ -0,0 +1,182 @@ +package com.iflytek.skillhub.domain.authoring.spec; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; +import org.junit.jupiter.api.Test; + +class ValidationSpecParserTest { + + private final ValidationSpecParser parser = new ValidationSpecParser(); + + @Test + void parsesValidScriptTask() { + ValidationSpecParser.ParseOutcome outcome = parser.parse(""" + version: 1 + tasks: + - name: smoke + description: basic check + type: script + script: scripts/check.sh + args: ["--quiet"] + timeoutMs: 5000 + assertions: + - type: exit_code + equals: 0 + - type: stdout_contains + value: OK + """); + + assertThat(outcome.isValid()).isTrue(); + ValidationSpec spec = outcome.spec(); + assertThat(spec.version()).isEqualTo(1); + assertThat(spec.safeTasks()).hasSize(1); + ValidationTaskSpec task = spec.safeTasks().get(0); + assertThat(task.name()).isEqualTo("smoke"); + assertThat(task.type()).isEqualTo(TaskType.SCRIPT); + assertThat(task.script()).isEqualTo("scripts/check.sh"); + assertThat(task.safeArgs()).containsExactly("--quiet"); + assertThat(task.timeoutMs()).isEqualTo(5000); + assertThat(task.safeAssertions()).hasSize(2); + assertThat(task.safeAssertions().get(0).type()).isEqualTo("exit_code"); + assertThat(task.safeAssertions().get(0).paramInt("equals")).isZero(); + } + + @Test + void parsesPromptTaskWithoutOptionalFields() { + ValidationSpecParser.ParseOutcome outcome = parser.parse(""" + version: 1 + tasks: + - name: ask + type: prompt + prompt: What is this skill about? + """); + + assertThat(outcome.isValid()).isTrue(); + ValidationTaskSpec task = outcome.spec().safeTasks().get(0); + assertThat(task.type()).isEqualTo(TaskType.PROMPT); + assertThat(task.timeoutMs()).isEqualTo(ValidationSpec.DEFAULT_TASK_TIMEOUT_MS); + assertThat(task.safeAssertions()).isEmpty(); + } + + @Test + void emptyContentIsASpecError() { + ValidationSpecParser.ParseOutcome outcome = parser.parse(" \n"); + + assertThat(outcome.isValid()).isFalse(); + assertThat(outcome.errors()).singleElement() + .satisfies(error -> assertThat(error.ruleCode()).isEqualTo("SPEC_EMPTY")); + } + + @Test + void invalidYamlIsASpecErrorNotAnException() { + ValidationSpecParser.ParseOutcome outcome = parser.parse("version: [unbalanced"); + + assertThat(outcome.isValid()).isFalse(); + assertThat(outcome.errors()).singleElement() + .satisfies(error -> assertThat(error.ruleCode()).isEqualTo("SPEC_YAML_INVALID")); + } + + @Test + void unsupportedVersionIsRejected() { + ValidationSpecParser.ParseOutcome outcome = parser.parse(""" + version: 2 + tasks: [] + """); + + assertThat(outcome.isValid()).isFalse(); + assertThat(outcome.errors()).singleElement() + .satisfies(error -> assertThat(error.ruleCode()).isEqualTo("SPEC_VERSION_INVALID")); + } + + @Test + void missingTasksListIsRejected() { + ValidationSpecParser.ParseOutcome outcome = parser.parse("version: 1\n"); + + assertThat(outcome.isValid()).isFalse(); + assertThat(outcome.errors()).singleElement() + .satisfies(error -> assertThat(error.ruleCode()).isEqualTo("SPEC_TASKS_MISSING")); + } + + @Test + void duplicateTaskNamesAreRejected() { + ValidationSpecParser.ParseOutcome outcome = parser.parse(""" + version: 1 + tasks: + - name: smoke + type: script + script: a.sh + - name: smoke + type: script + script: b.sh + """); + + assertThat(outcome.isValid()).isFalse(); + assertThat(outcome.errors()).singleElement() + .satisfies(error -> assertThat(error.ruleCode()).isEqualTo("TASK_NAME_DUPLICATE")); + } + + @Test + void scriptTaskRequiresScriptAndPromptTaskRequiresPrompt() { + ValidationSpecParser.ParseOutcome outcome = parser.parse(""" + version: 1 + tasks: + - name: no-script + type: script + - name: no-prompt + type: prompt + """); + + List codes = outcome.errors().stream().map(ValidationSpecParser.SpecError::ruleCode).toList(); + assertThat(codes).containsExactly("TASK_SCRIPT_MISSING", "TASK_PROMPT_MISSING"); + } + + @Test + void unknownTypeAndInvalidTimeoutAreRejected() { + ValidationSpecParser.ParseOutcome outcome = parser.parse(""" + version: 1 + tasks: + - name: weird + type: telepathy + - name: slow + type: script + script: a.sh + timeoutMs: 999999999 + """); + + List codes = outcome.errors().stream().map(ValidationSpecParser.SpecError::ruleCode).toList(); + assertThat(codes).contains("TASK_TYPE_INVALID", "TASK_TIMEOUT_INVALID"); + } + + @Test + void invalidTaskNameIsRejected() { + ValidationSpecParser.ParseOutcome outcome = parser.parse(""" + version: 1 + tasks: + - name: "has space" + type: script + script: a.sh + """); + + assertThat(outcome.isValid()).isFalse(); + assertThat(outcome.errors()).singleElement() + .satisfies(error -> assertThat(error.ruleCode()).isEqualTo("TASK_NAME_INVALID")); + } + + @Test + void assertionWithoutTypeIsRejected() { + ValidationSpecParser.ParseOutcome outcome = parser.parse(""" + version: 1 + tasks: + - name: smoke + type: script + script: a.sh + assertions: + - equals: 0 + """); + + assertThat(outcome.isValid()).isFalse(); + assertThat(outcome.errors()).singleElement() + .satisfies(error -> assertThat(error.ruleCode()).isEqualTo("ASSERTION_TYPE_MISSING")); + } +} diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/DraftFileJpaRepository.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/DraftFileJpaRepository.java new file mode 100644 index 000000000..458bc8187 --- /dev/null +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/DraftFileJpaRepository.java @@ -0,0 +1,30 @@ +package com.iflytek.skillhub.infra.jpa; + +import com.iflytek.skillhub.domain.authoring.DraftFile; +import com.iflytek.skillhub.domain.authoring.DraftFileRepository; +import java.util.List; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +/** + * JPA-backed repository for draft files. + */ +@Repository +public interface DraftFileJpaRepository extends JpaRepository, DraftFileRepository { + + List findByDraftIdOrderByFilePath(Long draftId); + + Optional findByDraftIdAndFilePath(Long draftId, String filePath); + + List findByDraftIdAndFilePathIn(Long draftId, List filePaths); + + long countByDraftId(Long draftId); + + @Query("SELECT COALESCE(SUM(file.size), 0) FROM DraftFile file WHERE file.draftId = :draftId") + long sumSizeByDraftId(@Param("draftId") Long draftId); + + void deleteByDraftId(Long draftId); +} diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/RuntimeBindingJpaRepository.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/RuntimeBindingJpaRepository.java new file mode 100644 index 000000000..c8b354fbf --- /dev/null +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/RuntimeBindingJpaRepository.java @@ -0,0 +1,19 @@ +package com.iflytek.skillhub.infra.jpa; + +import com.iflytek.skillhub.domain.authoring.RuntimeBinding; +import com.iflytek.skillhub.domain.authoring.RuntimeBindingRepository; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +/** + * JPA-backed repository for per-draft runtime bindings. + */ +@Repository +public interface RuntimeBindingJpaRepository extends JpaRepository, + RuntimeBindingRepository { + + Optional findByDraftId(Long draftId); + + void deleteByDraftId(Long draftId); +} diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillDraftJpaRepository.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillDraftJpaRepository.java new file mode 100644 index 000000000..db1398908 --- /dev/null +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillDraftJpaRepository.java @@ -0,0 +1,23 @@ +package com.iflytek.skillhub.infra.jpa; + +import com.iflytek.skillhub.domain.authoring.SkillDraft; +import com.iflytek.skillhub.domain.authoring.SkillDraftRepository; +import java.util.List; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +/** + * JPA-backed repository for skill authoring drafts. + */ +@Repository +public interface SkillDraftJpaRepository extends JpaRepository, SkillDraftRepository { + + List findByOwnerIdOrderByUpdatedAtDesc(String ownerId); + + List findByNamespaceId(Long namespaceId); + + Optional findByOwnerIdAndNameIgnoreCase(String ownerId, String name); + + boolean existsByIdAndOwnerId(Long id, String ownerId); +} diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/ValidationEventJpaRepository.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/ValidationEventJpaRepository.java new file mode 100644 index 000000000..8bcdafd2e --- /dev/null +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/ValidationEventJpaRepository.java @@ -0,0 +1,30 @@ +package com.iflytek.skillhub.infra.jpa; + +import com.iflytek.skillhub.domain.authoring.validation.ValidationEvent; +import com.iflytek.skillhub.domain.authoring.validation.ValidationEventRepository; +import java.util.List; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +/** + * JPA-backed repository for validation run events. + */ +@Repository +public interface ValidationEventJpaRepository extends JpaRepository, + ValidationEventRepository { + + List findByRunIdOrderBySeqAsc(Long runId); + + List findByRunIdAndSeqGreaterThanOrderBySeqAsc(Long runId, int afterSeq); + + List findByRunIdAndSeqIn(Long runId, List seqs); + + @Override + @Query("SELECT MAX(event.seq) FROM ValidationEvent event WHERE event.runId = :runId") + Optional findMaxSeqByRunId(@Param("runId") Long runId); + + void deleteByRunId(Long runId); +} diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/ValidationFindingJpaRepository.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/ValidationFindingJpaRepository.java new file mode 100644 index 000000000..03696b0f7 --- /dev/null +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/ValidationFindingJpaRepository.java @@ -0,0 +1,31 @@ +package com.iflytek.skillhub.infra.jpa; + +import com.iflytek.skillhub.domain.authoring.validation.ValidationFinding; +import com.iflytek.skillhub.domain.authoring.validation.ValidationFindingRepository; +import com.iflytek.skillhub.domain.authoring.validation.ValidationLayer; +import com.iflytek.skillhub.domain.authoring.validation.FindingStatus; +import java.util.List; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +/** + * JPA-backed repository for validation findings. + */ +@Repository +public interface ValidationFindingJpaRepository extends JpaRepository, + ValidationFindingRepository { + + Optional findById(Long id); + + List findByRunId(Long runId); + + List findByRunIdAndStatus(Long runId, FindingStatus status); + + List findByRunIdAndStatusAndLayer( + Long runId, FindingStatus status, ValidationLayer layer); + + List findByRunIdIn(List runIds); + + void deleteByRunId(Long runId); +} diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/ValidationRunJpaRepository.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/ValidationRunJpaRepository.java new file mode 100644 index 000000000..d9706ea11 --- /dev/null +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/ValidationRunJpaRepository.java @@ -0,0 +1,37 @@ +package com.iflytek.skillhub.infra.jpa; + +import com.iflytek.skillhub.domain.authoring.validation.ValidationRun; +import com.iflytek.skillhub.domain.authoring.validation.ValidationRunRepository; +import com.iflytek.skillhub.domain.authoring.validation.ValidationRunStatus; +import java.time.Instant; +import java.util.List; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +/** + * JPA-backed repository for validation runs. + * + *

The claim query uses explicit {@code FOR UPDATE} SQL for the same H2 PostgreSQL + * compatibility reason documented on {@link SkillVersionJpaRepository}. + */ +@Repository +public interface ValidationRunJpaRepository extends JpaRepository, + ValidationRunRepository { + + @Override + @Query(value = "SELECT * FROM validation_run WHERE id = :id FOR UPDATE", nativeQuery = true) + Optional findByIdForUpdate(@Param("id") Long id); + + List findByDraftIdOrderByCreatedAtDesc(Long draftId); + + Optional findFirstByDraftIdAndDraftRevisionOrderByCreatedAtDesc( + Long draftId, Integer draftRevision); + + List findByStatusIn(List statuses); + + List findByStatusInAndCreatedAtBefore( + List statuses, Instant cutoff); +} From 6137cfb33a29116445f592d603ed2729e99eec56 Mon Sep 17 00:00:00 2001 From: zjncs <18910855655@163.com> Date: Sun, 20 Sep 2026 02:35:47 +0800 Subject: [PATCH 02/19] feat(authoring): run three-layer validation with isolated runtimes and MCP probe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ValidationRunOrchestrator materializes a draft into a one-shot workspace and runs structure, configuration and behavior layers, streaming ordered events and findings. The local-script runtime executes inline for dev or in a locked-down Docker container (no network, resource caps, read-only rootfs, dropped capabilities); the OpenAI-compatible runtime drives an agent loop with real MCP tool execution. Declared MCP servers are probed for real at run time — connect, initialize handshake, tools/list — and unreachable servers or unknown toolFilters become CONFIG-layer findings even when validation.yaml is absent. A maintenance task sweeps crashed runs. Signed-off-by: zjncs <18910855655@163.com> --- .../iflytek/skillhub/SkillhubApplication.java | 4 +- .../config/AuthoringExecutorConfig.java | 29 + .../skillhub/config/AuthoringProperties.java | 214 +++++++ .../authoring/ValidationEventBroadcaster.java | 153 +++++ .../authoring/ValidationRunOrchestrator.java | 584 ++++++++++++++++++ .../adapter/DockerScriptCommandBuilder.java | 164 +++++ .../adapter/InlineScriptCommandBuilder.java | 38 ++ .../adapter/LocalScriptRuntimeAdapter.java | 237 +++++++ .../OpenAiCompatibleRuntimeAdapter.java | 328 ++++++++++ .../adapter/ScriptCommandBuilder.java | 53 ++ .../adapter/WorkingDirectoryArtifacts.java | 30 + .../service/authoring/mcp/HttpMcpClient.java | 231 +++++++ .../service/authoring/mcp/McpClient.java | 29 + .../authoring/mcp/McpClientFactory.java | 65 ++ .../authoring/mcp/McpProbeService.java | 92 +++ .../service/authoring/mcp/McpTool.java | 14 + .../service/authoring/mcp/StdioMcpClient.java | 170 +++++ .../task/ValidationRunMaintenanceTask.java | 117 ++++ .../src/main/resources/application.yml | 31 + .../src/main/resources/messages.properties | 41 ++ .../src/main/resources/messages_ru.properties | 41 ++ .../src/main/resources/messages_zh.properties | 41 ++ .../DockerScriptCommandBuilderTest.java | 85 +++ .../DockerScriptRuntimeAdapterTest.java | 120 ++++ ...iCompatibleRuntimeAdapterToolLoopTest.java | 278 +++++++++ .../authoring/mcp/HttpMcpClientTest.java | 101 +++ .../authoring/mcp/McpProbeServiceTest.java | 95 +++ .../authoring/mcp/StdioMcpClientTest.java | 89 +++ .../authoring/mcp/TestingMcpHttpServer.java | 156 +++++ 29 files changed, 3629 insertions(+), 1 deletion(-) create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/config/AuthoringExecutorConfig.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/config/AuthoringProperties.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/service/authoring/ValidationEventBroadcaster.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/service/authoring/ValidationRunOrchestrator.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/service/authoring/adapter/DockerScriptCommandBuilder.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/service/authoring/adapter/InlineScriptCommandBuilder.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/service/authoring/adapter/LocalScriptRuntimeAdapter.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/service/authoring/adapter/OpenAiCompatibleRuntimeAdapter.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/service/authoring/adapter/ScriptCommandBuilder.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/service/authoring/adapter/WorkingDirectoryArtifacts.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/service/authoring/mcp/HttpMcpClient.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/service/authoring/mcp/McpClient.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/service/authoring/mcp/McpClientFactory.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/service/authoring/mcp/McpProbeService.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/service/authoring/mcp/McpTool.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/service/authoring/mcp/StdioMcpClient.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/task/ValidationRunMaintenanceTask.java create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/service/authoring/adapter/DockerScriptCommandBuilderTest.java create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/service/authoring/adapter/DockerScriptRuntimeAdapterTest.java create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/service/authoring/adapter/OpenAiCompatibleRuntimeAdapterToolLoopTest.java create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/service/authoring/mcp/HttpMcpClientTest.java create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/service/authoring/mcp/McpProbeServiceTest.java create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/service/authoring/mcp/StdioMcpClientTest.java create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/service/authoring/mcp/TestingMcpHttpServer.java diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/SkillhubApplication.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/SkillhubApplication.java index 71ee7de8c..56540ea71 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/SkillhubApplication.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/SkillhubApplication.java @@ -1,6 +1,7 @@ package com.iflytek.skillhub; import com.iflytek.skillhub.bootstrap.BuiltinSkillProperties; +import com.iflytek.skillhub.config.AuthoringProperties; import com.iflytek.skillhub.config.ProfileFieldPolicyProperties; import com.iflytek.skillhub.config.ProfileModerationProperties; import org.springframework.boot.SpringApplication; @@ -14,7 +15,8 @@ @EnableConfigurationProperties({ BuiltinSkillProperties.class, ProfileModerationProperties.class, - ProfileFieldPolicyProperties.class + ProfileFieldPolicyProperties.class, + AuthoringProperties.class }) public class SkillhubApplication { public static void main(String[] args) { diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/AuthoringExecutorConfig.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/AuthoringExecutorConfig.java new file mode 100644 index 000000000..79771dfa5 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/AuthoringExecutorConfig.java @@ -0,0 +1,29 @@ +package com.iflytek.skillhub.config; + +import java.util.concurrent.Executor; +import java.util.concurrent.ThreadPoolExecutor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; + +/** + * Dedicated executor for authoring validation runs. Kept separate from + * {@code skillhubEventExecutor} because validation tasks run short-lived subprocesses + * and blocking HTTP calls that would starve the lightweight event executor. + */ +@Configuration +public class AuthoringExecutorConfig { + + @Bean(name = "authoringValidationExecutor") + public Executor authoringValidationExecutor(AuthoringProperties properties) { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + executor.setCorePoolSize(Math.max(1, properties.getExecutorThreads())); + executor.setMaxPoolSize(Math.max(1, properties.getExecutorThreads())); + executor.setQueueCapacity(100); + executor.setThreadNamePrefix("authoring-validation-"); + // a full queue runs the task on the submitting thread instead of dropping runs + executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); + executor.initialize(); + return executor; + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/AuthoringProperties.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/AuthoringProperties.java new file mode 100644 index 000000000..434b7c298 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/AuthoringProperties.java @@ -0,0 +1,214 @@ +package com.iflytek.skillhub.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * Feature configuration for the skill authoring and validation platform + * ({@code skillhub.authoring.*}). + */ +@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; + + private final LocalScript localScript = new LocalScript(); + + private final OpenAiCompatible openAiCompatible = new OpenAiCompatible(); + + 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; + } + + public LocalScript getLocalScript() { + return localScript; + } + + public OpenAiCompatible getOpenAiCompatible() { + return openAiCompatible; + } + + /** Local script execution runtime. */ + public static class LocalScript { + private boolean enabled = true; + + /** Where scripts run: INLINE on the server host (dev), DOCKER in a locked-down container (production). */ + private ScriptExecutionMode executionMode = ScriptExecutionMode.INLINE; + + private final Docker docker = new Docker(); + + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public ScriptExecutionMode getExecutionMode() { + return executionMode; + } + + public void setExecutionMode(ScriptExecutionMode executionMode) { + this.executionMode = executionMode; + } + + public Docker getDocker() { + return docker; + } + + /** Container isolation profile for the docker execution mode. */ + public static class Docker { + private String image = "alpine:3.20"; + private String memory = "256m"; + private String cpus = "1.0"; + private int pidsLimit = 128; + private String tmpfsSize = "64m"; + + public String getImage() { + return image; + } + + public void setImage(String image) { + this.image = image; + } + + public String getMemory() { + return memory; + } + + public void setMemory(String memory) { + this.memory = memory; + } + + public String getCpus() { + return cpus; + } + + public void setCpus(String cpus) { + this.cpus = cpus; + } + + public int getPidsLimit() { + return pidsLimit; + } + + public void setPidsLimit(int pidsLimit) { + this.pidsLimit = pidsLimit; + } + + public String getTmpfsSize() { + return tmpfsSize; + } + + public void setTmpfsSize(String tmpfsSize) { + this.tmpfsSize = tmpfsSize; + } + } + } + + /** Script execution backend selection. */ + public enum ScriptExecutionMode { + INLINE, + DOCKER + } + + /** + * OpenAI-compatible chat completion runtime. The API key is resolved here + * (environment/config), never from a per-draft runtime binding. + */ + public static class OpenAiCompatible { + private boolean enabled = false; + private String apiKey; + private String defaultEndpoint; + private String defaultModel; + private int timeoutMs = 120_000; + /** Max chat-completion rounds per prompt task (each round may execute MCP tool calls). */ + private int maxToolRounds = 4; + + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public String getApiKey() { + return apiKey; + } + + public void setApiKey(String apiKey) { + this.apiKey = apiKey; + } + + public String getDefaultEndpoint() { + return defaultEndpoint; + } + + public void setDefaultEndpoint(String defaultEndpoint) { + this.defaultEndpoint = defaultEndpoint; + } + + public String getDefaultModel() { + return defaultModel; + } + + public void setDefaultModel(String defaultModel) { + this.defaultModel = defaultModel; + } + + public int getTimeoutMs() { + return timeoutMs; + } + + public void setTimeoutMs(int timeoutMs) { + this.timeoutMs = timeoutMs; + } + + public int getMaxToolRounds() { + return maxToolRounds; + } + + public void setMaxToolRounds(int maxToolRounds) { + this.maxToolRounds = maxToolRounds; + } + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/authoring/ValidationEventBroadcaster.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/authoring/ValidationEventBroadcaster.java new file mode 100644 index 000000000..0dc5eaeba --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/authoring/ValidationEventBroadcaster.java @@ -0,0 +1,153 @@ +package com.iflytek.skillhub.service.authoring; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.iflytek.skillhub.domain.authoring.validation.ValidationEvent; +import com.iflytek.skillhub.domain.authoring.validation.ValidationEventType; +import java.io.IOException; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArrayList; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +/** + * In-memory SSE hub for validation runs. Each subscriber tracks the last sequence + * number it saw, so replay (Last-Event-ID / afterSeq) and live delivery can be + * interleaved without duplicates. The hub is per-instance by design: the current + * deployment runs one backend node, and remote clients always have the polling + * endpoint as a fallback. + */ +@Component +public class ValidationEventBroadcaster { + + /** SSE connection lifetime cap; clients reconnect with Last-Event-ID on expiry. */ + static final long EMITTER_TIMEOUT_MS = 30L * 60 * 1000; + + private final Map> sessionsByRun = new ConcurrentHashMap<>(); + private final ObjectMapper objectMapper; + + public ValidationEventBroadcaster(ObjectMapper objectMapper) { + this.objectMapper = objectMapper; + } + + /** + * Registers a live emitter and replays already-persisted events through it. The + * session cursor makes replay and concurrent live delivery duplicate-free and + * order-preserving: every send fires only for sequence numbers the session has + * not delivered yet. + * + * @param runId the run to subscribe to + * @param lastSeq the subscriber's last seen sequence number (Last-Event-ID) + * @param replay persisted events after {@code lastSeq}, oldest first + */ + public SseEmitter subscribe(Long runId, int lastSeq, List replay) { + EmitterSession session = new EmitterSession(lastSeq); + sessionsByRun.computeIfAbsent(runId, key -> new CopyOnWriteArrayList<>()).add(session); + Runnable detach = () -> detach(runId, session); + session.emitter().onCompletion(detach); + session.emitter().onTimeout(detach); + session.emitter().onError(error -> detach.run()); + + boolean terminal = false; + for (ValidationEvent event : replay) { + if (!session.send(event.getSeq(), envelope(event))) { + detach(runId, session); + return session.emitter(); + } + if (event.getEventType() == ValidationEventType.RUN_FINISHED) { + terminal = true; + } + } + if (terminal) { + // run already finished: close the stream so clients stop reconnecting + session.emitter().complete(); + } + return session.emitter(); + } + + /** Pushes one persisted event to every live subscriber of the run. */ + public void publish(Long runId, ValidationEvent event) { + List sessions = sessionsByRun.get(runId); + if (sessions == null) { + return; + } + String envelope = envelope(event); + for (EmitterSession session : sessions) { + if (!session.send(event.getSeq(), envelope)) { + detach(runId, session); + } + } + } + + /** Sends the terminal event and completes all subscribers of a finished run. */ + public void completeRun(Long runId, ValidationEvent terminalEvent) { + publish(runId, terminalEvent); + List sessions = sessionsByRun.remove(runId); + if (sessions == null) { + return; + } + for (EmitterSession session : sessions) { + session.emitter().complete(); + } + } + + private void detach(Long runId, EmitterSession session) { + List sessions = sessionsByRun.get(runId); + if (sessions != null) { + sessions.remove(session); + } + } + + private String envelope(ValidationEvent event) { + Map payload = event.getPayload() == null ? Map.of() : event.getPayload(); + Map envelope = Map.of( + "seq", event.getSeq(), + "type", event.getEventType().name(), + "phase", event.getPhase() == null ? "" : event.getPhase(), + "payload", payload, + "createdAt", event.getCreatedAt().toString()); + try { + return objectMapper.writeValueAsString(envelope); + } catch (IOException exception) { + return "{\"seq\":" + event.getSeq() + ",\"type\":\"" + event.getEventType() + "\"}"; + } + } + + /** One subscriber connection with its own delivery cursor and serialized writes. */ + static final class EmitterSession { + + private final SseEmitter emitter; + private int lastSeq; + + EmitterSession(int lastSeq) { + this.emitter = new SseEmitter(EMITTER_TIMEOUT_MS); + this.lastSeq = lastSeq; + } + + SseEmitter emitter() { + return emitter; + } + + int lastSeq() { + return lastSeq; + } + + /** Sends unless the event predates this session's cursor; false means the pipe broke. */ + synchronized boolean send(int seq, String envelope) { + if (seq <= lastSeq) { + return true; + } + try { + emitter.send(SseEmitter.event() + .id(String.valueOf(seq)) + .data(envelope, MediaType.APPLICATION_JSON)); + lastSeq = seq; + return true; + } catch (IOException | IllegalStateException exception) { + return false; + } + } + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/authoring/ValidationRunOrchestrator.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/authoring/ValidationRunOrchestrator.java new file mode 100644 index 000000000..23ccdce00 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/authoring/ValidationRunOrchestrator.java @@ -0,0 +1,584 @@ +package com.iflytek.skillhub.service.authoring; + +import com.iflytek.skillhub.config.AuthoringProperties; +import com.iflytek.skillhub.domain.authoring.RuntimeBinding; +import com.iflytek.skillhub.domain.authoring.SkillDraft; +import com.iflytek.skillhub.domain.authoring.runtime.AssertionEvaluator; +import com.iflytek.skillhub.domain.authoring.runtime.AssertionFailure; +import com.iflytek.skillhub.domain.authoring.runtime.RuntimeEventSink; +import com.iflytek.skillhub.domain.authoring.runtime.RuntimeExecutionContext; +import com.iflytek.skillhub.domain.authoring.runtime.SkillRuntimeAdapter; +import com.iflytek.skillhub.domain.authoring.runtime.TaskResult; +import com.iflytek.skillhub.domain.authoring.service.DraftStructureValidator; +import com.iflytek.skillhub.domain.authoring.service.RuntimeBindingService; +import com.iflytek.skillhub.domain.authoring.service.RuntimeBindingValidator; +import com.iflytek.skillhub.domain.authoring.service.SkillDraftService; +import com.iflytek.skillhub.domain.authoring.service.ValidationRunService; +import com.iflytek.skillhub.domain.authoring.spec.TaskType; +import com.iflytek.skillhub.domain.authoring.spec.ValidationSpec; +import com.iflytek.skillhub.domain.authoring.spec.ValidationSpecParser; +import com.iflytek.skillhub.domain.authoring.spec.ValidationTaskSpec; +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 com.iflytek.skillhub.service.authoring.mcp.McpProbeService; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashMap; +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.Component; + +/** + * Executes validation runs on a dedicated executor: materializes the draft into an + * isolated workspace, runs the three validation layers (structure, configuration, + * behavior), persists ordered events and findings as they occur, and settles the run. + * + *

All terminal transitions funnel through {@link #settle}, which appends exactly one + * RUN_FINISHED event per run regardless of whether the run ends normally, is cancelled, + * times out, or is swept after a crash. + */ +@Component +public class ValidationRunOrchestrator { + + private static final Logger log = LoggerFactory.getLogger(ValidationRunOrchestrator.class); + private static final String SPEC_FILE = ValidationSpec.FILE_PATH; + private static final int MAX_EVENT_TEXT = 4_000; + + private final ValidationRunService runService; + private final SkillDraftService draftService; + private final RuntimeBindingService bindingService; + private final DraftStructureValidator structureValidator; + private final RuntimeBindingValidator bindingValidator; + private final McpProbeService mcpProbeService; + private final ValidationSpecParser specParser = new ValidationSpecParser(); + private final AssertionEvaluator assertionEvaluator = new AssertionEvaluator(); + private final List adapters; + private final ValidationEventBroadcaster broadcaster; + private final AuthoringProperties properties; + private final Executor executor; + + /** Cooperative cancel flags mirroring the persisted cancelRequested flag for fast reaction. */ + private final Map cancelFlags = new ConcurrentHashMap<>(); + + /** Per-run monitors making the RUN_FINISHED append-and-broadcast exactly-once. */ + private final Map terminalLocks = new ConcurrentHashMap<>(); + + public ValidationRunOrchestrator(ValidationRunService runService, + SkillDraftService draftService, + RuntimeBindingService bindingService, + DraftStructureValidator structureValidator, + RuntimeBindingValidator bindingValidator, + McpProbeService mcpProbeService, + List adapters, + ValidationEventBroadcaster broadcaster, + AuthoringProperties properties, + @Qualifier("authoringValidationExecutor") Executor executor) { + this.runService = runService; + this.draftService = draftService; + this.bindingService = bindingService; + this.structureValidator = structureValidator; + this.bindingValidator = bindingValidator; + this.mcpProbeService = mcpProbeService; + this.adapters = List.copyOf(adapters); + this.broadcaster = broadcaster; + this.properties = properties; + 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 (between tasks or inside adapters). + */ + public ValidationRun requestCancel(Long runId, String userId, Set platformRoles) { + ValidationRun run = runService.requestCancel(runId, userId, platformRoles); + cancelFlags.computeIfAbsent(runId, key -> new AtomicBoolean()).set(true); + if (run.getStatus().isTerminal()) { + emitTerminalOnce(runId); + } + return run; + } + + /** + * Idempotent terminal settle: persists the outcome (first writer wins), appends the + * RUN_FINISHED event exactly once, and completes SSE subscribers. + */ + public void settle(Long runId, ValidationRunStatus status, int errorCount, int warningCount, + Map summary) { + runService.settleIfActive(runId, status, errorCount, warningCount, summary); + emitTerminalOnce(runId); + } + + // ---------------------------------------------------------------- execution + + private void executeRun(Long runId) { + try { + ValidationRun run = runService.claimForExecution(runId); + if (run.getStatus().isTerminal()) { + emitTerminalOnce(runId); + return; + } + executePipeline(run); + } catch (DomainConflictException exception) { + // cancelled while queued, or already claimed/settled elsewhere + log.info("Validation run {} no longer claimable: {}", runId, exception.getMessage()); + emitTerminalOnce(runId); + } catch (Exception exception) { + log.error("Validation run {} crashed", runId, exception); + settle(runId, ValidationRunStatus.FAILED, 1, 0, + Map.of("internalError", truncate(String.valueOf(exception.getMessage()), 500))); + } finally { + cancelFlags.remove(runId); + terminalLocks.remove(runId); + } + } + + private void executePipeline(ValidationRun run) { + Long runId = run.getId(); + SkillDraft draft = draftService.getDraft(run.getDraftId()); + List entries = draftService.materializeEntries(draft.getId()); + RunContext context = new RunContext(runId); + + emit(runId, ValidationEventType.RUN_STARTED, null, Map.of( + "draftId", draft.getId(), + "draftName", draft.getName(), + "draftRevision", run.getDraftRevision())); + + Path workspace = workspaceFor(runId); + try { + materializeWorkspace(workspace, entries); + + // ---- 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))); + + // ---- CONFIG layer + emit(runId, ValidationEventType.PHASE_STARTED, "CONFIG", Map.of()); + ValidationSpec spec = runConfigLayer(context, draft, entries); + emit(runId, ValidationEventType.PHASE_FINISHED, "CONFIG", Map.of( + "errors", context.errorCount(ValidationLayer.CONFIG), + "warnings", context.warningCount(ValidationLayer.CONFIG))); + + // ---- BEHAVIOR layer + emit(runId, ValidationEventType.PHASE_STARTED, "BEHAVIOR", Map.of()); + boolean blocked = context.errorCount(ValidationLayer.STRUCTURE) > 0 + || context.errorCount(ValidationLayer.CONFIG) > 0; + if (blocked) { + emit(runId, ValidationEventType.PHASE_FINISHED, "BEHAVIOR", + Map.of("skipped", true, "reason", "structure or configuration errors")); + } else { + BehaviorOutcome outcome = runBehaviorLayer(context, run, draft, spec, workspace); + if (outcome.interrupted()) { + return; // already settled (CANCELLED / TIMED_OUT) with terminal event + } + emit(runId, ValidationEventType.PHASE_FINISHED, "BEHAVIOR", Map.of( + "skipped", false, + "tasksRun", context.tasksRun, + "tasksFailed", context.tasksFailed)); + } + + settle(runId, + context.errorCount == 0 ? ValidationRunStatus.SUCCEEDED : ValidationRunStatus.FAILED, + context.errorCount, context.warningCount, summary(context, blocked)); + } 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))); + } finally { + deleteRecursively(workspace); + } + } + + // ---------------------------------------------------------------- config layer + + private ValidationSpec runConfigLayer(RunContext context, SkillDraft draft, + List entries) { + String specContent = readEntryText(entries, SPEC_FILE); + ValidationSpec spec = ValidationSpec.empty(); + if (specContent == null) { + context.recordFinding(FindingDraft.warning(ValidationLayer.CONFIG, "SPEC_MISSING", + SPEC_FILE, "validation.yaml is absent; behavior layer runs zero tasks")); + } else { + ValidationSpecParser.ParseOutcome outcome = specParser.parse(specContent); + outcome.errors().forEach(error -> context.recordFinding(FindingDraft.error( + ValidationLayer.CONFIG, error.ruleCode(), SPEC_FILE, error.message(), null))); + if (outcome.isValid()) { + spec = outcome.spec(); + } + } + + // The binding is checked (and its MCP servers probed) even when + // validation.yaml is absent: a binding that cannot work must fail the + // run here, not surface for the first time at submit. + RuntimeBinding binding = bindingService.findBinding(draft.getId()).orElse(null); + if (binding == null) { + if (!spec.safeTasks().isEmpty()) { + context.recordFinding(FindingDraft.error(ValidationLayer.CONFIG, "BINDING_MISSING", + "No runtime binding configured, but validation.yaml declares " + + spec.safeTasks().size() + " task(s)")); + } + return spec; + } + + bindingValidator.validate( + binding.getAgentType().identifier(), + binding.getConfig(), + binding.getToolAllowlist(), + binding.getMcpServers()) + .forEach(context::recordFinding); + + // declared MCP servers must actually answer before the behavior layer + // relies on their tools: connect, initialize, tools/list + List> mcpServers = binding.getMcpServers() == null + ? List.of() : binding.getMcpServers(); + if (!mcpServers.isEmpty()) { + McpProbeService.ProbeReport probeReport = + mcpProbeService.probe(mcpServers, java.time.Duration.ofSeconds(10)); + probeReport.findings().forEach(context::recordFinding); + for (McpProbeService.ServerProbe serverProbe : probeReport.servers()) { + if (serverProbe.connected()) { + emit(context.runId, ValidationEventType.LOG, "CONFIG", Map.of( + "task", "mcp:" + serverProbe.server(), "stream", "summary", + "line", "connected; tools: " + (serverProbe.tools().isEmpty() + ? "(none)" : String.join(", ", serverProbe.tools())))); + } + } + } + + SkillRuntimeAdapter adapter = findAdapter(binding.getAgentType().identifier()); + if (adapter == null) { + context.recordFinding(FindingDraft.error(ValidationLayer.CONFIG, "ADAPTER_NOT_FOUND", + "No runtime adapter is registered for agent type '" + + binding.getAgentType().identifier() + "'")); + } else if (!adapter.enabled()) { + context.recordFinding(FindingDraft.error(ValidationLayer.CONFIG, "RUNTIME_DISABLED", + "Runtime '" + binding.getAgentType().identifier() + + "' is disabled in server configuration")); + } + + for (ValidationTaskSpec task : spec.safeTasks()) { + assertionEvaluator.validateSyntax(task.safeAssertions(), task.type() == TaskType.PROMPT) + .forEach(problem -> context.recordFinding(FindingDraft.error( + ValidationLayer.CONFIG, "ASSERTION_INVALID", SPEC_FILE, + "task '" + task.name() + "': " + problem, null))); + } + return spec; + } + + // ---------------------------------------------------------------- behavior layer + + private BehaviorOutcome runBehaviorLayer(RunContext context, ValidationRun run, + SkillDraft draft, ValidationSpec spec, Path workspace) { + RuntimeBinding binding = bindingService.findBinding(draft.getId()).orElse(null); + if (binding == null || spec.safeTasks().isEmpty()) { + return BehaviorOutcome.COMPLETED; + } + SkillRuntimeAdapter adapter = findAdapter(binding.getAgentType().identifier()); + if (adapter == null || !adapter.enabled()) { + return BehaviorOutcome.COMPLETED; // already reported by the config layer + } + + long deadline = System.currentTimeMillis() + properties.getRunTimeoutMs(); + Map adapterConfig = binding.getConfig() == null + ? Map.of() : binding.getConfig(); + List toolAllowlist = binding.getToolAllowlist() == null + ? List.of() : binding.getToolAllowlist(); + AtomicBoolean cancelFlag = cancelFlags.computeIfAbsent(run.getId(), + key -> new AtomicBoolean(false)); + RuntimeExecutionContext executionContext = new RuntimeExecutionContext( + run.getId(), workspace, adapterConfig, toolAllowlist, + binding.getMcpServers() == null ? List.of() : binding.getMcpServers(), + cancelFlag::get); + + for (ValidationTaskSpec task : spec.safeTasks()) { + if (cancelFlag.get()) { + settle(run.getId(), ValidationRunStatus.CANCELLED, + context.errorCount, context.warningCount, summary(context, false)); + return BehaviorOutcome.interrupted("cancelled"); + } + if (System.currentTimeMillis() > deadline) { + settle(run.getId(), ValidationRunStatus.TIMED_OUT, + context.errorCount, context.warningCount, summary(context, false)); + return BehaviorOutcome.interrupted("timeout"); + } + executeTask(context, adapter, executionContext, task, workspace); + } + return BehaviorOutcome.COMPLETED; + } + + private void executeTask(RunContext context, SkillRuntimeAdapter adapter, + RuntimeExecutionContext executionContext, ValidationTaskSpec task, + Path workspace) { + if (!adapter.supports(task.type())) { + context.tasksFailed++; + context.recordFinding(FindingDraft.error(ValidationLayer.BEHAVIOR, "ADAPTER_TASK_UNSUPPORTED", + SPEC_FILE, "runtime '" + adapter.agentType() + "' cannot execute " + + task.type() + " task '" + task.name() + "'", null)); + return; + } + if (task.type() == TaskType.SCRIPT && !scriptExists(workspace, task.script())) { + context.tasksFailed++; + context.recordFinding(FindingDraft.error(ValidationLayer.BEHAVIOR, "TASK_SCRIPT_MISSING", + task.script(), "script file not found in the skill: " + task.script(), null)); + return; + } + + context.tasksRun++; + TaskResult result; + try { + result = adapter.execute(executionContext, task, context.sink()); + } catch (Exception exception) { + context.tasksFailed++; + context.recordFinding(FindingDraft.error(ValidationLayer.BEHAVIOR, "TASK_FAILED", + SPEC_FILE, "task '" + task.name() + "' failed: " + + truncate(String.valueOf(exception.getMessage()), 500), null)); + return; + } + + List failures = + assertionEvaluator.evaluate(task.safeAssertions(), result); + if (failures.isEmpty()) { + emit(context.runId, ValidationEventType.LOG, "BEHAVIOR", Map.of( + "task", task.name(), "stream", "summary", + "line", "task '" + task.name() + "' passed " + + task.safeAssertions().size() + " assertion(s)")); + return; + } + context.tasksFailed++; + for (AssertionFailure failure : failures) { + context.recordFinding(FindingDraft.error(ValidationLayer.BEHAVIOR, "ASSERTION_FAILED", + SPEC_FILE, "task '" + task.name() + "': " + failure.describe(), null)); + } + } + + // ---------------------------------------------------------------- events & terminal handling + + /** Emits one event, persists it, and pushes it to live SSE subscribers. */ + private void emit(Long runId, ValidationEventType type, String phase, Map payload) { + ValidationEvent event = runService.appendEvent(runId, type, phase, payload); + broadcaster.publish(runId, event); + } + + /** Appends the RUN_FINISHED event exactly once per run, then completes SSE subscribers. */ + private void emitTerminalOnce(Long runId) { + Object lock = terminalLocks.computeIfAbsent(runId, key -> new Object()); + synchronized (lock) { + for (ValidationEvent event : runService.listEvents(runId, null)) { + if (event.getEventType() == ValidationEventType.RUN_FINISHED) { + broadcaster.completeRun(runId, event); + return; + } + } + ValidationRun run = runService.getRun(runId); + ValidationEvent terminal = runService.appendEvent(runId, + ValidationEventType.RUN_FINISHED, null, Map.of( + "status", run.getStatus().name(), + "errorCount", run.getErrorCount(), + "warningCount", run.getWarningCount())); + broadcaster.completeRun(runId, terminal); + } + } + + // ---------------------------------------------------------------- small helpers + + private boolean scriptExists(Path workspace, String script) { + try { + Path resolved = workspace.resolve(script).normalize(); + return resolved.startsWith(workspace.normalize()) && Files.isRegularFile(resolved); + } catch (Exception exception) { + return false; + } + } + + private SkillRuntimeAdapter findAdapter(String agentType) { + return adapters.stream() + .filter(adapter -> adapter.agentType().equals(agentType)) + .findFirst() + .orElse(null); + } + + private Map summary(RunContext context, boolean behaviorSkipped) { + Map summary = new LinkedHashMap<>(); + summary.put("structureErrors", context.errorCount(ValidationLayer.STRUCTURE)); + summary.put("structureWarnings", context.warningCount(ValidationLayer.STRUCTURE)); + summary.put("configErrors", context.errorCount(ValidationLayer.CONFIG)); + summary.put("configWarnings", context.warningCount(ValidationLayer.CONFIG)); + summary.put("behaviorTasksRun", context.tasksRun); + summary.put("behaviorTasksFailed", context.tasksFailed); + summary.put("behaviorSkipped", behaviorSkipped); + summary.put("errorCount", context.errorCount); + summary.put("warningCount", context.warningCount); + return summary; + } + + private Path workspaceFor(Long runId) { + Path workspace = Path.of(properties.getWorkspaceRoot(), "run-" + runId); + try { + Files.createDirectories(workspace); + } catch (IOException exception) { + throw new IllegalStateException("cannot create validation workspace: " + workspace, exception); + } + return workspace; + } + + private void materializeWorkspace(Path workspace, List entries) throws IOException { + for (PackageEntry entry : entries) { + Path target = workspace.resolve(entry.path()).normalize(); + if (!target.startsWith(workspace.normalize())) { + throw new IOException("draft file escapes workspace: " + entry.path()); + } + Files.createDirectories(target.getParent()); + try (InputStream input = entry.openStream(); + OutputStream output = Files.newOutputStream(target)) { + input.transferTo(output); + } + } + } + + private String readEntryText(List entries, String path) { + return entries.stream() + .filter(entry -> path.equals(entry.path())) + .findFirst() + .map(entry -> new String(entry.content(), StandardCharsets.UTF_8)) + .orElse(null); + } + + private void deleteRecursively(Path root) { + try (var stream = Files.walk(root)) { + stream.sorted(Comparator.reverseOrder()).forEach(path -> { + try { + Files.deleteIfExists(path); + } catch (IOException ignored) { + // workspace cleanup is best-effort; the OS temp sweeper is the backstop + } + }); + } catch (IOException ignored) { + // same: cleanup failures must not mask the validation outcome + } + } + + private static String truncate(String value, int limit) { + if (value == null) { + return ""; + } + return value.length() <= limit ? value : value.substring(0, limit) + "…"; + } + + private record BehaviorOutcome(boolean interrupted, String reason) { + static final BehaviorOutcome COMPLETED = new BehaviorOutcome(false, null); + + static BehaviorOutcome interrupted(String reason) { + return new BehaviorOutcome(true, reason); + } + } + + /** Mutable per-run state: findings, counters, and the single-writer event sink. */ + private final class RunContext { + + private final Long runId; + private final List findings = new ArrayList<>(); + private int errorCount; + private int warningCount; + private int tasksRun; + private int tasksFailed; + + 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)); + } + + /** Serialized sink so per-run event sequence numbers cannot race. */ + RuntimeEventSink sink() { + return new RuntimeEventSink() { + @Override + public synchronized void agentMessage(String taskName, String content) { + emit(runId, ValidationEventType.AGENT_MESSAGE, "BEHAVIOR", Map.of( + "task", taskName, "content", truncate(content, MAX_EVENT_TEXT))); + } + + @Override + public synchronized void toolCall(String taskName, String tool, String argumentsJson) { + emit(runId, ValidationEventType.TOOL_CALL, "BEHAVIOR", Map.of( + "task", taskName, "tool", tool, + "arguments", truncate(argumentsJson, MAX_EVENT_TEXT))); + } + + @Override + public synchronized void toolResult(String taskName, String tool, String summaryJson) { + emit(runId, ValidationEventType.TOOL_RESULT, "BEHAVIOR", Map.of( + "task", taskName, "tool", tool, + "summary", truncate(summaryJson, MAX_EVENT_TEXT))); + } + + @Override + public synchronized void log(String taskName, String stream, String line) { + emit(runId, ValidationEventType.LOG, "BEHAVIOR", Map.of( + "task", taskName, "stream", stream, + "line", truncate(line, 2_000))); + } + }; + } + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/authoring/adapter/DockerScriptCommandBuilder.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/authoring/adapter/DockerScriptCommandBuilder.java new file mode 100644 index 000000000..197b0c133 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/authoring/adapter/DockerScriptCommandBuilder.java @@ -0,0 +1,164 @@ +package com.iflytek.skillhub.service.authoring.adapter; + +import com.iflytek.skillhub.config.AuthoringProperties; +import java.io.IOException; +import java.nio.file.Path; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.TimeUnit; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; + +/** + * Production backend: the script runs inside a throwaway container with a hard + * isolation profile — no network, memory/CPU/pids caps, read-only root filesystem, + * all capabilities dropped, no-new-privileges, no log persistence. Only the + * validation workspace is mounted (read-write) so scripts can produce artifacts; + * every other path the container sees is immutable or ephemeral tmpfs. + * + *

Network isolation is deliberately not configurable: a validation run must + * never be able to reach other services. The image must provide the configured + * interpreter (the default alpine ships {@code sh}; python/node skills need a + * matching image). + */ +@Component +public class DockerScriptCommandBuilder implements ScriptCommandBuilder { + + /** Mount point of the validation workspace inside the container. */ + public static final String CONTAINER_WORKSPACE = "/workspace"; + + private static final Logger log = LoggerFactory.getLogger(DockerScriptCommandBuilder.class); + private static final Duration PROBE_TIMEOUT = Duration.ofSeconds(5); + private static final Duration CLEANUP_TIMEOUT = Duration.ofSeconds(5); + + private final AuthoringProperties properties; + private volatile Boolean daemonReachable; + + public DockerScriptCommandBuilder(AuthoringProperties properties) { + this.properties = properties; + } + + @Override + public String backend() { + return "docker"; + } + + @Override + public boolean available() { + Boolean cached = daemonReachable; + if (cached == null) { + cached = probeDaemon(); + daemonReachable = cached; + } + return cached; + } + + /** Re-probes the daemon on the next {@link #available()} call. */ + void resetProbe() { + daemonReachable = null; + } + + private boolean probeDaemon() { + try { + Process process = new ProcessBuilder("docker", "info", "--format", "{{.ServerVersion}}") + .start(); + if (!process.waitFor(PROBE_TIMEOUT.toMillis(), TimeUnit.MILLISECONDS)) { + process.destroyForcibly(); + return false; + } + return process.exitValue() == 0; + } catch (IOException | InterruptedException exception) { + return false; + } + } + + @Override + public ScriptExecutionPlan plan(Path workspace, String interpreter, Path scriptPath, + List args, Map scrubbedEnvironment, + String runTag) { + AuthoringProperties.LocalScript.Docker docker = properties.getLocalScript().getDocker(); + String hostWorkspace = realPath(workspace); + + List command = new ArrayList<>(); + command.add("docker"); + command.add("run"); + command.add("--rm"); + command.add("--name"); + command.add(containerName(runTag)); + command.add("--network"); + command.add("none"); + command.add("--memory"); + command.add(docker.getMemory()); + command.add("--cpus"); + command.add(docker.getCpus()); + command.add("--pids-limit"); + command.add(String.valueOf(docker.getPidsLimit())); + command.add("--read-only"); + command.add("--tmpfs"); + command.add("/tmp:rw,size=" + docker.getTmpfsSize()); + command.add("--cap-drop"); + command.add("ALL"); + command.add("--security-opt"); + command.add("no-new-privileges"); + command.add("--log-driver"); + command.add("none"); + for (Map.Entry entry : scrubbedEnvironment.entrySet()) { + command.add("-e"); + command.add(entry.getKey() + "=" + containerEnvValue(entry.getKey(), entry.getValue())); + } + command.add("-v"); + command.add(hostWorkspace + ":" + CONTAINER_WORKSPACE); + command.add("-w"); + command.add(CONTAINER_WORKSPACE); + command.add(docker.getImage()); + command.add(interpreter); + command.add(containerScriptPath(workspace, scriptPath)); + command.addAll(args); + // the docker CLI needs its own environment to reach the daemon; the script + // only sees the -e variables above, inside the container + return ScriptExecutionPlan.inherited(List.copyOf(command)); + } + + @Override + public void cleanup(String runTag) { + try { + Process process = new ProcessBuilder("docker", "rm", "-f", containerName(runTag)).start(); + if (!process.waitFor(CLEANUP_TIMEOUT.toMillis(), TimeUnit.MILLISECONDS)) { + process.destroyForcibly(); + } + } catch (IOException | InterruptedException exception) { + log.warn("Could not force-remove validation container {}: {}", + containerName(runTag), exception.getMessage()); + } + } + + /** Container names allow [a-zA-Z0-9][a-zA-Z0-9_.-]+. */ + static String containerName(String runTag) { + String sanitized = runTag == null ? "unknown" : runTag.replaceAll("[^a-zA-Z0-9_.-]", "-"); + if (sanitized.isEmpty() || !Character.isLetterOrDigit(sanitized.charAt(0))) { + sanitized = "r" + sanitized; + } + return "skillhub-validation-" + sanitized; + } + + private String containerScriptPath(Path workspace, Path scriptPath) { + return CONTAINER_WORKSPACE + "/" + workspace.relativize(scriptPath); + } + + /** HOME points at the workspace; inside the container that is the mount point. */ + private String containerEnvValue(String key, String value) { + return "HOME".equals(key) ? CONTAINER_WORKSPACE : value; + } + + /** Resolves symlinks (e.g. macOS /var/folders → /private/var/folders) for bind mounts. */ + private String realPath(Path workspace) { + try { + return workspace.toRealPath().toString(); + } catch (IOException exception) { + return workspace.toAbsolutePath().toString(); + } + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/authoring/adapter/InlineScriptCommandBuilder.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/authoring/adapter/InlineScriptCommandBuilder.java new file mode 100644 index 000000000..6873649af --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/authoring/adapter/InlineScriptCommandBuilder.java @@ -0,0 +1,38 @@ +package com.iflytek.skillhub.service.authoring.adapter; + +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import org.springframework.stereotype.Component; + +/** + * Default backend: the script runs as a plain subprocess of the server process + * with a scrubbed environment. Suitable for local development and CI where the + * drafts are the developer's own; production deployments should switch to the + * docker backend. + */ +@Component +public class InlineScriptCommandBuilder implements ScriptCommandBuilder { + + @Override + public String backend() { + return "inline"; + } + + @Override + public boolean available() { + return true; + } + + @Override + public ScriptExecutionPlan plan(Path workspace, String interpreter, Path scriptPath, + List args, Map scrubbedEnvironment, + String runTag) { + List command = new ArrayList<>(); + command.add(interpreter); + command.add(scriptPath.toString()); + command.addAll(args); + return new ScriptExecutionPlan(List.copyOf(command), Map.copyOf(scrubbedEnvironment)); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/authoring/adapter/LocalScriptRuntimeAdapter.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/authoring/adapter/LocalScriptRuntimeAdapter.java new file mode 100644 index 000000000..df1f1ae7f --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/authoring/adapter/LocalScriptRuntimeAdapter.java @@ -0,0 +1,237 @@ +package com.iflytek.skillhub.service.authoring.adapter; + +import com.iflytek.skillhub.config.AuthoringProperties; +import com.iflytek.skillhub.domain.authoring.runtime.RuntimeEventSink; +import com.iflytek.skillhub.domain.authoring.runtime.RuntimeExecutionContext; +import com.iflytek.skillhub.domain.authoring.runtime.SkillRuntimeAdapter; +import com.iflytek.skillhub.domain.authoring.runtime.TaskResult; +import com.iflytek.skillhub.domain.authoring.spec.TaskType; +import com.iflytek.skillhub.domain.authoring.spec.ValidationTaskSpec; +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.TimeUnit; +import java.util.function.Function; +import java.util.stream.Collectors; +import org.springframework.stereotype.Component; + +/** + * Behavior-layer adapter that executes the skill's own scripts in an isolated working + * directory with a scrubbed environment. Only whitelisted interpreters may run and only + * environment variables named in the binding's {@code envAllowlist} pass through. + * + *

Where the process actually runs is delegated to a {@link ScriptCommandBuilder}: + * the inline backend spawns the interpreter on the server host, the docker backend + * wraps it in a locked-down container. Everything else (streaming, buffering, timeout, + * cancel) is backend-independent. + */ +@Component +public class LocalScriptRuntimeAdapter implements SkillRuntimeAdapter { + + private static final int MAX_STREAM_BUFFER = 256 * 1024; + private static final long POLL_SLICE_MS = 200; + + private final AuthoringProperties properties; + private final Map commandBuilders; + + public LocalScriptRuntimeAdapter(AuthoringProperties properties, + List commandBuilders) { + this.properties = properties; + this.commandBuilders = commandBuilders.stream() + .collect(Collectors.toUnmodifiableMap(ScriptCommandBuilder::backend, + Function.identity())); + } + + @Override + public String agentType() { + return "local-script"; + } + + @Override + public boolean supports(TaskType taskType) { + return taskType == TaskType.SCRIPT; + } + + @Override + public boolean enabled() { + return properties.getLocalScript().isEnabled() && commandBuilder().available(); + } + + @Override + public TaskResult execute(RuntimeExecutionContext context, ValidationTaskSpec task, + RuntimeEventSink sink) throws Exception { + ScriptCommandBuilder builder = commandBuilder(); + if (!builder.available()) { + throw new IllegalStateException( + "script execution backend '" + builder.backend() + "' is not available" + + " (is the docker daemon reachable?)"); + } + String interpreter = resolveInterpreter(context); + Path scriptPath = resolveInsideWorkingDirectory(context.workingDirectory(), task.script()); + String runTag = context.runId() + "-" + task.name(); + Map scrubbedEnvironment = scrubEnvironment(context); + + ScriptCommandBuilder.ScriptExecutionPlan plan = builder.plan( + context.workingDirectory(), interpreter, scriptPath, + task.safeArgs(), scrubbedEnvironment, runTag); + + sink.toolCall(task.name(), interpreter + " [" + builder.backend() + "]", + String.join(" ", task.safeArgs())); + + ProcessBuilder processBuilder = new ProcessBuilder(plan.command()); + processBuilder.directory(context.workingDirectory().toFile()); + processBuilder.redirectErrorStream(false); + if (plan.environment() != null) { + processBuilder.environment().clear(); + processBuilder.environment().putAll(plan.environment()); + } + + Process process = processBuilder.start(); + BoundedBuffer stdout = new BoundedBuffer(MAX_STREAM_BUFFER); + BoundedBuffer stderr = new BoundedBuffer(MAX_STREAM_BUFFER); + Thread stdoutReader = streamReader(process.getInputStream(), "stdout", task.name(), stdout, sink); + Thread stderrReader = streamReader(process.getErrorStream(), "stderr", task.name(), stderr, sink); + stdoutReader.start(); + stderrReader.start(); + + Integer exitCode = await(process, task, context, builder, runTag); + stdoutReader.join(TimeUnit.SECONDS.toMillis(5)); + stderrReader.join(TimeUnit.SECONDS.toMillis(5)); + + sink.toolResult(task.name(), interpreter, + "{\"backend\":\"" + builder.backend() + "\",\"exitCode\":" + exitCode + + ",\"stdoutBytes\":" + stdout.size() + + ",\"stderrBytes\":" + stderr.size() + "}"); + + return TaskResult.ofScript(exitCode, stdout.content(), stderr.content(), 1, + new WorkingDirectoryArtifacts(context.workingDirectory())); + } + + private ScriptCommandBuilder commandBuilder() { + String mode = properties.getLocalScript().getExecutionMode() + == AuthoringProperties.ScriptExecutionMode.DOCKER ? "docker" : "inline"; + ScriptCommandBuilder builder = commandBuilders.get(mode); + if (builder == null) { + throw new IllegalStateException( + "no script command builder registered for execution mode: " + mode); + } + return builder; + } + + private String resolveInterpreter(RuntimeExecutionContext context) { + String configured = context.configString("interpreter"); + if (configured != null && !configured.isBlank()) { + return configured; + } + return "sh"; + } + + private Map scrubEnvironment(RuntimeExecutionContext context) { + Map environment = new java.util.LinkedHashMap<>(); + environment.put("PATH", System.getenv().getOrDefault("PATH", "/usr/bin:/bin")); + environment.put("HOME", context.workingDirectory().toString()); + environment.put("LANG", "C.UTF-8"); + Object allowlist = context.safeAdapterConfig().get("envAllowlist"); + if (allowlist instanceof List entries) { + for (Object entry : entries) { + if (entry != null) { + String value = System.getenv(entry.toString()); + if (value != null) { + environment.put(entry.toString(), value); + } + } + } + } + return environment; + } + + /** + * Waits for process exit while honoring the task timeout and the cooperative cancel + * signal. Returns the exit code, or {@code null} when the process had to be killed; + * abnormal exits also trigger the builder's cleanup (the docker backend must + * force-remove the container because killing the CLI client does not stop it). + */ + private Integer await(Process process, ValidationTaskSpec task, RuntimeExecutionContext context, + ScriptCommandBuilder builder, String runTag) throws InterruptedException { + long deadline = System.nanoTime() + Duration.ofMillis(task.timeoutMs()).toNanos(); + while (true) { + if (process.waitFor(POLL_SLICE_MS, TimeUnit.MILLISECONDS)) { + return process.exitValue(); + } + if (context.cancellation() != null && context.cancellation().isCancelRequested()) { + process.destroyForcibly(); + process.waitFor(5, TimeUnit.SECONDS); + builder.cleanup(runTag); + return null; + } + if (System.nanoTime() > deadline) { + process.destroyForcibly(); + process.waitFor(5, TimeUnit.SECONDS); + builder.cleanup(runTag); + throw new IllegalStateException( + "task '" + task.name() + "' exceeded its timeout of " + task.timeoutMs() + " ms"); + } + } + } + + private Thread streamReader(java.io.InputStream stream, String streamName, String taskName, + BoundedBuffer buffer, RuntimeEventSink sink) { + Thread thread = new Thread(() -> { + try (BufferedReader reader = new BufferedReader( + new InputStreamReader(stream, StandardCharsets.UTF_8))) { + String line; + while ((line = reader.readLine()) != null) { + buffer.append(line); + sink.log(taskName, streamName, line); + } + } catch (IOException ignored) { + // stream closed when the process dies; buffered content is kept + } + }, "validation-stream-" + streamName); + thread.setDaemon(true); + return thread; + } + + static Path resolveInsideWorkingDirectory(Path workingDirectory, String relativePath) { + Path resolved = workingDirectory.resolve(relativePath).normalize(); + if (!resolved.startsWith(workingDirectory.normalize())) { + throw new IllegalArgumentException( + "path escapes the working directory: " + relativePath); + } + return resolved; + } + + /** Cap-aware single-stream buffer: keeps the head of the output for findings. */ + private static final class BoundedBuffer { + private final StringBuilder builder = new StringBuilder(); + private final int limit; + + BoundedBuffer(int limit) { + this.limit = limit; + } + + synchronized void append(String line) { + if (builder.length() >= limit) { + return; + } + if (builder.length() > 0) { + builder.append('\n'); + } + builder.append(line, 0, Math.min(line.length(), limit - builder.length())); + } + + synchronized String content() { + return builder.toString(); + } + + synchronized int size() { + return builder.length(); + } + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/authoring/adapter/OpenAiCompatibleRuntimeAdapter.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/authoring/adapter/OpenAiCompatibleRuntimeAdapter.java new file mode 100644 index 000000000..9cec555da --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/authoring/adapter/OpenAiCompatibleRuntimeAdapter.java @@ -0,0 +1,328 @@ +package com.iflytek.skillhub.service.authoring.adapter; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.iflytek.skillhub.config.AuthoringProperties; +import com.iflytek.skillhub.domain.authoring.runtime.RuntimeEventSink; +import com.iflytek.skillhub.domain.authoring.runtime.RuntimeExecutionContext; +import com.iflytek.skillhub.domain.authoring.runtime.SkillRuntimeAdapter; +import com.iflytek.skillhub.domain.authoring.runtime.TaskResult; +import com.iflytek.skillhub.domain.authoring.spec.TaskType; +import com.iflytek.skillhub.domain.authoring.spec.ValidationTaskSpec; +import com.iflytek.skillhub.service.authoring.mcp.HttpMcpClient; +import com.iflytek.skillhub.service.authoring.mcp.McpClient; +import com.iflytek.skillhub.service.authoring.mcp.McpClientFactory; +import com.iflytek.skillhub.service.authoring.mcp.McpTool; +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; +import org.springframework.stereotype.Component; + +/** + * Behavior-layer adapter for prompt tasks: an agentic loop against an + * OpenAI-compatible chat-completion endpoint. Tools declared on the binding's MCP + * servers are discovered live (initialize + tools/list), filtered by the server's + * toolFilters and the binding's toolAllowlist, exposed to the model as function + * tools, and executed for real when the model calls them — every chat round and + * every tool invocation/reply lands in the run's event trace. The API key is + * resolved from server-side configuration only; runtime bindings reference + * endpoints and models, never credentials. + */ +@Component +public class OpenAiCompatibleRuntimeAdapter implements SkillRuntimeAdapter { + + private static final String SKILL_MD = "SKILL.md"; + /** Cap on a single tool output fed back into the model context. */ + private static final int MAX_TOOL_OUTPUT_CHARS = 8_000; + /** Cap on tool discovery (initialize + tools/list) regardless of task timeout. */ + private static final Duration DISCOVERY_TIMEOUT = Duration.ofSeconds(30); + + private final AuthoringProperties properties; + private final ObjectMapper objectMapper; + private final McpClientFactory mcpClientFactory; + private final HttpClient httpClient; + + public OpenAiCompatibleRuntimeAdapter(AuthoringProperties properties, ObjectMapper objectMapper, + McpClientFactory mcpClientFactory) { + this.properties = properties; + this.objectMapper = objectMapper; + this.mcpClientFactory = mcpClientFactory; + this.httpClient = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(15)) + .build(); + } + + @Override + public String agentType() { + return "openai-compatible"; + } + + @Override + public boolean supports(TaskType taskType) { + return taskType == TaskType.PROMPT; + } + + @Override + public boolean enabled() { + return properties.getOpenAiCompatible().isEnabled(); + } + + /** One tool the model may call, bound to the client that can execute it. */ + private record ExposedTool(McpClient client, String server, McpTool tool) { + } + + @Override + public TaskResult execute(RuntimeExecutionContext context, ValidationTaskSpec task, + RuntimeEventSink sink) throws Exception { + AuthoringProperties.OpenAiCompatible config = properties.getOpenAiCompatible(); + String endpoint = firstNonBlank(context.configString("endpoint"), config.getDefaultEndpoint()); + String model = firstNonBlank(context.configString("model"), config.getDefaultModel()); + if (endpoint == null || model == null) { + throw new IllegalStateException( + "openai-compatible runtime requires endpoint and model (binding config or server defaults)"); + } + String url = endpoint.endsWith("/") ? endpoint + "chat/completions" : endpoint + "/chat/completions"; + Duration requestTimeout = Duration.ofMillis(Math.max(task.timeoutMs(), 1_000)); + + List clients = new ArrayList<>(); + Map exposedTools = discoverTools(context, task, sink, clients); + try { + ArrayNode messages = objectMapper.createArrayNode(); + messages.addObject().put("role", "system").put("content", readSkillMd(context.workingDirectory())); + messages.addObject().put("role", "user").put("content", task.prompt()); + + int executedToolCalls = 0; + String content = ""; + int maxRounds = Math.max(config.getMaxToolRounds(), 1); + for (int round = 1; round <= maxRounds; round++) { + if (context.cancellation().isCancelRequested()) { + throw new IllegalStateException("task '" + task.name() + "' cancelled"); + } + + ObjectNode requestBody = chatRequest(model, context, messages, exposedTools, round); + sink.toolCall(task.name(), "openai-chat", requestBody.get("meta").toString()); + JsonNode message = postChatCompletion(url, config, requestBody, requestTimeout); + + content = message.path("content").asText(""); + JsonNode toolCalls = message.path("tool_calls"); + if (toolCalls.isEmpty() || exposedTools.isEmpty()) { + break; // final answer (or nothing we can execute) + } + + // record the assistant turn verbatim, then append one tool reply per call + ObjectNode assistantMessage = objectMapper.createObjectNode(); + assistantMessage.put("role", "assistant"); + if (!content.isEmpty()) { + assistantMessage.put("content", content); + } + HttpMcpClient.attachToolCalls(objectMapper, assistantMessage, toolCalls); + messages.add(assistantMessage); + + for (JsonNode call : toolCalls) { + String callId = call.path("id").asText("call-" + executedToolCalls); + String functionName = call.path("function").path("name").asText(""); + String argumentsJson = call.path("function").path("arguments").asText("{}"); + ExposedTool exposed = exposedTools.get(functionName); + if (exposed == null) { + appendToolReply(messages, callId, "error: unknown tool '" + functionName + "'"); + sink.toolResult(task.name(), "mcp:" + functionName, + "{\"error\":\"unknown tool\"}"); + continue; + } + sink.toolCall(task.name(), "mcp:" + exposed.server() + "/" + exposed.tool().name(), + argumentsJson); + String reply; + try { + Map arguments = parseArguments(argumentsJson); + String output = exposed.client().callTool( + exposed.tool().name(), arguments, requestTimeout); + reply = truncate(output, MAX_TOOL_OUTPUT_CHARS); + executedToolCalls++; + sink.toolResult(task.name(), "mcp:" + exposed.server() + "/" + exposed.tool().name(), + "{\"ok\":true,\"length\":" + output.length() + "}"); + } catch (Exception exception) { + reply = "error: tool call failed: " + exception.getMessage(); + sink.toolResult(task.name(), "mcp:" + exposed.server() + "/" + exposed.tool().name(), + "{\"error\":" + jsonString(String.valueOf(exception.getMessage())) + "}"); + } + appendToolReply(messages, callId, reply); + } + if (round == maxRounds) { + sink.log(task.name(), "summary", + "round limit (" + maxRounds + ") reached while the model still requests tools"); + } + } + + sink.agentMessage(task.name(), truncate(content, 4_000)); + return TaskResult.ofPrompt(content, executedToolCalls, + new WorkingDirectoryArtifacts(context.workingDirectory())); + } finally { + clients.forEach(McpClient::close); + } + } + + // ---------------------------------------------------------------- tool discovery + + /** + * Connects to every declared MCP server, lists its tools, and keeps the ones + * allowed by the server's toolFilters and the binding's global toolAllowlist + * (empty allowlist = unrestricted; entries match the bare tool name or + * "server.tool"). Keys of the returned map are the function names exposed to + * the model. Every client created along the way is appended to {@code clients} + * so the caller can close them all — even servers whose tools were entirely + * filtered out. + */ + private Map discoverTools(RuntimeExecutionContext context, + ValidationTaskSpec task, RuntimeEventSink sink, + List clients) { + Map exposed = new LinkedHashMap<>(); + List allowlist = context.safeToolAllowlist(); + for (Map server : context.safeMcpServers()) { + String serverName = String.valueOf(server.get("name")); + Set toolFilters = server.get("toolFilters") instanceof List filters + ? filters.stream().map(String::valueOf).collect(Collectors.toSet()) + : Set.of(); + List tools; + McpClient client; + try { + client = mcpClientFactory.create(server); + clients.add(client); + tools = client.listTools(DISCOVERY_TIMEOUT); + } catch (Exception exception) { + // the config-layer probe already reported unreachable servers; skip quietly + sink.log(task.name(), "mcp", "server '" + serverName + "' unavailable during task: " + + exception.getMessage()); + continue; + } + for (McpTool tool : tools) { + if (!toolFilters.isEmpty() && !toolFilters.contains(tool.name())) { + continue; + } + if (!allowlist.isEmpty() && !allowlist.contains(tool.name()) + && !allowlist.contains(serverName + "." + tool.name())) { + continue; + } + exposed.put(exposedName(exposed.keySet(), serverName, tool.name()), + new ExposedTool(client, serverName, tool)); + } + } + return exposed; + } + + /** Function names must match ^[a-zA-Z0-9_-]{1,64}$; MCP names may contain dots. */ + private String exposedName(Set taken, String serverName, String toolName) { + String candidate = (serverName + "__" + toolName).replaceAll("[^a-zA-Z0-9_-]", "_"); + if (candidate.length() > 64) { + candidate = candidate.substring(0, 64); + } + String unique = candidate; + int suffix = 2; + while (taken.contains(unique)) { + String tail = "_" + suffix++; + unique = candidate.substring(0, 64 - tail.length()) + tail; + } + return unique; + } + + // ---------------------------------------------------------------- chat protocol + + private ObjectNode chatRequest(String model, RuntimeExecutionContext context, ArrayNode messages, + Map exposedTools, int round) { + ObjectNode body = objectMapper.createObjectNode(); + body.put("model", model); + body.put("stream", false); + Object temperature = context.safeAdapterConfig().get("temperature"); + if (temperature instanceof Number number) { + body.put("temperature", number.doubleValue()); + } + body.set("messages", messages); + if (!exposedTools.isEmpty()) { + ArrayNode tools = body.putArray("tools"); + exposedTools.forEach((exposedName, tool) -> tools.add( + HttpMcpClient.toFunctionSchema(objectMapper, tool.tool(), exposedName))); + } + // trace metadata; removed before the body reaches the endpoint + ObjectNode meta = objectMapper.createObjectNode(); + meta.put("model", model); + meta.put("round", round); + meta.put("messages", messages.size()); + meta.put("tools", exposedTools.size()); + body.set("meta", meta); + return body; + } + + /** Posts one chat-completion request and returns the first choice's message. */ + private JsonNode postChatCompletion(String url, AuthoringProperties.OpenAiCompatible config, + ObjectNode requestBody, Duration timeout) throws Exception { + requestBody.remove("meta"); // trace-only field must not reach the endpoint + HttpRequest.Builder requestBuilder = HttpRequest.newBuilder() + .uri(URI.create(url)) + .timeout(timeout) + .header("Content-Type", "application/json") + .POST(HttpRequest.BodyPublishers.ofString(requestBody.toString(), StandardCharsets.UTF_8)); + if (config.getApiKey() != null && !config.getApiKey().isBlank()) { + requestBuilder.header("Authorization", "Bearer " + config.getApiKey()); + } + HttpResponse response = + httpClient.send(requestBuilder.build(), HttpResponse.BodyHandlers.ofString()); + if (response.statusCode() < 200 || response.statusCode() >= 300) { + throw new IOException("chat completion failed with HTTP " + response.statusCode() + + ": " + truncate(response.body(), 500)); + } + return objectMapper.readTree(response.body()).path("choices").path(0).path("message"); + } + + private void appendToolReply(ArrayNode messages, String toolCallId, String content) { + messages.add(HttpMcpClient.toolResponseMessage(objectMapper, toolCallId, content)); + } + + @SuppressWarnings("unchecked") + private Map parseArguments(String argumentsJson) { + try { + return objectMapper.readValue(argumentsJson, Map.class); + } catch (Exception exception) { + return new HashMap<>(); + } + } + + private String jsonString(String value) { + return objectMapper.valueToTree(value == null ? "" : value).toString(); + } + + private String readSkillMd(Path workingDirectory) throws IOException { + Path skillMd = workingDirectory.resolve(SKILL_MD); + if (Files.isRegularFile(skillMd)) { + return Files.readString(skillMd, StandardCharsets.UTF_8); + } + return "No SKILL.md is present in this draft."; + } + + private static String firstNonBlank(String primary, String fallback) { + if (primary != null && !primary.isBlank()) { + return primary; + } + return fallback == null || fallback.isBlank() ? null : fallback; + } + + private static String truncate(String value, int limit) { + if (value == null) { + return ""; + } + return value.length() <= limit ? value : value.substring(0, limit) + "…"; + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/authoring/adapter/ScriptCommandBuilder.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/authoring/adapter/ScriptCommandBuilder.java new file mode 100644 index 000000000..c257be56c --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/authoring/adapter/ScriptCommandBuilder.java @@ -0,0 +1,53 @@ +package com.iflytek.skillhub.service.authoring.adapter; + +import java.nio.file.Path; +import java.util.List; +import java.util.Map; + +/** + * Builds the process command for one behavior-task script execution. Two backends + * ship: {@code inline} runs the script as a subprocess on the server host (local + * development only); {@code docker} runs it inside a locked-down container (no + * network, memory/CPU/pids caps, read-only root filesystem) for production. + */ +public interface ScriptCommandBuilder { + + /** Backend identifier matching {@code skillhub.authoring.local-script.execution-mode}. */ + String backend(); + + /** Whether the backend can execute right now (for docker: daemon reachable). */ + boolean available(); + + /** + * Builds the execution plan for one task. {@code scrubbedEnvironment} carries + * exactly the variables the script may see; backends decide how to deliver + * them (inline: process environment; docker: {@code -e} flags, with the + * workspace path remapped to its mount point inside the container). + * + * @param runTag stable per-task tag (run id + task name), used for container + * naming so abnormal exits can be force-removed + */ + ScriptExecutionPlan plan(Path workspace, String interpreter, Path scriptPath, + List args, Map scrubbedEnvironment, + String runTag); + + /** + * Best-effort cleanup after the wrapping process was killed (timeout or + * cancel). Killing the {@code docker run} client does not stop the container, + * so the docker backend force-removes it here. + */ + default void cleanup(String runTag) { + } + + /** + * Command line plus environment. A null environment means the spawned client + * (e.g. the docker CLI) needs the parent's environment intact; the isolation + * then lives in the container flags instead. + */ + record ScriptExecutionPlan(List command, Map environment) { + + public static ScriptExecutionPlan inherited(List command) { + return new ScriptExecutionPlan(command, null); + } + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/authoring/adapter/WorkingDirectoryArtifacts.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/authoring/adapter/WorkingDirectoryArtifacts.java new file mode 100644 index 000000000..25d6ae65f --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/authoring/adapter/WorkingDirectoryArtifacts.java @@ -0,0 +1,30 @@ +package com.iflytek.skillhub.service.authoring.adapter; + +import com.iflytek.skillhub.domain.authoring.runtime.TaskResult; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Optional; + +/** + * Reads artifact files from a run's isolated working directory, rejecting paths that + * would escape it. Shared by all runtime adapters. + */ +record WorkingDirectoryArtifacts(Path workingDirectory) implements TaskResult.ArtifactResolver { + + @Override + public Optional read(String relativePath) { + try { + Path resolved = workingDirectory.resolve(relativePath).normalize(); + if (!resolved.startsWith(workingDirectory.normalize())) { + return Optional.empty(); + } + if (!Files.isRegularFile(resolved)) { + return Optional.empty(); + } + return Optional.of(Files.readAllBytes(resolved)); + } catch (IllegalArgumentException | IOException exception) { + return Optional.empty(); + } + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/authoring/mcp/HttpMcpClient.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/authoring/mcp/HttpMcpClient.java new file mode 100644 index 000000000..34f33beba --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/authoring/mcp/HttpMcpClient.java @@ -0,0 +1,231 @@ +package com.iflytek.skillhub.service.authoring.mcp; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +/** + * MCP client for the streamable-HTTP transport (also used for legacy SSE + * endpoints that accept plain JSON-RPC POSTs). Each request is one JSON-RPC + * POST; responses may arrive as a bare JSON object or as a text/event-stream + * body whose data lines carry the JSON-RPC message. + */ +public class HttpMcpClient implements McpClient { + + public static final String PROTOCOL_VERSION = "2024-11-05"; + public static final String CLIENT_NAME = "skillhub-authoring"; + + private final String serverName; + private final String endpoint; + private final HttpClient httpClient; + private final ObjectMapper objectMapper; + private final Map extraHeaders; + private int nextId = 1; + private String sessionId; + + public HttpMcpClient(String serverName, String endpoint, HttpClient httpClient, + ObjectMapper objectMapper, Map extraHeaders) { + this.serverName = serverName; + this.endpoint = endpoint; + this.httpClient = httpClient; + this.objectMapper = objectMapper; + this.extraHeaders = Map.copyOf(extraHeaders); + } + + @Override + public String serverName() { + return serverName; + } + + @Override + public List listTools(Duration timeout) throws Exception { + ObjectNode params = objectMapper.createObjectNode(); + params.put("protocolVersion", PROTOCOL_VERSION); + params.set("capabilities", objectMapper.createObjectNode()); + ObjectNode clientInfo = params.putObject("clientInfo"); + clientInfo.put("name", CLIENT_NAME); + clientInfo.put("version", "1.0"); + // the server may assign a session id here; request() captures it for later calls + request("initialize", params, timeout); + // notify the server the handshake is complete; the response is irrelevant + notification("notifications/initialized", timeout); + + JsonNode tools = request("tools/list", objectMapper.createObjectNode(), timeout); + List discovered = new ArrayList<>(); + for (JsonNode tool : tools.path("tools")) { + discovered.add(new McpTool( + tool.path("name").asText(""), + tool.path("description").asText(""), + tool.path("inputSchema"))); + } + return discovered; + } + + @Override + public String callTool(String toolName, Map arguments, Duration timeout) + throws Exception { + ObjectNode params = objectMapper.createObjectNode(); + params.put("name", toolName); + params.set("arguments", objectMapper.valueToTree(arguments == null ? Map.of() : arguments)); + JsonNode result = request("tools/call", params, timeout); + if (result.path("isError").asBoolean(false)) { + throw new IOException("MCP tool '" + toolName + "' returned an error result on server '" + + serverName + "'"); + } + StringBuilder content = new StringBuilder(); + for (JsonNode part : result.path("content")) { + if ("text".equals(part.path("type").asText())) { + if (content.length() > 0) { + content.append('\n'); + } + content.append(part.path("text").asText("")); + } + } + return content.toString(); + } + + @Override + public void close() { + // HTTP transport is stateless from this client's point of view + } + + private JsonNode request(String method, ObjectNode params, Duration timeout) throws Exception { + ObjectNode request = objectMapper.createObjectNode(); + request.put("jsonrpc", "2.0"); + request.put("id", nextId++); + request.put("method", method); + request.set("params", params); + + HttpRequest.Builder builder = HttpRequest.newBuilder() + .uri(URI.create(endpoint)) + .timeout(timeout) + .header("Content-Type", "application/json") + .header("Accept", "application/json, text/event-stream") + .POST(HttpRequest.BodyPublishers.ofString(request.toString(), StandardCharsets.UTF_8)); + extraHeaders.forEach(builder::header); + if (sessionId != null) { + builder.header("Mcp-Session-Id", sessionId); + } + + HttpResponse response; + try { + response = httpClient.send(builder.build(), HttpResponse.BodyHandlers.ofString()); + } catch (IOException exception) { + throw new IOException("MCP server '" + serverName + "' request failed (" + + method + "): " + exception, exception); + } + if (response.statusCode() < 200 || response.statusCode() >= 300) { + throw new IOException("MCP server '" + serverName + "' returned HTTP " + + response.statusCode() + " for " + method); + } + String sessionHeader = response.headers().firstValue("Mcp-Session-Id").orElse(null); + if (sessionHeader != null) { + sessionId = sessionHeader; + } + JsonNode message = parseMessage(response.body(), request.path("id").asInt()); + if (message.has("error")) { + throw new IOException("MCP server '" + serverName + "' error on " + method + ": " + + message.path("error").path("message").asText()); + } + return message.path("result"); + } + + private void notification(String method, Duration timeout) { + try { + ObjectNode notification = objectMapper.createObjectNode(); + notification.put("jsonrpc", "2.0"); + notification.put("method", method); + HttpRequest.Builder builder = HttpRequest.newBuilder() + .uri(URI.create(endpoint)) + .timeout(timeout) + .header("Content-Type", "application/json") + .header("Accept", "application/json, text/event-stream") + .POST(HttpRequest.BodyPublishers.ofString(notification.toString(), + StandardCharsets.UTF_8)); + extraHeaders.forEach(builder::header); + if (sessionId != null) { + builder.header("Mcp-Session-Id", sessionId); + } + httpClient.send(builder.build(), HttpResponse.BodyHandlers.discarding()); + } catch (Exception ignored) { + // notifications are best-effort; servers that reject them still work + } + } + + /** Accepts either a bare JSON-RPC response or an SSE body with data lines. */ + private JsonNode parseMessage(String body, int requestId) throws IOException { + if (body == null || body.isBlank()) { + throw new IOException("MCP server '" + serverName + "' returned an empty body"); + } + String text = body.trim(); + if (text.startsWith("{")) { + return objectMapper.readTree(text); + } + for (String line : body.split("\n")) { + String trimmed = line.trim(); + if (!trimmed.startsWith("data:")) { + continue; + } + String payload = trimmed.substring("data:".length()).trim(); + if (payload.isEmpty()) { + continue; + } + JsonNode node = objectMapper.readTree(payload); + if (node.path("id").asInt(-1) == requestId) { + return node; + } + } + throw new IOException("MCP server '" + serverName + + "' response did not contain a result for request " + requestId); + } + + /** Helper for tests and callers that need the OpenAI-style tool schema. */ + public static ObjectNode toFunctionSchema(ObjectMapper objectMapper, McpTool tool) { + return toFunctionSchema(objectMapper, tool, tool.name()); + } + + /** Same, under the function name the caller exposes to the model. */ + public static ObjectNode toFunctionSchema(ObjectMapper objectMapper, McpTool tool, + String functionName) { + ObjectNode function = objectMapper.createObjectNode(); + function.put("name", functionName); + function.put("description", tool.description() == null ? "" : tool.description()); + function.set("parameters", tool.inputSchema() == null || tool.inputSchema().isNull() + ? objectMapper.createObjectNode() + : tool.inputSchema()); + ObjectNode wrapper = objectMapper.createObjectNode(); + wrapper.put("type", "function"); + wrapper.set("function", function); + return wrapper; + } + + /** Helper: builds the messages-array tool role entry for chat completions. */ + public static ObjectNode toolResponseMessage(ObjectMapper objectMapper, String toolCallId, + String content) { + ObjectNode message = objectMapper.createObjectNode(); + message.put("role", "tool"); + message.put("tool_call_id", toolCallId); + message.put("content", content); + return message; + } + + /** Helper: attaches tool call requests to an assistant message. */ + public static void attachToolCalls(ObjectMapper objectMapper, ObjectNode assistantMessage, + JsonNode toolCalls) { + ArrayNode calls = assistantMessage.putArray("tool_calls"); + for (JsonNode call : toolCalls) { + calls.add(call.deepCopy()); + } + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/authoring/mcp/McpClient.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/authoring/mcp/McpClient.java new file mode 100644 index 000000000..870c2f2fc --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/authoring/mcp/McpClient.java @@ -0,0 +1,29 @@ +package com.iflytek.skillhub.service.authoring.mcp; + +import java.time.Duration; +import java.util.List; +import java.util.Map; + +/** + * A live connection to one MCP server declared in a runtime binding. Implementations + * speak the MCP JSON-RPC protocol (initialize, tools/list, tools/call) over their + * transport and are closed after use. + */ +public interface McpClient extends AutoCloseable { + + /** Display name from the binding declaration, used in events and findings. */ + String serverName(); + + /** Performs the initialize handshake and a tools/list; returns the offered tools. */ + List listTools(Duration timeout) throws Exception; + + /** + * Executes one tool call and returns its text content. Implementations must + * surface MCP-level errors (isError or JSON-RPC error) as exceptions. + */ + String callTool(String toolName, Map arguments, Duration timeout) + throws Exception; + + @Override + void close(); +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/authoring/mcp/McpClientFactory.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/authoring/mcp/McpClientFactory.java new file mode 100644 index 000000000..8102eda9f --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/authoring/mcp/McpClientFactory.java @@ -0,0 +1,65 @@ +package com.iflytek.skillhub.service.authoring.mcp; + +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.IOException; +import java.net.http.HttpClient; +import java.time.Duration; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.springframework.stereotype.Component; + +/** + * Builds {@link McpClient} instances from the runtime binding's MCP server + * declarations. Transport "http"/"sse" yield an HTTP JSON-RPC client; "stdio" + * spawns the declared command with only the envRefs-named variables from the + * server process environment (the only way credentials reach an MCP server). + */ +@Component +public class McpClientFactory { + + private final ObjectMapper objectMapper; + private final HttpClient httpClient; + + public McpClientFactory(ObjectMapper objectMapper) { + this.objectMapper = objectMapper; + this.httpClient = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(10)) + .build(); + } + + /** @param server declaration map: name, transport, endpoint|command, toolFilters, envRefs */ + public McpClient create(Map server) throws IOException { + String name = String.valueOf(server.get("name")); + String transport = String.valueOf(server.get("transport")); + if ("http".equals(transport) || "sse".equals(transport)) { + return new HttpMcpClient(name, String.valueOf(server.get("endpoint")), + httpClient, objectMapper, Map.of()); + } + if ("stdio".equals(transport)) { + return new StdioMcpClient(name, commandOf(server), environmentOf(server), objectMapper); + } + throw new IllegalArgumentException( + "MCP server '" + name + "' has unsupported transport: " + transport); + } + + /** Splits the command on whitespace; no shell is involved. */ + private static List commandOf(Map server) { + String command = String.valueOf(server.get("command")); + return List.of(command.trim().split("\\s+")); + } + + /** Only envRefs-named variables from the server environment are passed through. */ + private static Map environmentOf(Map server) { + Map environment = new HashMap<>(); + if (server.get("envRefs") instanceof List refs) { + for (Object ref : refs) { + String value = System.getenv(String.valueOf(ref)); + if (value != null) { + environment.put(String.valueOf(ref), value); + } + } + } + return environment; + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/authoring/mcp/McpProbeService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/authoring/mcp/McpProbeService.java new file mode 100644 index 000000000..b3c902d39 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/authoring/mcp/McpProbeService.java @@ -0,0 +1,92 @@ +package com.iflytek.skillhub.service.authoring.mcp; + +import com.iflytek.skillhub.domain.authoring.validation.FindingDraft; +import com.iflytek.skillhub.domain.authoring.validation.ValidationLayer; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; +import org.springframework.stereotype.Component; + +/** + * CONFIG-layer liveness probe for declared MCP servers: connects, completes the + * initialize handshake, lists tools, and reports unreachable servers and + * toolFilters that reference tools the server does not expose. This is what turns + * an MCP declaration from "syntactically valid" into "verified working before + * the behavior layer relies on it". + */ +@Component +public class McpProbeService { + + private static final Set KNOWN_TRANSPORTS = Set.of("http", "sse", "stdio"); + + private final McpClientFactory clientFactory; + + public McpProbeService(McpClientFactory clientFactory) { + this.clientFactory = clientFactory; + } + + /** One server's probe outcome: either the discovered tool names or the failure. */ + public record ServerProbe(String server, boolean connected, List tools, String error) { + static ServerProbe connected(String server, List tools) { + return new ServerProbe(server, true, List.copyOf(tools), null); + } + + static ServerProbe failed(String server, String error) { + return new ServerProbe(server, false, List.of(), error); + } + } + + /** All probes plus the findings the orchestrator should record. */ + public record ProbeReport(List servers, List findings) { + } + + public ProbeReport probe(List> mcpServers, java.time.Duration timeout) { + List probes = new ArrayList<>(); + List findings = new ArrayList<>(); + for (Map server : mcpServers) { + Object name = server.get("name"); + Object transport = server.get("transport"); + if (name == null || transport == null || !KNOWN_TRANSPORTS.contains(transport.toString())) { + continue; // already reported by RuntimeBindingValidator + } + probes.add(probeOne(server, name.toString(), timeout, findings)); + } + return new ProbeReport(List.copyOf(probes), List.copyOf(findings)); + } + + private ServerProbe probeOne(Map server, String name, + java.time.Duration timeout, List findings) { + try (McpClient client = clientFactory.create(server)) { + List tools = client.listTools(timeout); + List toolNames = tools.stream().map(McpTool::name).toList(); + checkToolFilters(server, name, toolNames, findings); + return ServerProbe.connected(name, toolNames); + } catch (Exception exception) { + String message = String.valueOf(exception.getMessage()); + findings.add(FindingDraft.error(ValidationLayer.CONFIG, "MCP_CONNECT_FAILED", + "MCP server '" + name + "' cannot be reached: " + + (message.length() > 300 ? message.substring(0, 300) + "…" : message))); + return ServerProbe.failed(name, message); + } + } + + private void checkToolFilters(Map server, String name, List toolNames, + List findings) { + if (!(server.get("toolFilters") instanceof List filters) || filters.isEmpty()) { + return; + } + Set discovered = Set.copyOf(toolNames); + List unknown = filters.stream() + .map(String::valueOf) + .filter(filter -> !discovered.contains(filter)) + .collect(Collectors.toList()); + if (!unknown.isEmpty()) { + findings.add(FindingDraft.warning(ValidationLayer.CONFIG, "MCP_TOOL_FILTER_UNKNOWN", + "runtime binding", "MCP server '" + name + "' toolFilters reference unknown " + + "tool(s): " + String.join(", ", unknown) + "; discovered tools: " + + (toolNames.isEmpty() ? "(none)" : String.join(", ", toolNames)))); + } + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/authoring/mcp/McpTool.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/authoring/mcp/McpTool.java new file mode 100644 index 000000000..ad6f546eb --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/authoring/mcp/McpTool.java @@ -0,0 +1,14 @@ +package com.iflytek.skillhub.service.authoring.mcp; + +import com.fasterxml.jackson.databind.JsonNode; + +/** + * One tool discovered from an MCP server via {@code tools/list}. + */ +public record McpTool(String name, String description, JsonNode inputSchema) { + + @Override + public String toString() { + return name; + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/authoring/mcp/StdioMcpClient.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/authoring/mcp/StdioMcpClient.java new file mode 100644 index 000000000..4d1fa7080 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/authoring/mcp/StdioMcpClient.java @@ -0,0 +1,170 @@ +package com.iflytek.skillhub.service.authoring.mcp; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import java.io.BufferedReader; +import java.io.BufferedWriter; +import java.io.IOException; +import java.io.InputStreamReader; +import java.io.OutputStreamWriter; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.TimeUnit; + +/** + * MCP client for the stdio transport: the declared command is spawned as a + * subprocess and JSON-RPC messages are exchanged as newline-delimited JSON on + * its stdin/stdout. Only environment variables named in {@code envRefs} are + * passed through; the process is destroyed on close. + */ +public class StdioMcpClient implements McpClient { + + private final String serverName; + private final Process process; + private final BufferedWriter stdin; + private final BufferedReader stdout; + private final ObjectMapper objectMapper; + private int nextId = 1; + + public StdioMcpClient(String serverName, List command, Map environment, + ObjectMapper objectMapper) throws IOException { + this.serverName = serverName; + this.objectMapper = objectMapper; + ProcessBuilder processBuilder = new ProcessBuilder(command); + processBuilder.environment().clear(); + processBuilder.environment().putAll(environment); + processBuilder.redirectErrorStream(false); + this.process = processBuilder.start(); + this.stdin = new BufferedWriter( + new OutputStreamWriter(process.getOutputStream(), StandardCharsets.UTF_8)); + this.stdout = new BufferedReader( + new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8)); + } + + @Override + public String serverName() { + return serverName; + } + + @Override + public List listTools(Duration timeout) throws Exception { + ObjectNode params = objectMapper.createObjectNode(); + params.put("protocolVersion", HttpMcpClient.PROTOCOL_VERSION); + params.set("capabilities", objectMapper.createObjectNode()); + ObjectNode clientInfo = params.putObject("clientInfo"); + clientInfo.put("name", HttpMcpClient.CLIENT_NAME); + clientInfo.put("version", "1.0"); + request("initialize", params, timeout); + notification("notifications/initialized"); + + JsonNode tools = request("tools/list", objectMapper.createObjectNode(), timeout); + List discovered = new ArrayList<>(); + for (JsonNode tool : tools.path("tools")) { + discovered.add(new McpTool( + tool.path("name").asText(""), + tool.path("description").asText(""), + tool.path("inputSchema"))); + } + return discovered; + } + + @Override + public String callTool(String toolName, Map arguments, Duration timeout) + throws Exception { + ObjectNode params = objectMapper.createObjectNode(); + params.put("name", toolName); + params.set("arguments", objectMapper.valueToTree(arguments == null ? Map.of() : arguments)); + JsonNode result = request("tools/call", params, timeout); + if (result.path("isError").asBoolean(false)) { + throw new IOException("MCP tool '" + toolName + "' returned an error result on server '" + + serverName + "'"); + } + StringBuilder content = new StringBuilder(); + for (JsonNode part : result.path("content")) { + if ("text".equals(part.path("type").asText())) { + if (content.length() > 0) { + content.append('\n'); + } + content.append(part.path("text").asText("")); + } + } + return content.toString(); + } + + @Override + public void close() { + process.destroy(); + try { + if (!process.waitFor(3, TimeUnit.SECONDS)) { + process.destroyForcibly(); + } + } catch (InterruptedException ignored) { + process.destroyForcibly(); + Thread.currentThread().interrupt(); + } + } + + private JsonNode request(String method, ObjectNode params, Duration timeout) throws Exception { + ObjectNode request = objectMapper.createObjectNode(); + request.put("jsonrpc", "2.0"); + request.put("id", nextId++); + request.put("method", method); + request.set("params", params); + stdin.write(request.toString()); + stdin.write('\n'); + stdin.flush(); + return readResponse(request.path("id").asInt(), timeout); + } + + private void notification(String method) throws IOException { + ObjectNode notification = objectMapper.createObjectNode(); + notification.put("jsonrpc", "2.0"); + notification.put("method", method); + stdin.write(notification.toString()); + stdin.write('\n'); + stdin.flush(); + } + + /** Reads stdout lines until the response with the matching id arrives. */ + private JsonNode readResponse(int requestId, Duration timeout) throws IOException { + // readLine() blocks, so a silently hanging server needs a watchdog: kill the + // process after the timeout and readLine unblocks with end-of-stream. + Thread watchdog = Thread.ofVirtual().start(() -> { + try { + Thread.sleep(timeout.toMillis()); + process.destroyForcibly(); + } catch (InterruptedException ignored) { + // the response arrived in time + } + }); + try { + long deadline = System.nanoTime() + timeout.toNanos(); + while (System.nanoTime() < deadline) { + String line = stdout.readLine(); + if (line == null) { + throw new IOException("MCP server '" + serverName + "' (stdio) closed its output"); + } + String trimmed = line.trim(); + if (!trimmed.startsWith("{")) { + continue; + } + JsonNode node = objectMapper.readTree(trimmed); + if (node.path("id").asInt(-1) != requestId) { + continue; // notification or log output from the server + } + if (node.has("error")) { + throw new IOException("MCP server '" + serverName + "' error: " + + node.path("error").path("message").asText()); + } + return node.path("result"); + } + throw new IOException("MCP server '" + serverName + "' (stdio) timed out after " + timeout); + } finally { + watchdog.interrupt(); + } + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/task/ValidationRunMaintenanceTask.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/task/ValidationRunMaintenanceTask.java new file mode 100644 index 000000000..f7c687c8f --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/task/ValidationRunMaintenanceTask.java @@ -0,0 +1,117 @@ +package com.iflytek.skillhub.task; + +import com.iflytek.skillhub.config.AuthoringProperties; +import com.iflytek.skillhub.domain.authoring.validation.ValidationRun; +import com.iflytek.skillhub.domain.authoring.validation.ValidationRunStatus; +import com.iflytek.skillhub.domain.authoring.service.ValidationRunService; +import com.iflytek.skillhub.service.authoring.ValidationRunOrchestrator; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.time.Instant; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +/** + * Bounded recovery for validation runs: active runs older than the configured stale + * threshold are settled as TIMED_OUT (covering executor crashes and restarts), and + * orphaned workspace directories are removed best-effort. + */ +@Component +public class ValidationRunMaintenanceTask { + + private static final Logger log = LoggerFactory.getLogger(ValidationRunMaintenanceTask.class); + + private final ValidationRunService runService; + private final ValidationRunOrchestrator orchestrator; + private final AuthoringProperties properties; + + public ValidationRunMaintenanceTask(ValidationRunService runService, + ValidationRunOrchestrator orchestrator, + AuthoringProperties properties) { + this.runService = runService; + this.orchestrator = orchestrator; + this.properties = properties; + } + + @Scheduled(fixedDelayString = "${skillhub.authoring.maintenance-interval-ms:30000}") + public void sweep() { + settleStaleRuns(); + cleanupWorkspaces(); + } + + private void settleStaleRuns() { + Instant cutoff = Instant.now().minus(Duration.ofMinutes(properties.getStaleRunMinutes())); + List stale = runService.findActiveRunsCreatedBefore(cutoff); + for (ValidationRun run : stale) { + log.warn("Settling stale validation run {} (draft {}, status {}) as TIMED_OUT", + run.getId(), run.getDraftId(), run.getStatus()); + orchestrator.settle(run.getId(), ValidationRunStatus.TIMED_OUT, + run.getErrorCount(), run.getWarningCount(), + Map.of("reason", "stale run swept by maintenance task")); + } + } + + /** Removes workspace directories of runs that are no longer active. */ + private void cleanupWorkspaces() { + Path root = Path.of(properties.getWorkspaceRoot()); + if (!Files.isDirectory(root)) { + return; + } + try (var directories = Files.list(root)) { + directories.filter(Files::isDirectory) + .filter(directory -> directory.getFileName().toString().startsWith("run-")) + .forEach(this::cleanupIfOrphaned); + } catch (IOException exception) { + log.warn("Failed to list validation workspaces under {}: {}", root, exception.getMessage()); + } + } + + private void cleanupIfOrphaned(Path directory) { + String fileName = directory.getFileName().toString(); + long runId; + try { + runId = Long.parseLong(fileName.substring("run-".length())); + } catch (NumberFormatException exception) { + return; + } + try { + ValidationRun run = runService.getRun(runId); + if (run.isActive()) { + return; + } + try (var stream = Files.walk(directory)) { + stream.sorted(Comparator.reverseOrder()).forEach(path -> { + try { + Files.deleteIfExists(path); + } catch (IOException ignored) { + // best-effort cleanup + } + }); + } + } catch (Exception exception) { + // unknown run id (workspace of a deleted draft) — remove the directory too + deleteQuietly(directory); + } + } + + private void deleteQuietly(Path directory) { + try (var stream = Files.walk(directory)) { + stream.sorted(Comparator.reverseOrder()).forEach(path -> { + try { + Files.deleteIfExists(path); + } catch (IOException ignored) { + // best-effort cleanup + } + }); + } catch (IOException ignored) { + // best-effort cleanup + } + } +} diff --git a/server/skillhub-app/src/main/resources/application.yml b/server/skillhub-app/src/main/resources/application.yml index 6dfbbd429..28c11959c 100644 --- a/server/skillhub-app/src/main/resources/application.yml +++ b/server/skillhub-app/src/main/resources/application.yml @@ -234,6 +234,37 @@ skillhub: max-package-size: 104857600 # 100MB # allowed-file-extensions: uses SkillPackagePolicy.ALLOWED_EXTENSIONS by default # Override via SKILLHUB_PUBLISH_ALLOWED_FILE_EXTENSIONS env var if needed + 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} + maintenance-interval-ms: ${SKILLHUB_AUTHORING_MAINTENANCE_INTERVAL_MS:30000} + local-script: + enabled: ${SKILLHUB_AUTHORING_LOCAL_SCRIPT_ENABLED:true} + # inline: subprocess on the server host (local dev only). + # docker: script runs in a locked-down container — no network, memory/cpu/pids + # caps, read-only rootfs, all capabilities dropped. Use for production. + execution-mode: ${SKILLHUB_AUTHORING_LOCAL_SCRIPT_MODE:inline} + docker: + # must provide the interpreters your skills use (alpine ships sh only) + image: ${SKILLHUB_AUTHORING_DOCKER_IMAGE:alpine:3.20} + memory: ${SKILLHUB_AUTHORING_DOCKER_MEMORY:256m} + cpus: ${SKILLHUB_AUTHORING_DOCKER_CPUS:1.0} + pids-limit: ${SKILLHUB_AUTHORING_DOCKER_PIDS_LIMIT:128} + tmpfs-size: ${SKILLHUB_AUTHORING_DOCKER_TMPFS_SIZE:64m} + openai-compatible: + # Disabled by default: point it at an OpenAI-compatible endpoint to enable prompt tasks + enabled: ${SKILLHUB_AUTHORING_LLM_ENABLED:false} + api-key: ${SKILLHUB_AUTHORING_LLM_API_KEY:} + default-endpoint: ${SKILLHUB_AUTHORING_LLM_ENDPOINT:} + default-model: ${SKILLHUB_AUTHORING_LLM_MODEL:} + timeout-ms: ${SKILLHUB_AUTHORING_LLM_TIMEOUT_MS:120000} + # rounds of chat-completion (with MCP tool execution between rounds) per prompt task + max-tool-rounds: ${SKILLHUB_AUTHORING_LLM_MAX_TOOL_ROUNDS:4} profile: moderation: machine-review: ${SKILLHUB_PROFILE_MACHINE_REVIEW_ENABLED:true} # Enable machine review (e.g. sensitive word detection) diff --git a/server/skillhub-app/src/main/resources/messages.properties b/server/skillhub-app/src/main/resources/messages.properties index b71fda79c..708dd4a6a 100644 --- a/server/skillhub-app/src/main/resources/messages.properties +++ b/server/skillhub-app/src/main/resources/messages.properties @@ -260,3 +260,44 @@ error.suite.bundle.operation.cancel.notAllowed=This Suite Bundle operation can n error.suite.bundle.operation.retry.notAllowed=Only a retryable blocked Suite Bundle operation can be retried error.suite.bundle.member.stateChanged=The bound Skill or version state changed; create a new Suite Bundle preview error.suite.bundle.actor.inactive=The Suite Bundle actor is no longer active + +# 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.binding.notFound=No runtime binding configured for draft {0} +error.authoring.binding.agentType.unknown=Unknown agent runtime type: {0} +error.authoring.binding.invalid=Invalid runtime binding: {0} +error.authoring.binding.unserializable=Runtime binding could not be serialized +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 +error.authoring.submit.notValidated=Revision {0} has no passing validation run; run validation first +error.authoring.submit.runActive=Draft {0} has an active validation run diff --git a/server/skillhub-app/src/main/resources/messages_ru.properties b/server/skillhub-app/src/main/resources/messages_ru.properties index 6baf1a557..489ffcb4f 100644 --- a/server/skillhub-app/src/main/resources/messages_ru.properties +++ b/server/skillhub-app/src/main/resources/messages_ru.properties @@ -214,3 +214,44 @@ error.suite.bundle.operation.cancel.notAllowed=Эту операцию паке error.suite.bundle.operation.retry.notAllowed=Повторить можно только заблокированную операцию Skill Suite, допускающую повтор error.suite.bundle.member.stateChanged=Состояние связанного Skill или версии изменилось; создайте новый предварительный просмотр пакета Skill Suite error.suite.bundle.actor.inactive=Пользователь операции Skill Suite Bundle больше не активен + +# Платформа создания и проверки навыков +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.binding.notFound=Для черновика {0} не настроена привязка среды выполнения +error.authoring.binding.agentType.unknown=Неизвестный тип среды выполнения агента: {0} +error.authoring.binding.invalid=Недопустимая привязка среды выполнения: {0} +error.authoring.binding.unserializable=Привязка среды выполнения не может быть сериализована +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} +error.authoring.submit.notValidated=У ревизии {0} нет пройденной задачи проверки; сначала выполните проверку +error.authoring.submit.runActive=У черновика {0} есть активная задача проверки diff --git a/server/skillhub-app/src/main/resources/messages_zh.properties b/server/skillhub-app/src/main/resources/messages_zh.properties index 2923cf4ff..158a42dbb 100644 --- a/server/skillhub-app/src/main/resources/messages_zh.properties +++ b/server/skillhub-app/src/main/resources/messages_zh.properties @@ -260,3 +260,44 @@ error.suite.bundle.operation.cancel.notAllowed=该技能套件 Bundle 操作已 error.suite.bundle.operation.retry.notAllowed=只有处于可重试阻塞状态的技能套件 Bundle 操作才能重试 error.suite.bundle.member.stateChanged=绑定的技能或版本状态已经变化,请重新创建技能套件 Bundle 预览 error.suite.bundle.actor.inactive=技能套件 Bundle 的操作者已不再处于可用状态 + +# 技能创作与验证平台 +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.binding.notFound=草稿 {0} 尚未配置运行时绑定 +error.authoring.binding.agentType.unknown=未知的 Agent 运行时类型:{0} +error.authoring.binding.invalid=运行时绑定不合法:{0} +error.authoring.binding.unserializable=运行时绑定无法序列化 +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} 的修复建议无法解析 +error.authoring.submit.notValidated=版本 {0} 没有通过的验证任务,请先执行验证 +error.authoring.submit.runActive=草稿 {0} 有正在进行的验证任务 diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/authoring/adapter/DockerScriptCommandBuilderTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/authoring/adapter/DockerScriptCommandBuilderTest.java new file mode 100644 index 000000000..d99ed4c1c --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/authoring/adapter/DockerScriptCommandBuilderTest.java @@ -0,0 +1,85 @@ +package com.iflytek.skillhub.service.authoring.adapter; + +import com.iflytek.skillhub.config.AuthoringProperties; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Verifies the docker command line: every isolation flag must be present, the + * workspace mounted exactly once, and the script referenced by its + * container-relative path. + */ +class DockerScriptCommandBuilderTest { + + private final AuthoringProperties properties = new AuthoringProperties(); + private final DockerScriptCommandBuilder builder = new DockerScriptCommandBuilder(properties); + + private final Path workspace = Path.of("/tmp/skillhub-authoring/run-42"); + private final Path script = workspace.resolve("scripts/greet.sh"); + + private ScriptCommandBuilder.ScriptExecutionPlan plan() { + return builder.plan(workspace, "sh", script, List.of("arg1", "arg two"), + Map.of("PATH", "/usr/bin:/bin", "HOME", workspace.toString(), "LANG", "C.UTF-8"), + "42-greet"); + } + + @Test + void commandEnforcesIsolationProfile() { + List command = plan().command(); + assertThat(command).containsSubsequence( + List.of("docker", "run", "--rm", "--name", "skillhub-validation-42-greet")); + assertThat(command).containsSubsequence(List.of("--network", "none")); + assertThat(command).containsSubsequence(List.of("--memory", "256m")); + assertThat(command).containsSubsequence(List.of("--cpus", "1.0")); + assertThat(command).containsSubsequence(List.of("--pids-limit", "128")); + assertThat(command).contains("--read-only"); + assertThat(command).containsSubsequence(List.of("--tmpfs", "/tmp:rw,size=64m")); + assertThat(command).containsSubsequence(List.of("--cap-drop", "ALL")); + assertThat(command).containsSubsequence(List.of("--security-opt", "no-new-privileges")); + assertThat(command).containsSubsequence(List.of("--log-driver", "none")); + } + + @Test + void workspaceIsMountedAndScriptUsesContainerPath() { + List command = plan().command(); + assertThat(command).containsSubsequence( + List.of("-v", workspace + ":" + DockerScriptCommandBuilder.CONTAINER_WORKSPACE)); + assertThat(command).containsSubsequence(List.of("-w", DockerScriptCommandBuilder.CONTAINER_WORKSPACE)); + assertThat(command).contains("/workspace/scripts/greet.sh"); + assertThat(command).endsWith("alpine:3.20", "sh", "/workspace/scripts/greet.sh", "arg1", "arg two"); + } + + @Test + void environmentIsPassedViaFlagsWithHomeRemapped() { + List command = plan().command(); + assertThat(command).contains("PATH=/usr/bin:/bin"); + assertThat(command).contains("HOME=" + DockerScriptCommandBuilder.CONTAINER_WORKSPACE); + assertThat(command).contains("LANG=C.UTF-8"); + // the docker CLI keeps its own environment to reach the daemon + assertThat(plan().environment()).isNull(); + } + + @Test + void containerNamesAreDockerSafe() { + assertThat(DockerScriptCommandBuilder.containerName("42-greet")).isEqualTo("skillhub-validation-42-greet"); + assertThat(DockerScriptCommandBuilder.containerName("7-task/with spaces")) + .isEqualTo("skillhub-validation-7-task-with-spaces"); + assertThat(DockerScriptCommandBuilder.containerName("task")) + .isEqualTo("skillhub-validation-task"); + } + + @Test + void dockerLimitsComeFromConfiguration() { + properties.getLocalScript().getDocker().setImage("python:3.12-alpine"); + properties.getLocalScript().getDocker().setMemory("512m"); + properties.getLocalScript().getDocker().setCpus("2.0"); + List command = plan().command(); + assertThat(command).containsSubsequence(List.of("--memory", "512m")); + assertThat(command).containsSubsequence(List.of("--cpus", "2.0")); + assertThat(command).contains("python:3.12-alpine"); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/authoring/adapter/DockerScriptRuntimeAdapterTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/authoring/adapter/DockerScriptRuntimeAdapterTest.java new file mode 100644 index 000000000..b975811b5 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/authoring/adapter/DockerScriptRuntimeAdapterTest.java @@ -0,0 +1,120 @@ +package com.iflytek.skillhub.service.authoring.adapter; + +import com.iflytek.skillhub.config.AuthoringProperties; +import com.iflytek.skillhub.domain.authoring.runtime.RuntimeEventSink; +import com.iflytek.skillhub.domain.authoring.runtime.RuntimeExecutionContext; +import com.iflytek.skillhub.domain.authoring.runtime.TaskResult; +import com.iflytek.skillhub.domain.authoring.spec.ValidationTaskSpec; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Executes real behavior tasks through the docker backend and proves the + * isolation profile inside the container: no network interfaces beyond loopback, + * read-only root filesystem, and the workspace mounted writable. Skipped + * silently when no docker daemon is reachable. + */ +class DockerScriptRuntimeAdapterTest { + + @TempDir + Path workspace; + + private final AuthoringProperties properties = new AuthoringProperties(); + + @BeforeAll + static void requireDockerWithImage() { + DockerScriptCommandBuilder probe = new DockerScriptCommandBuilder(new AuthoringProperties()); + Assumptions.assumeTrue(probe.available(), "docker daemon not reachable"); + try { + Process pull = new ProcessBuilder("docker", "image", "inspect", "alpine:3.20").start(); + if (pull.waitFor() != 0) { + new ProcessBuilder("docker", "pull", "alpine:3.20").start().waitFor(); + } + } catch (Exception exception) { + Assumptions.assumeTrue(false, "alpine image unavailable: " + exception.getMessage()); + } + } + + private LocalScriptRuntimeAdapter dockerAdapter() { + properties.getLocalScript().setExecutionMode(AuthoringProperties.ScriptExecutionMode.DOCKER); + return new LocalScriptRuntimeAdapter(properties, List.of( + new InlineScriptCommandBuilder(), new DockerScriptCommandBuilder(properties))); + } + + private record RecordingSink(List logLines) implements RuntimeEventSink { + RecordingSink() { + this(new ArrayList<>()); + } + + @Override + public void agentMessage(String taskName, String content) { + } + + @Override + public void toolCall(String taskName, String tool, String argumentsJson) { + } + + @Override + public void toolResult(String taskName, String tool, String summaryJson) { + } + + @Override + public void log(String taskName, String stream, String line) { + logLines.add(line); + } + } + + private TaskResult runScript(String script) throws Exception { + Files.createDirectories(workspace.resolve("scripts")); + Files.writeString(workspace.resolve("scripts/task.sh"), script); + ValidationTaskSpec task = new ValidationTaskSpec("probe", "isolation probe", + com.iflytek.skillhub.domain.authoring.spec.TaskType.SCRIPT, + "scripts/task.sh", List.of(), null, 60_000, List.of()); + RuntimeExecutionContext context = new RuntimeExecutionContext( + 99L, workspace, Map.of("interpreter", "sh"), List.of(), () -> false); + return dockerAdapter().execute(context, task, new RecordingSink()); + } + + @Test + void runsScriptAndCapturesOutput() throws Exception { + TaskResult result = runScript("echo hello from docker\n"); + assertThat(result.exitCode()).isZero(); + assertThat(result.stdout()).contains("hello from docker"); + } + + @Test + void containerHasNoNetworkRoutes() throws Exception { + // with --network none the routing table is empty (header line only), + // so nothing beyond loopback is reachable + TaskResult result = runScript("tail -n +2 /proc/net/route | wc -l | tr -d ' '\n"); + assertThat(result.exitCode()).isZero(); + assertThat(result.stdout().trim()).isEqualTo("0"); + } + + @Test + void rootFilesystemIsReadOnly() throws Exception { + TaskResult result = runScript( + "if touch /etc/forbidden 2>/dev/null; then echo writable; else echo readonly; fi\n"); + assertThat(result.exitCode()).isZero(); + assertThat(result.stdout()).contains("readonly").doesNotContain("writable"); + } + + @Test + void workspaceIsWritableForArtifacts() throws Exception { + TaskResult result = runScript("mkdir -p artifacts && echo data > artifacts/out.txt && cat artifacts/out.txt\n"); + assertThat(result.exitCode()).isZero(); + assertThat(result.stdout()).contains("data"); + assertThat(result.artifacts().read("artifacts/out.txt")).isPresent(); + assertThat(new String(result.artifacts().read("artifacts/out.txt").orElseThrow())) + .contains("data"); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/authoring/adapter/OpenAiCompatibleRuntimeAdapterToolLoopTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/authoring/adapter/OpenAiCompatibleRuntimeAdapterToolLoopTest.java new file mode 100644 index 000000000..3b3350974 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/authoring/adapter/OpenAiCompatibleRuntimeAdapterToolLoopTest.java @@ -0,0 +1,278 @@ +package com.iflytek.skillhub.service.authoring.adapter; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.iflytek.skillhub.config.AuthoringProperties; +import com.iflytek.skillhub.domain.authoring.runtime.RuntimeEventSink; +import com.iflytek.skillhub.domain.authoring.runtime.RuntimeExecutionContext; +import com.iflytek.skillhub.domain.authoring.runtime.TaskResult; +import com.iflytek.skillhub.domain.authoring.spec.TaskType; +import com.iflytek.skillhub.domain.authoring.spec.ValidationTaskSpec; +import com.iflytek.skillhub.service.authoring.mcp.McpClientFactory; +import com.iflytek.skillhub.service.authoring.mcp.TestingMcpHttpServer; +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpServer; +import java.io.IOException; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CopyOnWriteArrayList; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Drives the full prompt-task agent loop against a fake OpenAI-compatible + * endpoint and a fake MCP server: tools are discovered over MCP, exposed to the + * model, executed for real when called, and every step lands in the trace sink. + * Also pins the toolFilters / toolAllowlist restrictions on what gets exposed. + */ +class OpenAiCompatibleRuntimeAdapterToolLoopTest { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + @TempDir + Path workspace; + + private TestingMcpHttpServer mcpServer; + private FakeOpenAiEndpoint openAi; + private final List trace = new ArrayList<>(); + private OpenAiCompatibleRuntimeAdapter adapter; + private AuthoringProperties properties; + + private record TraceEvent(String kind, String tool, String payload) { + } + + /** Records toolCall/toolResult/agentMessage/log in order. */ + private final RuntimeEventSink sink = new RuntimeEventSink() { + @Override + public void agentMessage(String taskName, String content) { + trace.add(new TraceEvent("agentMessage", null, content)); + } + + @Override + public void toolCall(String taskName, String tool, String argumentsJson) { + trace.add(new TraceEvent("toolCall", tool, argumentsJson)); + } + + @Override + public void toolResult(String taskName, String tool, String summaryJson) { + trace.add(new TraceEvent("toolResult", tool, summaryJson)); + } + + @Override + public void log(String taskName, String stream, String line) { + trace.add(new TraceEvent("log", stream, line)); + } + }; + + @BeforeEach + void start() throws Exception { + mcpServer = new TestingMcpHttpServer(List.of("get_forecast", "lookup_city")); + openAi = new FakeOpenAiEndpoint(); + properties = new AuthoringProperties(); + properties.getOpenAiCompatible().setEnabled(true); + adapter = new OpenAiCompatibleRuntimeAdapter(properties, MAPPER, new McpClientFactory(MAPPER)); + Files.writeString(workspace.resolve("SKILL.md"), "---\nname: test\n---\nUse the weather tools.\n"); + trace.clear(); + } + + @AfterEach + void stop() { + if (mcpServer != null) { + mcpServer.close(); + } + if (openAi != null) { + openAi.close(); + } + } + + private Map weatherServer() { + return Map.of("name", "weather", "transport", "http", "endpoint", mcpServer.endpoint()); + } + + private TaskResult run(List> mcpServers, List allowlist) throws Exception { + ValidationTaskSpec task = new ValidationTaskSpec("ask", "asks the weather", + TaskType.PROMPT, null, List.of(), "What is the weather in Paris?", 30_000, List.of()); + RuntimeExecutionContext context = new RuntimeExecutionContext(99L, workspace, + Map.of("endpoint", openAi.baseUrl() + "/", "model", "test-model"), + allowlist, mcpServers, () -> false); + return adapter.execute(context, task, sink); + } + + private static ObjectNode assistantMessage(String content) { + ObjectNode message = MAPPER.createObjectNode(); + message.put("role", "assistant"); + message.put("content", content); + return message; + } + + private static ObjectNode toolCallMessage(String functionName, String arguments) { + ObjectNode message = MAPPER.createObjectNode(); + message.put("role", "assistant"); + message.put("content", ""); + ObjectNode call = message.putArray("tool_calls").addObject(); + call.put("id", "call-1"); + call.put("type", "function"); + call.putObject("function").put("name", functionName).put("arguments", arguments); + return message; + } + + @Test + void executesMcpToolAndTracesEveryStep() throws Exception { + openAi.script(List.of( + toolCallMessage("weather__get_forecast", "{\"city\":\"Paris\"}"), + assistantMessage("The forecast for Paris is sunny."))); + + TaskResult result = run(List.of(weatherServer()), List.of()); + + assertThat(result.response()).isEqualTo("The forecast for Paris is sunny."); + assertThat(result.toolCallCount()).isEqualTo(1); + + // the real MCP server saw the call with the model's arguments + assertThat(mcpServer.toolCalls).hasSize(1); + assertThat(mcpServer.toolCalls.get(0).getKey()).isEqualTo("get_forecast"); + assertThat(mcpServer.toolCalls.get(0).getValue().path("city").asText()).isEqualTo("Paris"); + + // two chat rounds; the second carries the tool reply back to the model + assertThat(openAi.requests).hasSize(2); + assertThat(exposedFunctionNames(0)).containsExactlyInAnyOrder( + "weather__get_forecast", "weather__lookup_city"); + JsonNode secondMessages = openAi.requests.get(1).path("messages"); + assertThat(secondMessages).anySatisfy(message -> { + assertThat(message.path("role").asText()).isEqualTo("tool"); + assertThat(message.path("tool_call_id").asText()).isEqualTo("call-1"); + assertThat(message.path("content").asText()).contains("result of get_forecast"); + }); + + // the trace shows both chat rounds, the tool invocation, and the result + assertThat(trace).extracting(TraceEvent::kind) + .containsExactly("toolCall", "toolCall", "toolResult", "toolCall", "agentMessage"); + assertThat(trace.get(1).tool()).isEqualTo("mcp:weather/get_forecast"); + assertThat(trace.get(1).payload()).contains("Paris"); + assertThat(trace.get(2).tool()).isEqualTo("mcp:weather/get_forecast"); + assertThat(trace.get(2).payload()).contains("\"ok\":true"); + } + + @Test + void toolAllowlistRestrictsExposedTools() throws Exception { + openAi.script(List.of(assistantMessage("I only have the lookup tool."))); + + run(List.of(weatherServer()), List.of("lookup_city")); + + assertThat(openAi.requests).hasSize(1); + assertThat(exposedFunctionNames(0)).containsExactly("weather__lookup_city"); + } + + @Test + void serverToolFiltersRestrictExposedTools() throws Exception { + openAi.script(List.of(assistantMessage("done"))); + + Map filtered = new java.util.HashMap<>(weatherServer()); + filtered.put("toolFilters", List.of("get_forecast")); + run(List.of(filtered), List.of()); + + assertThat(exposedFunctionNames(0)).containsExactly("weather__get_forecast"); + } + + @Test + void withoutMcpServersNoToolsAreSentAndASingleRoundRuns() throws Exception { + openAi.script(List.of(assistantMessage("plain answer"))); + + TaskResult result = run(List.of(), List.of()); + + assertThat(result.response()).isEqualTo("plain answer"); + assertThat(result.toolCallCount()).isZero(); + assertThat(openAi.requests).hasSize(1); + assertThat(openAi.requests.get(0).has("tools")).isFalse(); + } + + @Test + void roundLimitCapsTheToolLoop() throws Exception { + properties.getOpenAiCompatible().setMaxToolRounds(2); + openAi.script(List.of( + toolCallMessage("weather__get_forecast", "{\"city\":\"Paris\"}"), + toolCallMessage("weather__get_forecast", "{\"city\":\"Rome\"}"), + toolCallMessage("weather__get_forecast", "{\"city\":\"Oslo\"}"))); + + TaskResult result = run(List.of(weatherServer()), List.of()); + + assertThat(result.toolCallCount()).isEqualTo(2); + assertThat(openAi.requests).hasSize(2); + assertThat(trace).anySatisfy(event -> { + assertThat(event.kind()).isEqualTo("log"); + assertThat(event.payload()).contains("round limit"); + }); + } + + private List exposedFunctionNames(int requestIndex) { + List names = new ArrayList<>(); + for (JsonNode tool : openAi.requests.get(requestIndex).path("tools")) { + names.add(tool.path("function").path("name").asText()); + } + return names; + } + + // ---------------------------------------------------------------- fake endpoint + + /** Serves scripted chat-completion responses and records every request body. */ + private static final class FakeOpenAiEndpoint implements AutoCloseable { + + private final HttpServer server; + private final List requests = new CopyOnWriteArrayList<>(); + private final List scriptedResponses = new CopyOnWriteArrayList<>(); + + FakeOpenAiEndpoint() throws IOException { + server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/", this::handle); + server.start(); + } + + String baseUrl() { + return "http://127.0.0.1:" + server.getAddress().getPort() + "/v1"; + } + + void script(List assistantMessages) { + scriptedResponses.addAll(assistantMessages); + } + + private void handle(HttpExchange exchange) throws IOException { + try { + String body = new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8); + requests.add((ObjectNode) MAPPER.readTree(body)); + ObjectNode message = scriptedResponses.isEmpty() + ? assistantMessage("no script left") : scriptedResponses.remove(0); + ObjectNode choice = MAPPER.createObjectNode(); + choice.put("index", 0); + choice.set("message", message); + choice.put("finish_reason", "stop"); + ObjectNode response = MAPPER.createObjectNode(); + response.put("id", "chatcmpl-test"); + response.putArray("choices").add(choice); + byte[] bytes = response.toString().getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().set("Content-Type", "application/json"); + exchange.sendResponseHeaders(200, bytes.length); + try (OutputStream output = exchange.getResponseBody()) { + output.write(bytes); + } + } finally { + exchange.close(); + } + } + + @Override + public void close() { + server.stop(0); + } + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/authoring/mcp/HttpMcpClientTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/authoring/mcp/HttpMcpClientTest.java new file mode 100644 index 000000000..39ea9e65c --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/authoring/mcp/HttpMcpClientTest.java @@ -0,0 +1,101 @@ +package com.iflytek.skillhub.service.authoring.mcp; + +import com.fasterxml.jackson.databind.ObjectMapper; +import java.net.http.HttpClient; +import java.time.Duration; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Exercises HttpMcpClient against a real local HTTP server implementing the MCP + * streamable-HTTP protocol, including the session-id contract and SSE-framed + * responses. + */ +class HttpMcpClientTest { + + private final ObjectMapper objectMapper = new ObjectMapper(); + private final HttpClient httpClient = HttpClient.newHttpClient(); + + private TestingMcpHttpServer mcpServer; + + @BeforeEach + void startServer() throws Exception { + mcpServer = new TestingMcpHttpServer(List.of("get_forecast", "lookup_city")); + } + + @AfterEach + void stopServer() { + if (mcpServer != null) { + mcpServer.close(); + } + } + + private HttpMcpClient client() { + return new HttpMcpClient("weather", mcpServer.endpoint(), httpClient, objectMapper, Map.of()); + } + + @Test + void initializesListsToolsAndCallsTools() throws Exception { + try (HttpMcpClient client = client()) { + List tools = client.listTools(Duration.ofSeconds(5)); + assertThat(tools).extracting(McpTool::name) + .containsExactly("get_forecast", "lookup_city"); + assertThat(tools.get(0).description()).isEqualTo("test tool get_forecast"); + + String output = client.callTool("get_forecast", + Map.of("city", "Paris"), Duration.ofSeconds(5)); + assertThat(output).isEqualTo("result of get_forecast: {\"city\":\"Paris\"}"); + assertThat(mcpServer.toolCalls).hasSize(1); + } + assertThat(mcpServer.initializeCount.get()).isEqualTo(1); + } + + @Test + void sessionHeaderIsReusedAfterInitialize() throws Exception { + // the fake server answers tools/list with 400 unless the Mcp-Session-Id + // captured during initialize is replayed — so a successful listing proves it + try (HttpMcpClient client = client()) { + assertThat(client.listTools(Duration.ofSeconds(5))).hasSize(2); + } + assertThat(mcpServer.sawRequestWithoutSessionId) + .as("tools/list must carry the session id from initialize") + .isFalse(); + } + + @Test + void parsesSseFramedResponses() throws Exception { + try (TestingMcpHttpServer sseServer = + new TestingMcpHttpServer(List.of("echo"), true); + HttpMcpClient client = new HttpMcpClient("sse", sseServer.endpoint(), + httpClient, objectMapper, Map.of())) { + List tools = client.listTools(Duration.ofSeconds(5)); + assertThat(tools).extracting(McpTool::name).containsExactly("echo"); + } + } + + @Test + void toolErrorResultBecomesException() throws Exception { + try (HttpMcpClient client = client()) { + client.listTools(Duration.ofSeconds(5)); + assertThatThrownBy(() -> client.callTool("boom", Map.of(), Duration.ofSeconds(5))) + .isInstanceOf(java.io.IOException.class) + .hasMessageContaining("boom"); + } + } + + @Test + void unreachableServerFailsFastWithClearMessage() { + try (HttpMcpClient client = new HttpMcpClient("dead", + "http://127.0.0.1:9/mcp", httpClient, objectMapper, Map.of())) { + assertThatThrownBy(() -> client.listTools(Duration.ofSeconds(5))) + .isInstanceOf(Exception.class) + .hasMessageContaining("dead"); + } + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/authoring/mcp/McpProbeServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/authoring/mcp/McpProbeServiceTest.java new file mode 100644 index 000000000..c7046ccfc --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/authoring/mcp/McpProbeServiceTest.java @@ -0,0 +1,95 @@ +package com.iflytek.skillhub.service.authoring.mcp; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.iflytek.skillhub.domain.authoring.validation.FindingDraft; +import com.iflytek.skillhub.domain.authoring.validation.FindingSeverity; +import java.time.Duration; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * McpProbeService turns MCP declarations into verified facts: reachable servers + * with their discovered tools, and findings for unreachable servers or filters + * naming tools the server does not expose. + */ +class McpProbeServiceTest { + + private final McpProbeService probeService = + new McpProbeService(new McpClientFactory(new ObjectMapper())); + + private TestingMcpHttpServer mcpServer; + + @BeforeEach + void startServer() throws Exception { + mcpServer = new TestingMcpHttpServer(List.of("get_forecast", "lookup_city")); + } + + @AfterEach + void stopServer() { + if (mcpServer != null) { + mcpServer.close(); + } + } + + private Map server(Map overrides) { + Map server = new java.util.HashMap<>(); + server.put("name", "weather"); + server.put("transport", "http"); + server.put("endpoint", mcpServer.endpoint()); + server.putAll(overrides); + return server; + } + + @Test + void reachableServerReportsDiscoveredToolsWithoutFindings() { + McpProbeService.ProbeReport report = probeService.probe( + List.of(server(Map.of())), Duration.ofSeconds(5)); + assertThat(report.servers()).hasSize(1); + assertThat(report.servers().get(0).connected()).isTrue(); + assertThat(report.servers().get(0).tools()) + .containsExactly("get_forecast", "lookup_city"); + assertThat(report.findings()).isEmpty(); + } + + @Test + void unreachableServerProducesConnectFailedError() { + Map dead = Map.of( + "name", "dead", "transport", "http", "endpoint", "http://127.0.0.1:9/mcp"); + McpProbeService.ProbeReport report = probeService.probe(List.of(dead), Duration.ofSeconds(5)); + assertThat(report.servers().get(0).connected()).isFalse(); + assertThat(report.findings()).hasSize(1); + FindingDraft finding = report.findings().get(0); + assertThat(finding.ruleCode()).isEqualTo("MCP_CONNECT_FAILED"); + assertThat(finding.severity()).isEqualTo(FindingSeverity.ERROR); + assertThat(finding.message()).contains("dead"); + } + + @Test + void unknownToolFilterProducesWarningButStaysConnected() { + McpProbeService.ProbeReport report = probeService.probe( + List.of(server(Map.of("toolFilters", List.of("get_forecast", "does_not_exist")))), + Duration.ofSeconds(5)); + assertThat(report.servers().get(0).connected()).isTrue(); + assertThat(report.findings()).hasSize(1); + FindingDraft finding = report.findings().get(0); + assertThat(finding.ruleCode()).isEqualTo("MCP_TOOL_FILTER_UNKNOWN"); + assertThat(finding.severity()).isEqualTo(FindingSeverity.WARNING); + assertThat(finding.message()).contains("does_not_exist").contains("get_forecast"); + } + + @Test + void malformedDeclarationsAreSkippedNotProbed() { + // name/transport problems are already reported by RuntimeBindingValidator; + // the probe must not duplicate them or crash + McpProbeService.ProbeReport report = probeService.probe( + List.of(Map.of("name", "weird", "transport", "carrier-pigeon")), + Duration.ofSeconds(5)); + assertThat(report.servers()).isEmpty(); + assertThat(report.findings()).isEmpty(); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/authoring/mcp/StdioMcpClientTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/authoring/mcp/StdioMcpClientTest.java new file mode 100644 index 000000000..cff36de72 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/authoring/mcp/StdioMcpClientTest.java @@ -0,0 +1,89 @@ +package com.iflytek.skillhub.service.authoring.mcp; + +import com.fasterxml.jackson.databind.ObjectMapper; +import java.time.Duration; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Runs StdioMcpClient against a python3 process implementing the stdio transport + * (newline-delimited JSON-RPC on stdin/stdout). Skipped when python3 is absent. + */ +class StdioMcpClientTest { + + private static final String FAKE_SERVER = """ + import json, sys + def send(obj): + sys.stdout.write(json.dumps(obj) + "\\n") + sys.stdout.flush() + for line in sys.stdin: + line = line.strip() + if not line: + continue + msg = json.loads(line) + if 'id' not in msg: + continue + method = msg.get('method') + if method == 'initialize': + send({'jsonrpc': '2.0', 'id': msg['id'], 'result': { + 'protocolVersion': '2024-11-05', 'capabilities': {}, + 'serverInfo': {'name': 'fake', 'version': '1.0'}}}) + elif method == 'tools/list': + send({'jsonrpc': '2.0', 'id': msg['id'], 'result': {'tools': [ + {'name': 'echo', 'description': 'echo text', + 'inputSchema': {'type': 'object'}}]}}) + elif method == 'tools/call': + args = msg['params']['arguments'] + send({'jsonrpc': '2.0', 'id': msg['id'], 'result': {'content': [ + {'type': 'text', 'text': 'echo:' + args.get('text', '')}]}}) + """; + + @BeforeAll + static void requirePython3() { + try { + int exit = new ProcessBuilder("python3", "--version").start().waitFor(); + Assumptions.assumeTrue(exit == 0, "python3 unavailable"); + } catch (Exception exception) { + Assumptions.assumeTrue(false, "python3 unavailable: " + exception.getMessage()); + } + } + + private StdioMcpClient client() throws Exception { + return new StdioMcpClient("echo-server", List.of("python3", "-c", FAKE_SERVER), + Map.of(), new ObjectMapper()); + } + + @Test + void listsToolsAndCallsThemOverStdio() throws Exception { + try (StdioMcpClient client = client()) { + List tools = client.listTools(Duration.ofSeconds(10)); + assertThat(tools).extracting(McpTool::name).containsExactly("echo"); + + String output = client.callTool("echo", Map.of("text", "hello"), Duration.ofSeconds(10)); + assertThat(output).isEqualTo("echo:hello"); + } + } + + @Test + void processIsTerminatedOnClose() throws Exception { + StdioMcpClient client = client(); + client.listTools(Duration.ofSeconds(10)); + client.close(); + // a second close must not hang or throw; the process is already gone + client.close(); + } + + @Test + void failingCommandSurfacesAsException() { + assertThatThrownBy(() -> new StdioMcpClient("broken", + List.of("definitely-not-a-real-command-xyz"), Map.of(), new ObjectMapper()) + .listTools(Duration.ofSeconds(10))) + .isInstanceOf(Exception.class); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/authoring/mcp/TestingMcpHttpServer.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/authoring/mcp/TestingMcpHttpServer.java new file mode 100644 index 000000000..c2f9678aa --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/authoring/mcp/TestingMcpHttpServer.java @@ -0,0 +1,156 @@ +package com.iflytek.skillhub.service.authoring.mcp; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpServer; +import java.io.IOException; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * Minimal MCP streamable-HTTP server for tests. Implements the initialize / + * notifications/initialized / tools/list / tools/call exchange the real client + * performs, can answer in plain JSON or SSE framing, enforces the session id on + * post-initialize requests, and records every tools/call it received. The tool + * named "boom" always answers with an error result. + */ +public class TestingMcpHttpServer implements AutoCloseable { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private final HttpServer server; + private final List toolNames; + private final boolean sseMode; + + /** Every tools/call: tool name -> argument map, in arrival order. */ + public final List> toolCalls = new CopyOnWriteArrayList<>(); + /** Whether a post-initialize request arrived without the session id header. */ + public volatile boolean sawRequestWithoutSessionId; + /** How many initialize requests were handled. */ + public final AtomicInteger initializeCount = new AtomicInteger(); + + public TestingMcpHttpServer(List toolNames) throws IOException { + this(toolNames, false); + } + + public TestingMcpHttpServer(List toolNames, boolean sseMode) throws IOException { + this.toolNames = List.copyOf(toolNames); + this.sseMode = sseMode; + server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/", this::handle); + server.start(); + } + + public String endpoint() { + return "http://127.0.0.1:" + server.getAddress().getPort() + "/mcp"; + } + + private void handle(HttpExchange exchange) throws IOException { + try { + String body = new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8); + JsonNode request = MAPPER.readTree(body); + String method = request.path("method").asText(""); + if ("initialize".equals(method)) { + initializeCount.incrementAndGet(); + respond(exchange, result(exchange, request, initializeResult()), "test-session-1"); + } else if ("notifications/initialized".equals(method)) { + exchange.sendResponseHeaders(202, -1); + } else if ("tools/list".equals(method)) { + if (exchange.getRequestHeaders().getFirst("Mcp-Session-Id") == null) { + sawRequestWithoutSessionId = true; + exchange.sendResponseHeaders(400, -1); + return; + } + respond(exchange, result(exchange, request, toolsListResult()), null); + } else if ("tools/call".equals(method)) { + if (exchange.getRequestHeaders().getFirst("Mcp-Session-Id") == null) { + sawRequestWithoutSessionId = true; + exchange.sendResponseHeaders(400, -1); + return; + } + String tool = request.path("params").path("name").asText(); + toolCalls.add(Map.entry(tool, request.path("params").path("arguments"))); + respond(exchange, result(exchange, request, toolCallResult(tool, + request.path("params").path("arguments"))), null); + } else { + exchange.sendResponseHeaders(404, -1); + } + } finally { + exchange.close(); + } + } + + private ObjectNode initializeResult() { + ObjectNode result = MAPPER.createObjectNode(); + result.put("protocolVersion", "2024-11-05"); + result.set("capabilities", MAPPER.createObjectNode()); + result.putObject("serverInfo").put("name", "testing-mcp").put("version", "1.0"); + return result; + } + + private ObjectNode toolsListResult() { + ObjectNode result = MAPPER.createObjectNode(); + ArrayNode tools = result.putArray("tools"); + for (String name : toolNames) { + ObjectNode tool = tools.addObject(); + tool.put("name", name); + tool.put("description", "test tool " + name); + tool.putObject("inputSchema").put("type", "object"); + } + return result; + } + + private ObjectNode toolCallResult(String tool, JsonNode arguments) { + ObjectNode result = MAPPER.createObjectNode(); + if ("boom".equals(tool)) { + result.put("isError", true); + result.putArray("content").addObject().put("type", "text").put("text", "tool exploded"); + return result; + } + result.putArray("content").addObject().put("type", "text") + .put("text", "result of " + tool + ": " + arguments); + return result; + } + + private ObjectNode result(HttpExchange exchange, JsonNode request, ObjectNode payload) { + ObjectNode response = MAPPER.createObjectNode(); + response.put("jsonrpc", "2.0"); + response.put("id", request.path("id").asInt()); + response.set("result", payload); + return response; + } + + private void respond(HttpExchange exchange, ObjectNode response, String sessionId) + throws IOException { + byte[] bytes; + String contentType; + if (sseMode) { + bytes = ("event: message\ndata: " + response + "\n\n").getBytes(StandardCharsets.UTF_8); + contentType = "text/event-stream"; + } else { + bytes = response.toString().getBytes(StandardCharsets.UTF_8); + contentType = "application/json"; + } + if (sessionId != null) { + exchange.getResponseHeaders().set("Mcp-Session-Id", sessionId); + } + exchange.getResponseHeaders().set("Content-Type", contentType); + exchange.sendResponseHeaders(200, bytes.length); + try (OutputStream output = exchange.getResponseBody()) { + output.write(bytes); + } + } + + @Override + public void close() { + server.stop(0); + } +} From 7d33180d8ea8e909621b4ca167d6d0804b4d1317 Mon Sep 17 00:00:00 2001 From: zjncs <18910855655@163.com> Date: Sun, 20 Sep 2026 02:35:54 +0800 Subject: [PATCH 03/19] feat(authoring): expose draft authoring REST API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Endpoints for the authoring workbench: draft CRUD and file management (text + binary upload, content-addressed storage), runtime binding, validation run lifecycle with SSE event streaming and polling fallback, findings with fix application and dismissal, and validated draft submission into the publish pipeline. The integration test runs the full create → edit → bind → validate → fix → revalidate → submit loop on real PostgreSQL, including the dead-MCP-server regression. Signed-off-by: zjncs <18910855655@163.com> --- .../authoring/SkillAuthoringController.java | 325 ++++++++++++++++ .../authoring/ValidationStreamController.java | 68 ++++ .../skillhub/dto/CreateDraftRequest.java | 11 + .../dto/DraftFileContentResponse.java | 11 + .../skillhub/dto/DraftFileResponse.java | 25 ++ .../iflytek/skillhub/dto/DraftResponse.java | 41 ++ .../skillhub/dto/RuntimeBindingRequest.java | 18 + .../skillhub/dto/RuntimeBindingResponse.java | 15 + .../skillhub/dto/SaveDraftFileRequest.java | 21 ++ .../skillhub/dto/SaveDraftFileResponse.java | 20 + .../skillhub/dto/SubmitDraftRequest.java | 10 + .../skillhub/dto/SubmitDraftResponse.java | 17 + .../skillhub/dto/ValidationEventResponse.java | 28 ++ .../dto/ValidationFindingResponse.java | 41 ++ .../skillhub/dto/ValidationRunResponse.java | 46 +++ .../AuthoringFlowIntegrationTest.java | 351 ++++++++++++++++++ 16 files changed, 1048 insertions(+) create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/authoring/SkillAuthoringController.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/authoring/ValidationStreamController.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/CreateDraftRequest.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/DraftFileContentResponse.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/DraftFileResponse.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/DraftResponse.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/RuntimeBindingRequest.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/RuntimeBindingResponse.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SaveDraftFileRequest.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SaveDraftFileResponse.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SubmitDraftRequest.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SubmitDraftResponse.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/ValidationEventResponse.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/ValidationFindingResponse.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/ValidationRunResponse.java create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/authoring/AuthoringFlowIntegrationTest.java diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/authoring/SkillAuthoringController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/authoring/SkillAuthoringController.java new file mode 100644 index 000000000..737e8765c --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/authoring/SkillAuthoringController.java @@ -0,0 +1,325 @@ +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.DraftSubmitService; +import com.iflytek.skillhub.domain.authoring.service.FindingFixService; +import com.iflytek.skillhub.domain.authoring.service.RuntimeBindingService; +import com.iflytek.skillhub.domain.authoring.service.SkillDraftService; +import com.iflytek.skillhub.domain.authoring.service.ValidationRunService; +import com.iflytek.skillhub.domain.authoring.validation.ValidationEvent; +import com.iflytek.skillhub.domain.authoring.validation.ValidationFinding; +import com.iflytek.skillhub.domain.authoring.validation.ValidationRun; +import com.iflytek.skillhub.domain.skill.SkillVisibility; +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.RuntimeBindingRequest; +import com.iflytek.skillhub.dto.RuntimeBindingResponse; +import com.iflytek.skillhub.dto.SaveDraftFileRequest; +import com.iflytek.skillhub.dto.SaveDraftFileResponse; +import com.iflytek.skillhub.dto.SubmitDraftRequest; +import com.iflytek.skillhub.dto.SubmitDraftResponse; +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 io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.List; +import java.util.Map; +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.RequestBody; +import org.springframework.web.bind.annotation.RequestAttribute; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +/** + * Skill authoring workbench API: draft CRUD and file editing, runtime binding, + * validation run lifecycle (start, cancel, events, findings), fix application, + * and submission of validated drafts into the publish pipeline. + */ +@Tag(name = "Skill authoring") +@RestController +@RequestMapping({"/api/v1/authoring", "/api/web/authoring"}) +public class SkillAuthoringController extends BaseApiController { + + private final SkillDraftService draftService; + private final RuntimeBindingService bindingService; + private final ValidationRunService runService; + private final DraftSubmitService submitService; + private final FindingFixService fixService; + private final ValidationRunOrchestrator orchestrator; + + public SkillAuthoringController(com.iflytek.skillhub.dto.ApiResponseFactory responseFactory, + SkillDraftService draftService, + RuntimeBindingService bindingService, + ValidationRunService runService, + DraftSubmitService submitService, + FindingFixService fixService, + ValidationRunOrchestrator orchestrator) { + super(responseFactory); + this.draftService = draftService; + this.bindingService = bindingService; + this.runService = runService; + this.submitService = submitService; + this.fixService = fixService; + this.orchestrator = orchestrator; + } + + // ---------------------------------------------------------------- drafts + + @Operation(operationId = "createAuthoringDraft", summary = "Create a draft seeded with a SKILL.md scaffold") + @PostMapping("/drafts") + public ApiResponse createDraft(@Valid @RequestBody CreateDraftRequest request, + @RequestAttribute("userId") String userId, + @RequestAttribute(value = "platformRoles", required = false) + Set platformRoles) { + SkillDraft draft = draftService.createDraft( + request.namespaceSlug(), userId, request.name(), request.requirement(), platformRoles); + return ok("response.success.created", DraftResponse.from(draft)); + } + + @Operation(operationId = "listAuthoringDrafts", summary = "List the current user's drafts") + @GetMapping("/drafts") + public ApiResponse> listDrafts(@RequestAttribute("userId") String userId) { + return ok("response.success", draftService.listDrafts(userId).stream() + .map(DraftResponse::from) + .toList()); + } + + @Operation(operationId = "getAuthoringDraft", summary = "Get one draft owned by the current user") + @GetMapping("/drafts/{draftId}") + public ApiResponse getDraft(@PathVariable Long draftId, + @RequestAttribute("userId") String userId, + @RequestAttribute(value = "platformRoles", required = false) + Set platformRoles) { + return ok("response.success", + DraftResponse.from(draftService.getOwnedDraft(draftId, userId, platformRoles))); + } + + @Operation(operationId = "deleteAuthoringDraft", summary = "Delete a draft and its files") + @DeleteMapping("/drafts/{draftId}") + public ApiResponse deleteDraft(@PathVariable Long draftId, + @RequestAttribute("userId") String userId, + @RequestAttribute(value = "platformRoles", required = false) + Set platformRoles) { + draftService.deleteDraft(draftId, userId, platformRoles); + return ok("response.success.deleted", null); + } + + // ---------------------------------------------------------------- draft files + + @Operation(operationId = "listDraftFiles", summary = "List a draft's files with metadata") + @GetMapping("/drafts/{draftId}/files") + public ApiResponse> listFiles(@PathVariable Long draftId) { + return ok("response.success", draftService.listFiles(draftId).stream() + .map(DraftFileResponse::from) + .toList()); + } + + @Operation(operationId = "saveDraftFile", summary = "Create or update one draft file") + @PutMapping("/drafts/{draftId}/files") + public ApiResponse saveFile(@PathVariable Long draftId, + @Valid @RequestBody SaveDraftFileRequest request, + @RequestAttribute("userId") String userId, + @RequestAttribute(value = "platformRoles", required = false) + Set platformRoles) { + byte[] content = request.isBase64() + ? Base64.getDecoder().decode(request.content()) + : request.content().getBytes(StandardCharsets.UTF_8); + return ok("response.success.updated", SaveDraftFileResponse.from( + draftService.saveFile(draftId, userId, request.path(), content, + request.contentType(), request.expectedRevision(), platformRoles))); + } + + @Operation(operationId = "readDraftFile", summary = "Read one draft file's content") + @GetMapping("/drafts/{draftId}/files/content") + public ApiResponse readFile(@PathVariable Long draftId, + @RequestParam("path") String path, + @RequestAttribute("userId") String userId, + @RequestAttribute(value = "platformRoles", required = false) + Set platformRoles) { + SkillDraftService.FileContent content = + draftService.readFile(draftId, userId, path, platformRoles); + return ok("response.success", new DraftFileContentResponse( + content.file().getFilePath(), + content.file().getSha256(), + content.file().getSize(), + content.file().getContentType(), + content.asText())); + } + + @Operation(operationId = "deleteDraftFile", summary = "Delete one draft file") + @DeleteMapping("/drafts/{draftId}/files") + public ApiResponse deleteFile(@PathVariable Long draftId, + @RequestParam("path") String path, + @RequestParam(value = "expectedRevision", required = false) + Integer expectedRevision, + @RequestAttribute("userId") String userId, + @RequestAttribute(value = "platformRoles", required = false) + Set platformRoles) { + draftService.deleteFile(draftId, userId, path, expectedRevision, platformRoles); + return ok("response.success.deleted", null); + } + + // ---------------------------------------------------------------- runtime binding + + @Operation(operationId = "getDraftRuntimeBinding", summary = "Get the draft's runtime binding") + @GetMapping("/drafts/{draftId}/runtime") + public ApiResponse getRuntime(@PathVariable Long draftId, + @RequestAttribute("userId") String userId, + @RequestAttribute(value = "platformRoles", required = false) + Set platformRoles) { + draftService.getOwnedDraft(draftId, userId, platformRoles); + return ok("response.success", bindingService.findBinding(draftId) + .map(this::toRuntimeResponse) + .orElseGet(() -> new RuntimeBindingResponse(null, Map.of(), List.of(), List.of(), null))); + } + + @Operation(operationId = "saveDraftRuntimeBinding", summary = "Configure the agent runtime, tools, and MCP servers") + @PutMapping("/drafts/{draftId}/runtime") + public ApiResponse saveRuntime(@PathVariable Long draftId, + @Valid @RequestBody RuntimeBindingRequest request, + @RequestAttribute("userId") String userId, + @RequestAttribute(value = "platformRoles", required = false) + Set platformRoles) { + return ok("response.success.updated", toRuntimeResponse(bindingService.saveBinding( + draftId, userId, request.agentType(), request.config(), + request.toolAllowlist(), request.mcpServers(), platformRoles))); + } + + private RuntimeBindingResponse toRuntimeResponse( + com.iflytek.skillhub.domain.authoring.RuntimeBinding binding) { + return new RuntimeBindingResponse( + binding.getAgentType().identifier(), + binding.getConfig() == null ? Map.of() : binding.getConfig(), + binding.getToolAllowlist() == null ? List.of() : binding.getToolAllowlist(), + binding.getMcpServers() == null ? List.of() : binding.getMcpServers(), + binding.getUpdatedAt()); + } + + // ---------------------------------------------------------------- validation runs + + @Operation(operationId = "startValidationRun", summary = "Start a validation run for the draft's current revision") + @PostMapping("/drafts/{draftId}/runs") + public ApiResponse startRun(@PathVariable Long draftId, + @RequestAttribute("userId") String userId, + @RequestAttribute(value = "platformRoles", required = false) + Set platformRoles) { + ValidationRun run = runService.startRun(draftId, userId, platformRoles); + orchestrator.submit(run.getId()); + return ok("response.success.created", ValidationRunResponse.from(run)); + } + + @Operation(operationId = "listValidationRuns", summary = "List the draft's validation runs") + @GetMapping("/drafts/{draftId}/runs") + public ApiResponse> listRuns(@PathVariable Long draftId) { + return ok("response.success", runService.listRuns(draftId).stream() + .map(ValidationRunResponse::from) + .toList()); + } + + @Operation(operationId = "getValidationRun", summary = "Get one validation run") + @GetMapping("/runs/{runId}") + public ApiResponse getRun(@PathVariable Long runId, + @RequestAttribute("userId") String userId, + @RequestAttribute(value = "platformRoles", required = false) + Set platformRoles) { + ValidationRun run = runService.getOwnedRun(runId, userId, platformRoles); + return ok("response.success", ValidationRunResponse.from(run)); + } + + @Operation(operationId = "cancelValidationRun", summary = "Request cooperative cancellation of a run") + @PostMapping("/runs/{runId}/cancel") + public ApiResponse cancelRun(@PathVariable Long runId, + @RequestAttribute("userId") String userId, + @RequestAttribute(value = "platformRoles", required = false) + Set platformRoles) { + ValidationRun run = orchestrator.requestCancel(runId, userId, platformRoles); + return ok("response.success.updated", ValidationRunResponse.from(run)); + } + + @Operation(operationId = "listValidationEvents", summary = "List run events after a sequence cursor (polling)") + @GetMapping("/runs/{runId}/events") + public ApiResponse> listEvents( + @PathVariable Long runId, + @RequestParam(value = "afterSeq", required = false) Integer afterSeq, + @RequestAttribute("userId") String userId, + @RequestAttribute(value = "platformRoles", required = false) + Set platformRoles) { + runService.getOwnedRun(runId, userId, platformRoles); + return ok("response.success", runService.listEvents(runId, afterSeq).stream() + .map(ValidationEventResponse::from) + .toList()); + } + + // ---------------------------------------------------------------- findings & fixes + + @Operation(operationId = "listValidationFindings", summary = "List a run's findings with fix suggestions") + @GetMapping("/runs/{runId}/findings") + public ApiResponse> listFindings( + @PathVariable Long runId, + @RequestAttribute("userId") String userId, + @RequestAttribute(value = "platformRoles", required = false) + Set platformRoles) { + runService.getOwnedRun(runId, userId, platformRoles); + return ok("response.success", runService.listFindings(runId).stream() + .map(ValidationFindingResponse::from) + .toList()); + } + + @Operation(operationId = "applyFindingFix", summary = "Apply a finding's fix suggestion to the draft") + @PostMapping("/runs/{runId}/findings/{findingId}/apply") + public ApiResponse applyFindingFix( + @PathVariable Long runId, + @PathVariable Long findingId, + @RequestAttribute("userId") String userId, + @RequestAttribute(value = "platformRoles", required = false) + Set platformRoles) { + FindingFixService.FixOutcome outcome = fixService.applyFix(findingId, userId, platformRoles); + return ok("response.success.updated", ValidationFindingResponse.from(outcome.finding())); + } + + @Operation(operationId = "dismissFindingFix", summary = "Dismiss a finding as not applicable") + @PostMapping("/runs/{runId}/findings/{findingId}/dismiss") + public ApiResponse dismissFinding( + @PathVariable Long runId, + @PathVariable Long findingId, + @RequestAttribute("userId") String userId, + @RequestAttribute(value = "platformRoles", required = false) + Set platformRoles) { + ValidationFinding finding = fixService.dismissFinding(findingId, userId, platformRoles); + return ok("response.success.updated", ValidationFindingResponse.from(finding)); + } + + // ---------------------------------------------------------------- submission + + @Operation(operationId = "submitValidatedDraft", summary = "Submit a validated draft into the publish pipeline") + @PostMapping("/drafts/{draftId}/submit") + public ApiResponse submitDraft(@PathVariable Long draftId, + @RequestBody(required = false) SubmitDraftRequest request, + @RequestAttribute("userId") String userId, + @RequestAttribute(value = "platformRoles", required = false) + Set platformRoles) { + SkillVisibility visibility = request == null || request.visibility() == null + ? SkillVisibility.PRIVATE + : SkillVisibility.valueOf(request.visibility()); + Set roles = request == null || request.platformRoles() == null + ? platformRoles + : request.platformRoles(); + return ok("response.success.created", + SubmitDraftResponse.from(submitService.submit(draftId, userId, visibility, roles))); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/authoring/ValidationStreamController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/authoring/ValidationStreamController.java new file mode 100644 index 000000000..92e6ead2e --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/authoring/ValidationStreamController.java @@ -0,0 +1,68 @@ +package com.iflytek.skillhub.controller.authoring; + +import com.iflytek.skillhub.domain.authoring.service.ValidationRunService; +import com.iflytek.skillhub.domain.authoring.validation.ValidationEvent; +import com.iflytek.skillhub.domain.authoring.validation.ValidationRun; +import com.iflytek.skillhub.service.authoring.ValidationEventBroadcaster; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import java.util.List; +import java.util.Set; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestAttribute; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +/** + * Server-sent events for validation runs. Browsers authenticate via the session + * cookie (EventSource cannot send headers); token clients use the /api/v1 variant. + * Event ids are the per-run sequence numbers, so reconnecting clients resume from + * Last-Event-ID without gaps or duplicates. + */ +@Tag(name = "Skill authoring") +@RestController +@RequestMapping({"/api/v1/authoring", "/api/web/authoring"}) +public class ValidationStreamController { + + private final ValidationRunService runService; + private final ValidationEventBroadcaster broadcaster; + + public ValidationStreamController(ValidationRunService runService, + ValidationEventBroadcaster broadcaster) { + this.runService = runService; + this.broadcaster = broadcaster; + } + + @Operation(operationId = "streamValidationEvents", + summary = "Stream run events over SSE; resumes from Last-Event-ID") + @GetMapping(value = "/runs/{runId}/events/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE) + public SseEmitter streamEvents(@PathVariable Long runId, + @RequestParam(value = "afterSeq", required = false) Integer afterSeq, + @RequestHeader(value = "Last-Event-ID", required = false) String lastEventId, + @RequestAttribute("userId") String userId, + @RequestAttribute(value = "platformRoles", required = false) + Set platformRoles) { + ValidationRun run = runService.getOwnedRun(runId, userId, platformRoles); + int lastSeq = resolveCursor(afterSeq, lastEventId); + List replay = runService.listEvents(run.getId(), lastSeq); + return broadcaster.subscribe(run.getId(), lastSeq, replay); + } + + private int resolveCursor(Integer afterSeq, String lastEventId) { + int fromParam = afterSeq == null ? 0 : Math.max(0, afterSeq); + int fromHeader = 0; + if (lastEventId != null && !lastEventId.isBlank()) { + try { + fromHeader = Math.max(0, Integer.parseInt(lastEventId.trim())); + } catch (NumberFormatException ignored) { + // malformed Last-Event-ID restarts from the beginning + } + } + return Math.max(fromParam, fromHeader); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/CreateDraftRequest.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/CreateDraftRequest.java new file mode 100644 index 000000000..cc59ea4d9 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/CreateDraftRequest.java @@ -0,0 +1,11 @@ +package com.iflytek.skillhub.dto; + +import jakarta.validation.constraints.NotBlank; + +/** Request body for creating a skill authoring draft. */ +public record CreateDraftRequest( + @NotBlank(message = "{error.badRequest}") String namespaceSlug, + @NotBlank(message = "{error.badRequest}") String name, + String requirement +) { +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/DraftFileContentResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/DraftFileContentResponse.java new file mode 100644 index 000000000..a1d283c03 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/DraftFileContentResponse.java @@ -0,0 +1,11 @@ +package com.iflytek.skillhub.dto; + +/** Draft file content, always delivered as text (draft files are text formats). */ +public record DraftFileContentResponse( + String path, + String sha256, + Long size, + String contentType, + String content +) { +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/DraftFileResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/DraftFileResponse.java new file mode 100644 index 000000000..f2f25c2bb --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/DraftFileResponse.java @@ -0,0 +1,25 @@ +package com.iflytek.skillhub.dto; + +import com.iflytek.skillhub.domain.authoring.DraftFile; +import java.time.Instant; + +/** Draft file metadata (content is fetched separately). */ +public record DraftFileResponse( + Long id, + String path, + String sha256, + Long size, + String contentType, + Instant updatedAt +) { + + public static DraftFileResponse from(DraftFile file) { + return new DraftFileResponse( + file.getId(), + file.getFilePath(), + file.getSha256(), + file.getSize(), + file.getContentType(), + file.getUpdatedAt()); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/DraftResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/DraftResponse.java new file mode 100644 index 000000000..60aa7bb94 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/DraftResponse.java @@ -0,0 +1,41 @@ +package com.iflytek.skillhub.dto; + +import com.iflytek.skillhub.domain.authoring.SkillDraft; +import java.time.Instant; + +/** Draft summary/aggregate exposed to the authoring UI. */ +public record DraftResponse( + Long id, + Long namespaceId, + String name, + String requirement, + Integer revision, + String contentDigest, + boolean validated, + Integer validatedRevision, + Long validatedRunId, + Long submittedSkillId, + Long submittedVersionId, + Instant submittedAt, + Instant createdAt, + Instant updatedAt +) { + + public static DraftResponse from(SkillDraft draft) { + return new DraftResponse( + draft.getId(), + draft.getNamespaceId(), + draft.getName(), + draft.getRequirement(), + draft.getRevision(), + draft.getContentDigest(), + draft.isCurrentRevisionValidated(), + draft.getValidatedRevision(), + draft.getValidatedRunId(), + draft.getSubmittedSkillId(), + draft.getSubmittedVersionId(), + draft.getSubmittedAt(), + draft.getCreatedAt(), + draft.getUpdatedAt()); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/RuntimeBindingRequest.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/RuntimeBindingRequest.java new file mode 100644 index 000000000..2a2cef8f0 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/RuntimeBindingRequest.java @@ -0,0 +1,18 @@ +package com.iflytek.skillhub.dto; + +import jakarta.validation.constraints.NotBlank; +import java.util.List; +import java.util.Map; + +/** + * Request body for the per-draft runtime binding: which agent runtime executes + * behavior tasks, its adapter configuration, the tool allowlist, and MCP server + * declarations. Credentials must be referenced by name, never inlined. + */ +public record RuntimeBindingRequest( + @NotBlank(message = "{error.badRequest}") String agentType, + Map config, + List toolAllowlist, + List> mcpServers +) { +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/RuntimeBindingResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/RuntimeBindingResponse.java new file mode 100644 index 000000000..7489cbb64 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/RuntimeBindingResponse.java @@ -0,0 +1,15 @@ +package com.iflytek.skillhub.dto; + +import java.time.Instant; +import java.util.List; +import java.util.Map; + +/** Runtime binding as seen by the authoring UI. */ +public record RuntimeBindingResponse( + String agentType, + Map config, + List toolAllowlist, + List> mcpServers, + Instant updatedAt +) { +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SaveDraftFileRequest.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SaveDraftFileRequest.java new file mode 100644 index 000000000..88c82ab06 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SaveDraftFileRequest.java @@ -0,0 +1,21 @@ +package com.iflytek.skillhub.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +/** + * Request body for saving one draft file. Text is the default encoding; base64 + * transports binary-safe content. + */ +public record SaveDraftFileRequest( + @NotBlank(message = "{error.badRequest}") String path, + @NotNull(message = "{error.badRequest}") String content, + String encoding, + String contentType, + Integer expectedRevision +) { + + public boolean isBase64() { + return "base64".equalsIgnoreCase(encoding); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SaveDraftFileResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SaveDraftFileResponse.java new file mode 100644 index 000000000..e0b162020 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SaveDraftFileResponse.java @@ -0,0 +1,20 @@ +package com.iflytek.skillhub.dto; + +import com.iflytek.skillhub.domain.authoring.service.SkillDraftService; + +/** Result of saving one draft file: updated draft plus file metadata. */ +public record SaveDraftFileResponse( + DraftResponse draft, + DraftFileResponse file, + boolean created, + boolean revisionAdvanced +) { + + public static SaveDraftFileResponse from(SkillDraftService.SaveFileOutcome outcome) { + return new SaveDraftFileResponse( + DraftResponse.from(outcome.draft()), + DraftFileResponse.from(outcome.file()), + outcome.created(), + outcome.revisionAdvanced()); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SubmitDraftRequest.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SubmitDraftRequest.java new file mode 100644 index 000000000..b6fe7eaff --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SubmitDraftRequest.java @@ -0,0 +1,10 @@ +package com.iflytek.skillhub.dto; + +import java.util.Set; + +/** Request body for submitting a validated draft into the publish pipeline. */ +public record SubmitDraftRequest( + String visibility, + Set platformRoles +) { +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SubmitDraftResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SubmitDraftResponse.java new file mode 100644 index 000000000..ebaaf9823 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SubmitDraftResponse.java @@ -0,0 +1,17 @@ +package com.iflytek.skillhub.dto; + +import com.iflytek.skillhub.domain.authoring.service.DraftSubmitService; + +/** Outcome of submitting a validated draft: the published skill version coordinates. */ +public record SubmitDraftResponse( + Long skillId, + Long versionId, + String slug, + String version +) { + + public static SubmitDraftResponse from(DraftSubmitService.SubmitOutcome outcome) { + return new SubmitDraftResponse( + outcome.skillId(), outcome.versionId(), outcome.slug(), outcome.version()); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/ValidationEventResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/ValidationEventResponse.java new file mode 100644 index 000000000..9a7265b99 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/ValidationEventResponse.java @@ -0,0 +1,28 @@ +package com.iflytek.skillhub.dto; + +import com.iflytek.skillhub.domain.authoring.validation.ValidationEvent; +import java.time.Instant; +import java.util.Map; + +/** One persisted validation event (log line, tool call, finding, phase marker...). */ +public record ValidationEventResponse( + Long id, + Long runId, + Integer seq, + String type, + String phase, + Map payload, + Instant createdAt +) { + + public static ValidationEventResponse from(ValidationEvent event) { + return new ValidationEventResponse( + event.getId(), + event.getRunId(), + event.getSeq(), + event.getEventType().name(), + event.getPhase(), + event.getPayload() == null ? Map.of() : event.getPayload(), + event.getCreatedAt()); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/ValidationFindingResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/ValidationFindingResponse.java new file mode 100644 index 000000000..a0d85379b --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/ValidationFindingResponse.java @@ -0,0 +1,41 @@ +package com.iflytek.skillhub.dto; + +import com.iflytek.skillhub.domain.authoring.FixSuggestion; +import com.iflytek.skillhub.domain.authoring.validation.ValidationFinding; +import java.time.Instant; + +/** + * One validation finding. {@code suggestion} carries the machine-applicable fix + * (patches with old/new values) when one exists, for preview and confirmed apply. + */ +public record ValidationFindingResponse( + Long id, + Long runId, + String layer, + String ruleCode, + String severity, + String filePath, + String location, + String message, + FixSuggestion suggestion, + String status, + Integer appliedRevision, + Instant createdAt +) { + + public static ValidationFindingResponse from(ValidationFinding finding) { + return new ValidationFindingResponse( + finding.getId(), + finding.getRunId(), + finding.getLayer().name(), + finding.getRuleCode(), + finding.getSeverity().name(), + finding.getFilePath(), + finding.getLocation(), + finding.getMessage(), + finding.getSuggestion(), + finding.getStatus().name(), + finding.getAppliedRevision(), + finding.getCreatedAt()); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/ValidationRunResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/ValidationRunResponse.java new file mode 100644 index 000000000..921a05d19 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/ValidationRunResponse.java @@ -0,0 +1,46 @@ +package com.iflytek.skillhub.dto; + +import com.iflytek.skillhub.domain.authoring.validation.ValidationRun; +import java.time.Instant; +import java.util.Map; + +/** Validation run state and outcome counters. */ +public record ValidationRunResponse( + Long id, + Long draftId, + Integer draftRevision, + String status, + boolean cancelRequested, + boolean active, + boolean terminal, + Integer errorCount, + Integer warningCount, + String triggeredBy, + Instant startedAt, + Instant finishedAt, + Instant createdAt, + Map summary +) { + + public static ValidationRunResponse from(ValidationRun run) { + return from(run, run.getSummary()); + } + + public static ValidationRunResponse from(ValidationRun run, Map summary) { + return new ValidationRunResponse( + run.getId(), + run.getDraftId(), + run.getDraftRevision(), + run.getStatus().name(), + run.isCancelRequested(), + run.isActive(), + run.getStatus().isTerminal(), + run.getErrorCount(), + run.getWarningCount(), + run.getTriggeredBy(), + run.getStartedAt(), + run.getFinishedAt(), + run.getCreatedAt(), + summary == null ? Map.of() : summary); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/authoring/AuthoringFlowIntegrationTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/authoring/AuthoringFlowIntegrationTest.java new file mode 100644 index 000000000..1c2cef4e4 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/authoring/AuthoringFlowIntegrationTest.java @@ -0,0 +1,351 @@ +package com.iflytek.skillhub.authoring; + +import com.iflytek.skillhub.TestRedisConfig; +import com.iflytek.skillhub.domain.authoring.SkillDraft; +import com.iflytek.skillhub.domain.authoring.service.DraftSubmitService; +import com.iflytek.skillhub.domain.authoring.service.FindingFixService; +import com.iflytek.skillhub.domain.authoring.service.RuntimeBindingService; +import com.iflytek.skillhub.domain.authoring.service.SkillDraftService; +import com.iflytek.skillhub.domain.authoring.service.ValidationRunService; +import com.iflytek.skillhub.domain.authoring.validation.FindingSeverity; +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.NamespaceRepository; +import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; +import com.iflytek.skillhub.domain.namespace.NamespaceRole; +import com.iflytek.skillhub.domain.skill.SkillVisibility; +import com.iflytek.skillhub.domain.skill.SkillVersion; +import com.iflytek.skillhub.domain.skill.service.SkillPublishService; +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.Optional; +import java.util.Set; +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.boot.test.mock.mockito.MockBean; +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.fail; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; + +/** + * End-to-end authoring flow against the real service stack (Testcontainers + * PostgreSQL, in-memory object storage, mocked publish pipeline): create draft → + * edit files → bind runtime → validate (structure + config + behavior) → fix + * findings → re-validate → submit. Runs on real PostgreSQL because the JSONB + * columns must round-trip exactly as they do in production. + */ +@SpringBootTest +@ActiveProfiles("test") +@Import({TestRedisConfig.class, AuthoringFlowIntegrationTest.InMemoryStorageConfig.class}) +@Testcontainers +class AuthoringFlowIntegrationTest { + + @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 RuntimeBindingService bindingService; + @Autowired private ValidationRunService runService; + @Autowired private DraftSubmitService submitService; + @Autowired private FindingFixService fixService; + @Autowired private ValidationRunOrchestrator orchestrator; + @Autowired private NamespaceRepository namespaceRepository; + @Autowired private NamespaceMemberRepository namespaceMemberRepository; + @Autowired private UserAccountRepository userAccountRepository; + + @MockBean private SkillPublishService publishService; + + @TestConfiguration + static class InMemoryStorageConfig { + + @Bean + @Primary + ObjectStorageService inMemoryObjectStorage() { + return new InMemoryObjectStorage(); + } + } + + static final class InMemoryObjectStorage implements ObjectStorageService { + private final Map objects = new ConcurrentHashMap<>(); + + @Override + public void putObject(String key, InputStream data, long size, String contentType) { + try { + objects.put(key, data.readAllBytes()); + } catch (Exception exception) { + throw new IllegalStateException(exception); + } + } + + @Override + public InputStream getObject(String key) { + byte[] content = objects.get(key); + if (content == null) { + throw new IllegalArgumentException("missing object: " + key); + } + return new java.io.ByteArrayInputStream(content); + } + + @Override + public void deleteObject(String key) { + objects.remove(key); + } + + @Override + public void deleteObjects(List keys) { + keys.forEach(objects::remove); + } + + @Override + public boolean exists(String key) { + return objects.containsKey(key); + } + + @Override + public ObjectMetadata getMetadata(String key) { + byte[] content = objects.get(key); + if (content == null) { + throw new IllegalArgumentException("missing object: " + key); + } + return new ObjectMetadata(content.length, "application/octet-stream", java.time.Instant.now()); + } + + @Override + public String generatePresignedUrl(String key, Duration expiry, String downloadFilename) { + return "http://localhost/" + key; + } + } + + @BeforeEach + void setUpNamespace() { + if (userAccountRepository.findById(USER).isEmpty()) { + userAccountRepository.save(new UserAccount(USER, "Author User", "author@example.com", null)); + } + if (namespaceRepository.findBySlug(NS_SLUG).isEmpty()) { + Namespace namespace = new Namespace(NS_SLUG, "Authoring Test", USER); + namespace.setStatus(com.iflytek.skillhub.domain.namespace.NamespaceStatus.ACTIVE); + Namespace saved = namespaceRepository.save(namespace); + if (namespaceMemberRepository.findByNamespaceIdAndUserId(saved.getId(), USER).isEmpty()) { + namespaceMemberRepository.save(new NamespaceMember(saved.getId(), USER, NamespaceRole.MEMBER)); + } + } + } + + @Test + void validatedDraftRunsAllLayersAndSubmits() { + // ---- create with scaffold + SkillDraft draft = draftService.createDraft(NS_SLUG, USER, "demo-flow", "summarizes documents", null); + assertThat(draft.getRevision()).isEqualTo(1); + assertThat(draftService.listFiles(draft.getId())) + .extracting(file -> file.getFilePath()) + .containsExactly("SKILL.md"); + // the scaffold bytes must be readable straight away, before any manual + // save — creation persists content, not just the metadata row + assertThat(draftService.readFile(draft.getId(), USER, "SKILL.md", null).asText()) + .contains("name: demo-flow") + .contains("description: summarizes documents"); + + // ---- edit files: overwrite SKILL.md, add script + validation.yaml + draftService.saveFile(draft.getId(), USER, "SKILL.md", validSkillMd(), "text/markdown", null, null); + draftService.saveFile(draft.getId(), USER, "scripts/check.sh", + "echo 'smoke OK'\n".getBytes(StandardCharsets.UTF_8), null, null, null); + draftService.saveFile(draft.getId(), USER, "validation.yaml", + validationYaml().getBytes(StandardCharsets.UTF_8), null, null, null); + assertThat(draftService.getDraft(draft.getId()).getRevision()).isEqualTo(4); + + // ---- bind runtime: local script execution + bindingService.saveBinding(draft.getId(), USER, "local-script", + Map.of("interpreter", "sh"), null, null, null); + + // ---- validate: all three layers + ValidationRun run = runService.startRun(draft.getId(), USER, null); + orchestrator.submit(run.getId()); + ValidationRun finished = awaitTerminal(run.getId()); + + assertThat(finished.getStatus()).isEqualTo(ValidationRunStatus.SUCCEEDED); + assertThat(finished.getErrorCount()).isZero(); + assertThat(runService.listFindings(run.getId())).isEmpty(); + assertThat(runService.listEvents(run.getId(), null)) + .extracting(event -> event.getEventType().name()) + .contains("RUN_STARTED", "PHASE_STARTED", "TOOL_CALL", "LOG", "RUN_FINISHED"); + + // ---- draft is validated for this revision + SkillDraft validated = draftService.getDraft(draft.getId()); + assertThat(validated.isCurrentRevisionValidated()).isTrue(); + assertThat(validated.getValidatedRevision()).isEqualTo(validated.getRevision()); + + // ---- submit into the (mocked) publish pipeline + SkillVersion publishedVersion = new SkillVersion(99L, "1.0.0", USER); + when(publishService.publishFromEntries(eq(NS_SLUG), any(), eq(USER), + eq(SkillVisibility.PRIVATE), any(), anyBoolean())) + .thenReturn(new SkillPublishService.PublishResult(99L, "demo-flow", publishedVersion)); + + DraftSubmitService.SubmitOutcome outcome = submitService.submit( + draft.getId(), USER, SkillVisibility.PRIVATE, null); + + assertThat(outcome.skillId()).isEqualTo(99L); + assertThat(outcome.version()).isEqualTo("1.0.0"); + assertThat(draftService.getDraft(draft.getId()).getSubmittedSkillId()).isEqualTo(99L); + } + + @Test + void brokenFrontmatterProducesFixableFindingAndRevalidationLoop() { + SkillDraft draft = draftService.createDraft(NS_SLUG, USER, "broken-flow", null, null); + // SKILL.md without the required description field + draftService.saveFile(draft.getId(), USER, "SKILL.md", + "---\nname: broken-flow\n---\n\n# Broken\n".getBytes(StandardCharsets.UTF_8), + "text/markdown", null, null); + + ValidationRun run = runService.startRun(draft.getId(), USER, null); + orchestrator.submit(run.getId()); + awaitTerminal(run.getId()); + + List findings = runService.listFindings(run.getId()); + assertThat(findings).isNotEmpty(); + Optional missingField = findings.stream() + .filter(finding -> "FRONTMATTER_FIELD_MISSING".equals(finding.getRuleCode())) + .findFirst(); + assertThat(missingField).isPresent(); + assertThat(missingField.get().getSeverity()).isEqualTo(FindingSeverity.ERROR); + assertThat(missingField.get().hasSuggestion()).isTrue(); + assertThat(runService.getRun(run.getId()).getStatus()).isEqualTo(ValidationRunStatus.FAILED); + + // ---- apply the suggested fix + int revisionBefore = draftService.getDraft(draft.getId()).getRevision(); + FindingFixService.FixOutcome outcome = fixService.applyFix( + missingField.get().getId(), USER, null); + assertThat(outcome.finding().getStatus().name()).isEqualTo("APPLIED"); + assertThat(outcome.draft().getRevision()).isGreaterThan(revisionBefore); + + // ---- validation verdict is invalidated by the new revision + SkillDraft fixed = draftService.getDraft(draft.getId()); + assertThat(fixed.isCurrentRevisionValidated()).isFalse(); + + // ---- re-validate: now the description field exists and the run passes + ValidationRun second = runService.startRun(draft.getId(), USER, null); + orchestrator.submit(second.getId()); + ValidationRun finishedSecond = awaitTerminal(second.getId()); + + assertThat(finishedSecond.getStatus()).isEqualTo(ValidationRunStatus.SUCCEEDED); + assertThat(draftService.getDraft(draft.getId()).isCurrentRevisionValidated()).isTrue(); + } + + @Test + void deadMcpServerFailsTheRunWithoutValidationYaml() { + // no validation.yaml on purpose: the config layer must still check the + // runtime binding — a declared MCP server that cannot be reached is a + // config error even when the behavior layer has no tasks to run + SkillDraft draft = draftService.createDraft(NS_SLUG, USER, "mcp-probe-flow", + "declares a dead MCP server", null); + draftService.saveFile(draft.getId(), USER, "SKILL.md", validSkillMd(), "text/markdown", null, null); + + bindingService.saveBinding(draft.getId(), USER, "local-script", + Map.of("interpreter", "sh"), null, + List.of(Map.of("name", "dead-server", "transport", "http", + "endpoint", "http://127.0.0.1:9/mcp")), null); + + ValidationRun run = runService.startRun(draft.getId(), USER, null); + orchestrator.submit(run.getId()); + ValidationRun finished = awaitTerminal(run.getId()); + + assertThat(finished.getStatus()).isEqualTo(ValidationRunStatus.FAILED); + assertThat(runService.listFindings(run.getId())) + .extracting(ValidationFinding::getRuleCode) + .contains("SPEC_MISSING", "MCP_CONNECT_FAILED"); + } + + // ---------------------------------------------------------------- helpers + + 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 " + runId + " did not reach a terminal state in time"); + return null; + } + + private static byte[] validSkillMd() { + return """ + --- + name: demo-flow + description: summarizes documents for the demo + --- + + # Demo flow + + ## Overview + + summarizes documents for the demo + """.getBytes(StandardCharsets.UTF_8); + } + + private static String validationYaml() { + return """ + version: 1 + tasks: + - name: smoke + description: script runs and prints OK + type: script + script: scripts/check.sh + args: [] + timeoutMs: 10000 + assertions: + - type: exit_code + equals: 0 + - type: stdout_contains + value: smoke OK + """; + } +} From 40debb03412e21d949e59b6ee5c2e661d3f2568b Mon Sep 17 00:00:00 2001 From: zjncs <18910855655@163.com> Date: Sun, 20 Sep 2026 02:35:59 +0800 Subject: [PATCH 04/19] feat(web): add skill authoring workbench Draft list and detail pages with a file editor (create/edit/upload, binary files render metadata + sha256, delete with explicit reselection), runtime binding form (local-script, docker mode, OpenAI-compatible, MCP server declarations), validation run pages with a live event console (SSE with polling fallback, terminal-run backfill), findings with diff previews and two-step fix confirmation, and submit gating on the validated revision. Dev proxy target is configurable via VITE_API_PROXY_TARGET. Signed-off-by: zjncs <18910855655@163.com> --- web/src/api/client.ts | 168 + web/src/api/generated/schema.d.ts | 3622 ++++++++++++----- web/src/api/types.ts | 120 + web/src/app/router.tsx | 35 + .../features/authoring/binary-file.test.ts | 27 + web/src/features/authoring/binary-file.ts | 56 + .../features/authoring/draft-file-editor.tsx | 352 ++ web/src/features/authoring/event-console.tsx | 72 + web/src/features/authoring/finding-card.tsx | 142 + .../features/authoring/merge-events.test.ts | 45 + web/src/features/authoring/merge-events.ts | 40 + .../features/authoring/preview-patch.test.ts | 38 + web/src/features/authoring/preview-patch.ts | 66 + .../features/authoring/run-status-badge.tsx | 25 + .../authoring/runtime-binding-form.tsx | 193 + .../authoring/use-validation-stream.test.ts | 149 + .../authoring/use-validation-stream.ts | 128 + web/src/i18n/locales/en.json | 151 +- web/src/i18n/locales/ru.json | 151 +- web/src/i18n/locales/zh.json | 151 +- web/src/pages/authoring/draft-detail.tsx | 289 ++ web/src/pages/authoring/drafts.tsx | 281 ++ web/src/pages/authoring/run-detail.tsx | 208 + web/src/pages/dashboard.tsx | 3 +- web/vite.config.ts | 5 +- 25 files changed, 5549 insertions(+), 968 deletions(-) create mode 100644 web/src/features/authoring/binary-file.test.ts create mode 100644 web/src/features/authoring/binary-file.ts create mode 100644 web/src/features/authoring/draft-file-editor.tsx create mode 100644 web/src/features/authoring/event-console.tsx create mode 100644 web/src/features/authoring/finding-card.tsx create mode 100644 web/src/features/authoring/merge-events.test.ts create mode 100644 web/src/features/authoring/merge-events.ts create mode 100644 web/src/features/authoring/preview-patch.test.ts create mode 100644 web/src/features/authoring/preview-patch.ts create mode 100644 web/src/features/authoring/run-status-badge.tsx create mode 100644 web/src/features/authoring/runtime-binding-form.tsx create mode 100644 web/src/features/authoring/use-validation-stream.test.ts create mode 100644 web/src/features/authoring/use-validation-stream.ts create mode 100644 web/src/pages/authoring/draft-detail.tsx create mode 100644 web/src/pages/authoring/drafts.tsx create mode 100644 web/src/pages/authoring/run-detail.tsx diff --git a/web/src/api/client.ts b/web/src/api/client.ts index aeef6d670..a9f46c4ba 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -51,6 +51,15 @@ import type { BatchMemberResponse, SkillSuite, SkillSuiteDraftInput, + AuthoringDraft, + DraftFileSummary, + DraftFileContent, + SaveDraftFileOutcome, + RuntimeBindingInfo, + ValidationRunInfo, + ValidationEventInfo, + ValidationFindingInfo, + SubmitDraftOutcome, } from './types' import { ApiError } from '@/shared/lib/api-error' import i18n from '@/i18n/config' @@ -1636,3 +1645,162 @@ export const notificationApi = { }) }, } + +export const authoringApi = { + async listDrafts(): Promise { + return fetchJson(`${WEB_API_PREFIX}/authoring/drafts`) + }, + + async createDraft(request: { namespaceSlug: string, name: string, requirement?: string }): Promise { + return fetchJson(`${WEB_API_PREFIX}/authoring/drafts`, { + method: 'POST', + headers: await ensureCsrfHeaders({ + 'Content-Type': 'application/json', + }), + body: JSON.stringify(request), + }) + }, + + async getDraft(draftId: number): Promise { + return fetchJson(`${WEB_API_PREFIX}/authoring/drafts/${draftId}`) + }, + + async deleteDraft(draftId: number): Promise { + await fetchJson(`${WEB_API_PREFIX}/authoring/drafts/${draftId}`, { + method: 'DELETE', + headers: await ensureCsrfHeaders(), + }) + }, + + async listFiles(draftId: number): Promise { + return fetchJson(`${WEB_API_PREFIX}/authoring/drafts/${draftId}/files`) + }, + + async readFile(draftId: number, path: string): Promise { + const query = new URLSearchParams({ path }) + return fetchJson( + `${WEB_API_PREFIX}/authoring/drafts/${draftId}/files/content?${query.toString()}`, + ) + }, + + async saveFile( + draftId: number, + request: { path: string, content: string, contentType?: string, expectedRevision?: number, base64?: boolean }, + ): Promise { + return fetchJson(`${WEB_API_PREFIX}/authoring/drafts/${draftId}/files`, { + method: 'PUT', + headers: await ensureCsrfHeaders({ + 'Content-Type': 'application/json', + }), + body: JSON.stringify({ + path: request.path, + content: request.content, + encoding: request.base64 ? 'base64' : undefined, + contentType: request.contentType, + expectedRevision: request.expectedRevision, + }), + }) + }, + + async deleteFile(draftId: number, path: string, expectedRevision?: number): Promise { + const query = new URLSearchParams({ path }) + if (expectedRevision !== undefined) { + query.set('expectedRevision', String(expectedRevision)) + } + await fetchJson( + `${WEB_API_PREFIX}/authoring/drafts/${draftId}/files?${query.toString()}`, + { + method: 'DELETE', + headers: await ensureCsrfHeaders(), + }, + ) + }, + + async getRuntimeBinding(draftId: number): Promise { + return fetchJson(`${WEB_API_PREFIX}/authoring/drafts/${draftId}/runtime`) + }, + + async saveRuntimeBinding( + draftId: number, + request: { agentType: string, config?: Record, toolAllowlist?: string[], mcpServers?: Record[] }, + ): Promise { + return fetchJson(`${WEB_API_PREFIX}/authoring/drafts/${draftId}/runtime`, { + method: 'PUT', + headers: await ensureCsrfHeaders({ + 'Content-Type': 'application/json', + }), + body: JSON.stringify({ + agentType: request.agentType, + config: request.config ?? {}, + toolAllowlist: request.toolAllowlist ?? [], + mcpServers: request.mcpServers ?? [], + }), + }) + }, + + async startRun(draftId: number): Promise { + return fetchJson(`${WEB_API_PREFIX}/authoring/drafts/${draftId}/runs`, { + method: 'POST', + headers: await ensureCsrfHeaders(), + }) + }, + + async listRuns(draftId: number): Promise { + return fetchJson(`${WEB_API_PREFIX}/authoring/drafts/${draftId}/runs`) + }, + + async getRun(runId: number): Promise { + return fetchJson(`${WEB_API_PREFIX}/authoring/runs/${runId}`) + }, + + async cancelRun(runId: number): Promise { + return fetchJson(`${WEB_API_PREFIX}/authoring/runs/${runId}/cancel`, { + method: 'POST', + headers: await ensureCsrfHeaders(), + }) + }, + + async listEvents(runId: number, afterSeq?: number): Promise { + const query = afterSeq === undefined ? '' : `?afterSeq=${afterSeq}` + return fetchJson(`${WEB_API_PREFIX}/authoring/runs/${runId}/events${query}`) + }, + + async listFindings(runId: number): Promise { + return fetchJson(`${WEB_API_PREFIX}/authoring/runs/${runId}/findings`) + }, + + async applyFix(runId: number, findingId: number): Promise { + return fetchJson( + `${WEB_API_PREFIX}/authoring/runs/${runId}/findings/${findingId}/apply`, + { + method: 'POST', + headers: await ensureCsrfHeaders(), + }, + ) + }, + + async dismissFinding(runId: number, findingId: number): Promise { + return fetchJson( + `${WEB_API_PREFIX}/authoring/runs/${runId}/findings/${findingId}/dismiss`, + { + method: 'POST', + headers: await ensureCsrfHeaders(), + }, + ) + }, + + async submitDraft(draftId: number, visibility: 'PRIVATE' | 'PUBLIC' = 'PRIVATE'): Promise { + return fetchJson(`${WEB_API_PREFIX}/authoring/drafts/${draftId}/submit`, { + method: 'POST', + headers: await ensureCsrfHeaders({ + 'Content-Type': 'application/json', + }), + body: JSON.stringify({ visibility }), + }) + }, + + /** Absolute URL for the SSE stream; EventSource resumes from the browser-managed Last-Event-ID. */ + validationStreamUrl(runId: number): string { + return buildApiUrl(`${WEB_API_PREFIX}/authoring/runs/${runId}/events/stream`) + }, +} diff --git a/web/src/api/generated/schema.d.ts b/web/src/api/generated/schema.d.ts index 0103827d3..30f6c3ac7 100644 --- a/web/src/api/generated/schema.d.ts +++ b/web/src/api/generated/schema.d.ts @@ -426,6 +426,80 @@ export interface paths { patch?: never; trace?: never; }; + "/api/web/authoring/drafts/{draftId}/runtime": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get the draft's runtime binding */ + get: operations["getDraftRuntimeBinding"]; + /** Configure the agent runtime, tools, and MCP servers */ + put: operations["saveDraftRuntimeBinding"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/authoring/drafts/{draftId}/runtime": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get the draft's runtime binding */ + get: operations["getDraftRuntimeBinding_1"]; + /** Configure the agent runtime, tools, and MCP servers */ + put: operations["saveDraftRuntimeBinding_1"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/web/authoring/drafts/{draftId}/files": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List a draft's files with metadata */ + get: operations["listDraftFiles"]; + /** Create or update one draft file */ + put: operations["saveDraftFile"]; + post?: never; + /** Delete one draft file */ + delete: operations["deleteDraftFile"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/authoring/drafts/{draftId}/files": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List a draft's files with metadata */ + get: operations["listDraftFiles_1"]; + /** Create or update one draft file */ + put: operations["saveDraftFile_1"]; + post?: never; + /** Delete one draft file */ + delete: operations["deleteDraftFile_1"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/tokens/{id}/expiration": { parameters: { query?: never; @@ -1900,6 +1974,214 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/authoring/runs/{runId}/findings/{findingId}/dismiss": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Dismiss a finding as not applicable */ + post: operations["dismissFindingFix"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/web/authoring/runs/{runId}/findings/{findingId}/dismiss": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Dismiss a finding as not applicable */ + post: operations["dismissFindingFix_1"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/web/authoring/runs/{runId}/findings/{findingId}/apply": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Apply a finding's fix suggestion to the draft */ + post: operations["applyFindingFix"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/authoring/runs/{runId}/findings/{findingId}/apply": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Apply a finding's fix suggestion to the draft */ + post: operations["applyFindingFix_1"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/authoring/runs/{runId}/cancel": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Request cooperative cancellation of a run */ + post: operations["cancelValidationRun"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/web/authoring/runs/{runId}/cancel": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Request cooperative cancellation of a run */ + post: operations["cancelValidationRun_1"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/web/authoring/drafts/{draftId}/submit": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Submit a validated draft into the publish pipeline */ + post: operations["submitValidatedDraft"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/authoring/drafts/{draftId}/submit": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Submit a validated draft into the publish pipeline */ + post: operations["submitValidatedDraft_1"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/authoring/drafts/{draftId}/runs": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List the draft's validation runs */ + get: operations["listValidationRuns"]; + put?: never; + /** Start a validation run for the draft's current revision */ + post: operations["startValidationRun"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/web/authoring/drafts/{draftId}/runs": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List the draft's validation runs */ + get: operations["listValidationRuns_1"]; + put?: never; + /** Start a validation run for the draft's current revision */ + post: operations["startValidationRun_1"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/authoring/drafts": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List the current user's drafts */ + get: operations["listAuthoringDrafts"]; + put?: never; + /** Create a draft seeded with a SKILL.md scaffold */ + post: operations["createAuthoringDraft"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/web/authoring/drafts": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List the current user's drafts */ + get: operations["listAuthoringDrafts_1"]; + put?: never; + /** Create a draft seeded with a SKILL.md scaffold */ + post: operations["createAuthoringDraft_1"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/tokens": { parameters: { query?: never; @@ -4273,14 +4555,15 @@ export interface paths { patch?: never; trace?: never; }; - "/api/v1/whoami": { + "/api/web/authoring/runs/{runId}/findings": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get: operations["whoami"]; + /** List a run's findings with fix suggestions */ + get: operations["listValidationFindings"]; put?: never; post?: never; delete?: never; @@ -4289,14 +4572,15 @@ export interface paths { patch?: never; trace?: never; }; - "/api/v1/skills/{skillId}/versions/{versionId}/security-audit": { + "/api/v1/authoring/runs/{runId}/findings": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get: operations["getSecurityAudits"]; + /** List a run's findings with fix suggestions */ + get: operations["listValidationFindings_1"]; put?: never; post?: never; delete?: never; @@ -4305,30 +4589,32 @@ export interface paths { patch?: never; trace?: never; }; - "/api/v1/skills/{canonicalSlug}": { + "/api/v1/authoring/runs/{runId}/events/stream": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get: operations["getSkill"]; + /** Stream run events over SSE; resumes from Last-Event-ID */ + get: operations["streamValidationEvents"]; put?: never; post?: never; - delete: operations["deleteSkill_2"]; + delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/v1/search": { + "/api/web/authoring/runs/{runId}/events/stream": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get: operations["search_1"]; + /** Stream run events over SSE; resumes from Last-Event-ID */ + get: operations["streamValidationEvents_1"]; put?: never; post?: never; delete?: never; @@ -4337,14 +4623,15 @@ export interface paths { patch?: never; trace?: never; }; - "/api/v1/resolve": { + "/api/web/authoring/runs/{runId}/events": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get: operations["resolveByQuery"]; + /** List run events after a sequence cursor (polling) */ + get: operations["listValidationEvents"]; put?: never; post?: never; delete?: never; @@ -4353,14 +4640,15 @@ export interface paths { patch?: never; trace?: never; }; - "/api/v1/resolve/{canonicalSlug}": { + "/api/v1/authoring/runs/{runId}/events": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get: operations["resolve"]; + /** List run events after a sequence cursor (polling) */ + get: operations["listValidationEvents_1"]; put?: never; post?: never; delete?: never; @@ -4369,14 +4657,15 @@ export interface paths { patch?: never; trace?: never; }; - "/api/v1/organizations": { + "/api/web/authoring/runs/{runId}": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get: operations["listOrganizations"]; + /** Get one validation run */ + get: operations["getValidationRun"]; put?: never; post?: never; delete?: never; @@ -4385,14 +4674,15 @@ export interface paths { patch?: never; trace?: never; }; - "/api/v1/organizations/{organizationId}": { + "/api/v1/authoring/runs/{runId}": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get: operations["getOrganization"]; + /** Get one validation run */ + get: operations["getValidationRun_1"]; put?: never; post?: never; delete?: never; @@ -4401,14 +4691,15 @@ export interface paths { patch?: never; trace?: never; }; - "/api/v1/organizations/{organizationId}/login-connections": { + "/api/v1/authoring/drafts/{draftId}/files/content": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get: operations["listLoginConnections"]; + /** Read one draft file's content */ + get: operations["readDraftFile"]; put?: never; post?: never; delete?: never; @@ -4417,14 +4708,15 @@ export interface paths { patch?: never; trace?: never; }; - "/api/v1/health": { + "/api/web/authoring/drafts/{draftId}/files/content": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get: operations["health"]; + /** Read one draft file's content */ + get: operations["readDraftFile_1"]; put?: never; post?: never; delete?: never; @@ -4433,46 +4725,50 @@ export interface paths { patch?: never; trace?: never; }; - "/api/v1/download": { + "/api/web/authoring/drafts/{draftId}": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get: operations["downloadByQuery"]; + /** Get one draft owned by the current user */ + get: operations["getAuthoringDraft"]; put?: never; post?: never; - delete?: never; + /** Delete a draft and its files */ + delete: operations["deleteAuthoringDraft"]; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/v1/download/{canonicalSlug}": { + "/api/v1/authoring/drafts/{draftId}": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get: operations["downloadByPath"]; + /** Get one draft owned by the current user */ + get: operations["getAuthoringDraft_1"]; put?: never; post?: never; - delete?: never; + /** Delete a draft and its files */ + delete: operations["deleteAuthoringDraft_1"]; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/v1/auth/providers": { + "/api/v1/whoami": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get: operations["providers"]; + get: operations["whoami"]; put?: never; post?: never; delete?: never; @@ -4481,14 +4777,14 @@ export interface paths { patch?: never; trace?: never; }; - "/api/v1/auth/methods": { + "/api/v1/skills/{skillId}/versions/{versionId}/security-audit": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get: operations["methods"]; + get: operations["getSecurityAudits"]; put?: never; post?: never; delete?: never; @@ -4497,17 +4793,209 @@ export interface paths { patch?: never; trace?: never; }; - "/api/v1/auth/me": { + "/api/v1/skills/{canonicalSlug}": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get: operations["me"]; + get: operations["getSkill"]; put?: never; post?: never; - delete?: never; + delete: operations["deleteSkill_2"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/search": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["search_1"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/resolve": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["resolveByQuery"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/resolve/{canonicalSlug}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["resolve"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/organizations": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["listOrganizations"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/organizations/{organizationId}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["getOrganization"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/organizations/{organizationId}/login-connections": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["listLoginConnections"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/health": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["health"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/download": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["downloadByQuery"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/download/{canonicalSlug}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["downloadByPath"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/auth/providers": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["providers"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/auth/methods": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["methods"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/auth/me": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["me"]; + put?: never; + post?: never; + delete?: never; options?: never; head?: never; patch?: never; @@ -5180,6 +5668,98 @@ export interface components { /** Format: date-time */ updatedAt?: string; }; + RuntimeBindingRequest: { + agentType: string; + config?: { + [key: string]: Record; + }; + toolAllowlist?: string[]; + mcpServers?: { + [key: string]: Record; + }[]; + }; + ApiResponseRuntimeBindingResponse: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["RuntimeBindingResponse"]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; + RuntimeBindingResponse: { + agentType?: string; + config?: { + [key: string]: Record; + }; + toolAllowlist?: string[]; + mcpServers?: { + [key: string]: Record; + }[]; + /** Format: date-time */ + updatedAt?: string; + }; + SaveDraftFileRequest: { + path: string; + content: string; + encoding?: string; + contentType?: string; + /** Format: int32 */ + expectedRevision?: number; + base64?: boolean; + }; + ApiResponseSaveDraftFileResponse: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["SaveDraftFileResponse"]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; + DraftFileResponse: { + /** Format: int64 */ + id?: number; + path?: string; + sha256?: string; + /** Format: int64 */ + size?: number; + contentType?: string; + /** Format: date-time */ + updatedAt?: string; + }; + DraftResponse: { + /** Format: int64 */ + id?: number; + /** Format: int64 */ + namespaceId?: number; + name?: string; + requirement?: string; + /** Format: int32 */ + revision?: number; + contentDigest?: string; + validated?: boolean; + /** Format: int32 */ + validatedRevision?: number; + /** Format: int64 */ + validatedRunId?: number; + /** Format: int64 */ + submittedSkillId?: number; + /** Format: int64 */ + submittedVersionId?: number; + /** Format: date-time */ + submittedAt?: string; + /** Format: date-time */ + createdAt?: string; + /** Format: date-time */ + updatedAt?: string; + }; + SaveDraftFileResponse: { + draft?: components["schemas"]["DraftResponse"]; + file?: components["schemas"]["DraftFileResponse"]; + created?: boolean; + revisionAdvanced?: boolean; + }; TokenExpirationUpdateRequest: { expiresAt?: string; }; @@ -5629,22 +6209,129 @@ export interface components { createdAt?: string; readAt?: string; }; - TokenCreateRequest: { - name: string; - scopes?: string[]; - expiresAt?: string; - }; - ApiResponseTokenCreateResponse: { + ApiResponseValidationFindingResponse: { /** Format: int32 */ code?: number; msg?: string; - data?: components["schemas"]["TokenCreateResponse"]; + data?: components["schemas"]["ValidationFindingResponse"]; /** Format: date-time */ timestamp?: string; requestId?: string; }; - TokenCreateResponse: { - token?: string; + FilePatch: { + filePath?: string; + oldSha256?: string; + oldValue?: string; + newValue?: string; + }; + FixSuggestion: { + patches?: components["schemas"]["FilePatch"][]; + description?: string; + }; + ValidationFindingResponse: { + /** Format: int64 */ + id?: number; + /** Format: int64 */ + runId?: number; + layer?: string; + ruleCode?: string; + severity?: string; + filePath?: string; + location?: string; + message?: string; + suggestion?: components["schemas"]["FixSuggestion"]; + status?: string; + /** Format: int32 */ + appliedRevision?: number; + /** Format: date-time */ + createdAt?: string; + }; + ApiResponseValidationRunResponse: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["ValidationRunResponse"]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; + ValidationRunResponse: { + /** Format: int64 */ + id?: number; + /** Format: int64 */ + draftId?: number; + /** Format: int32 */ + draftRevision?: number; + status?: string; + cancelRequested?: boolean; + active?: boolean; + terminal?: boolean; + /** Format: int32 */ + errorCount?: number; + /** Format: int32 */ + warningCount?: number; + triggeredBy?: string; + /** Format: date-time */ + startedAt?: string; + /** Format: date-time */ + finishedAt?: string; + /** Format: date-time */ + createdAt?: string; + summary?: { + [key: string]: Record; + }; + }; + SubmitDraftRequest: { + visibility?: string; + platformRoles?: string[]; + }; + ApiResponseSubmitDraftResponse: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["SubmitDraftResponse"]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; + SubmitDraftResponse: { + /** Format: int64 */ + skillId?: number; + /** Format: int64 */ + versionId?: number; + slug?: string; + version?: string; + }; + CreateDraftRequest: { + namespaceSlug: string; + name: string; + requirement?: string; + }; + ApiResponseDraftResponse: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["DraftResponse"]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; + TokenCreateRequest: { + name: string; + scopes?: string[]; + expiresAt?: string; + }; + ApiResponseTokenCreateResponse: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["TokenCreateResponse"]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; + TokenCreateResponse: { + token?: string; /** Format: int64 */ id?: number; name?: string; @@ -7006,6 +7693,87 @@ export interface components { /** Format: int32 */ size?: number; }; + ApiResponseListValidationFindingResponse: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["ValidationFindingResponse"][]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; + SseEmitter: { + /** Format: int64 */ + timeout?: number; + }; + ApiResponseListValidationEventResponse: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["ValidationEventResponse"][]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; + ValidationEventResponse: { + /** Format: int64 */ + id?: number; + /** Format: int64 */ + runId?: number; + /** Format: int32 */ + seq?: number; + type?: string; + phase?: string; + payload?: { + [key: string]: Record; + }; + /** Format: date-time */ + createdAt?: string; + }; + ApiResponseListValidationRunResponse: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["ValidationRunResponse"][]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; + ApiResponseDraftFileContentResponse: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["DraftFileContentResponse"]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; + DraftFileContentResponse: { + path?: string; + sha256?: string; + /** Format: int64 */ + size?: number; + contentType?: string; + content?: string; + }; + ApiResponseListDraftFileResponse: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["DraftFileResponse"][]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; + ApiResponseListDraftResponse: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["DraftResponse"][]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; ClawHubWhoamiResponse: { user?: components["schemas"]["User"]; }; @@ -8859,20 +9627,16 @@ export interface operations { }; }; }; - updateExpiration: { + getDraftRuntimeBinding: { parameters: { query?: never; header?: never; path: { - id: number; + draftId: number; }; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["TokenExpirationUpdateRequest"]; - }; - }; + requestBody?: never; responses: { /** @description OK */ 200: { @@ -8880,23 +9644,23 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseTokenSummaryResponse"]; + "*/*": components["schemas"]["ApiResponseRuntimeBindingResponse"]; }; }; }; }; - updateUserStatus: { + saveDraftRuntimeBinding: { parameters: { query?: never; header?: never; path: { - userId: string; + draftId: number; }; cookie?: never; }; requestBody: { content: { - "application/json": components["schemas"]["AdminUserStatusUpdateRequest"]; + "application/json": components["schemas"]["RuntimeBindingRequest"]; }; }; responses: { @@ -8906,25 +9670,21 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseAdminUserMutationResponse"]; + "*/*": components["schemas"]["ApiResponseRuntimeBindingResponse"]; }; }; }; }; - updateUserRole: { + getDraftRuntimeBinding_1: { parameters: { query?: never; header?: never; path: { - userId: string; + draftId: number; }; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["AdminUserRoleUpdateRequest"]; - }; - }; + requestBody?: never; responses: { /** @description OK */ 200: { @@ -8932,24 +9692,23 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseAdminUserMutationResponse"]; + "*/*": components["schemas"]["ApiResponseRuntimeBindingResponse"]; }; }; }; }; - updateMemberRole_2: { + saveDraftRuntimeBinding_1: { parameters: { query?: never; header?: never; path: { - slug: string; - userId: string; + draftId: number; }; cookie?: never; }; requestBody: { content: { - "application/json": components["schemas"]["UpdateMemberRoleRequest"]; + "application/json": components["schemas"]["RuntimeBindingRequest"]; }; }; responses: { @@ -8959,25 +9718,21 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseMemberResponse"]; + "*/*": components["schemas"]["ApiResponseRuntimeBindingResponse"]; }; }; }; }; - updateLabel: { + listDraftFiles: { parameters: { query?: never; header?: never; path: { - slug: string; + draftId: number; }; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["AdminLabelUpdateRequest"]; - }; - }; + requestBody?: never; responses: { /** @description OK */ 200: { @@ -8985,21 +9740,25 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseLabelDefinitionResponse"]; + "*/*": components["schemas"]["ApiResponseListDraftFileResponse"]; }; }; }; }; - deleteLabel: { + saveDraftFile: { parameters: { query?: never; header?: never; path: { - slug: string; + draftId: number; }; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["SaveDraftFileRequest"]; + }; + }; responses: { /** @description OK */ 200: { @@ -9007,23 +9766,24 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseMessageResponse"]; + "*/*": components["schemas"]["ApiResponseSaveDraftFileResponse"]; }; }; }; }; - updateSortOrder: { + deleteDraftFile: { parameters: { - query?: never; + query: { + path: string; + expectedRevision?: number; + }; header?: never; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["LabelSortOrderUpdateRequest"]; + path: { + draftId: number; }; + cookie?: never; }; + requestBody?: never; responses: { /** @description OK */ 200: { @@ -9031,124 +9791,374 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseListLabelDefinitionResponse"]; + "*/*": components["schemas"]["ApiResponseVoid"]; }; }; }; }; - yankSkillSuiteVersion: { + listDraftFiles_1: { parameters: { query?: never; header?: never; path: { - suiteId: number; - versionId: number; + draftId: number; }; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["SkillSuiteReasonRequest"]; - }; - }; + requestBody?: never; responses: { - /** @description Suite version yanked */ + /** @description OK */ 200: { headers: { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseMessageResponse"]; + "*/*": components["schemas"]["ApiResponseListDraftFileResponse"]; }; }; }; }; - yankSkillSuiteVersion_1: { + saveDraftFile_1: { parameters: { query?: never; header?: never; path: { - suiteId: number; - versionId: number; + draftId: number; }; cookie?: never; }; requestBody: { content: { - "application/json": components["schemas"]["SkillSuiteReasonRequest"]; + "application/json": components["schemas"]["SaveDraftFileRequest"]; }; }; responses: { - /** @description Suite version yanked */ + /** @description OK */ 200: { headers: { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseMessageResponse"]; + "*/*": components["schemas"]["ApiResponseSaveDraftFileResponse"]; }; }; }; }; - submitSkillSuiteReview: { + deleteDraftFile_1: { parameters: { - query?: never; + query: { + path: string; + expectedRevision?: number; + }; header?: never; path: { - suiteId: number; - versionId: number; + draftId: number; }; cookie?: never; }; requestBody?: never; responses: { - /** @description Suite draft submitted */ + /** @description OK */ 200: { headers: { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseMessageResponse"]; + "*/*": components["schemas"]["ApiResponseVoid"]; }; }; }; }; - submitSkillSuiteReview_1: { + updateExpiration: { parameters: { query?: never; header?: never; path: { - suiteId: number; - versionId: number; + id: number; }; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["TokenExpirationUpdateRequest"]; + }; + }; responses: { - /** @description Suite draft submitted */ + /** @description OK */ 200: { headers: { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseMessageResponse"]; + "*/*": components["schemas"]["ApiResponseTokenSummaryResponse"]; }; }; }; }; - reopenSkillSuiteDraft: { + updateUserStatus: { parameters: { query?: never; header?: never; path: { - suiteId: number; - versionId: number; + userId: string; }; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["AdminUserStatusUpdateRequest"]; + }; + }; responses: { - /** @description Suite draft reopened */ + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseAdminUserMutationResponse"]; + }; + }; + }; + }; + updateUserRole: { + parameters: { + query?: never; + header?: never; + path: { + userId: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["AdminUserRoleUpdateRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseAdminUserMutationResponse"]; + }; + }; + }; + }; + updateMemberRole_2: { + parameters: { + query?: never; + header?: never; + path: { + slug: string; + userId: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["UpdateMemberRoleRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseMemberResponse"]; + }; + }; + }; + }; + updateLabel: { + parameters: { + query?: never; + header?: never; + path: { + slug: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["AdminLabelUpdateRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseLabelDefinitionResponse"]; + }; + }; + }; + }; + deleteLabel: { + parameters: { + query?: never; + header?: never; + path: { + slug: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseMessageResponse"]; + }; + }; + }; + }; + updateSortOrder: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["LabelSortOrderUpdateRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseListLabelDefinitionResponse"]; + }; + }; + }; + }; + yankSkillSuiteVersion: { + parameters: { + query?: never; + header?: never; + path: { + suiteId: number; + versionId: number; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["SkillSuiteReasonRequest"]; + }; + }; + responses: { + /** @description Suite version yanked */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseMessageResponse"]; + }; + }; + }; + }; + yankSkillSuiteVersion_1: { + parameters: { + query?: never; + header?: never; + path: { + suiteId: number; + versionId: number; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["SkillSuiteReasonRequest"]; + }; + }; + responses: { + /** @description Suite version yanked */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseMessageResponse"]; + }; + }; + }; + }; + submitSkillSuiteReview: { + parameters: { + query?: never; + header?: never; + path: { + suiteId: number; + versionId: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Suite draft submitted */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseMessageResponse"]; + }; + }; + }; + }; + submitSkillSuiteReview_1: { + parameters: { + query?: never; + header?: never; + path: { + suiteId: number; + versionId: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Suite draft submitted */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseMessageResponse"]; + }; + }; + }; + }; + reopenSkillSuiteDraft: { + parameters: { + query?: never; + header?: never; + path: { + suiteId: number; + versionId: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Suite draft reopened */ 200: { headers: { [name: string]: unknown; @@ -11330,14 +12340,14 @@ export interface operations { }; }; }; - list_4: { + dismissFindingFix: { parameters: { - query?: { - page?: number; - size?: number; - }; + query?: never; header?: never; - path?: never; + path: { + runId: number; + findingId: number; + }; cookie?: never; }; requestBody?: never; @@ -11348,23 +12358,22 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponsePageResponseTokenSummaryResponse"]; + "*/*": components["schemas"]["ApiResponseValidationFindingResponse"]; }; }; }; }; - create: { + dismissFindingFix_1: { parameters: { query?: never; header?: never; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["TokenCreateRequest"]; + path: { + runId: number; + findingId: number; }; + cookie?: never; }; + requestBody?: never; responses: { /** @description OK */ 200: { @@ -11372,17 +12381,18 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseTokenCreateResponse"]; + "*/*": components["schemas"]["ApiResponseValidationFindingResponse"]; }; }; }; }; - starSkill_2: { + applyFindingFix: { parameters: { query?: never; header?: never; path: { - canonicalSlug: string; + runId: number; + findingId: number; }; cookie?: never; }; @@ -11394,17 +12404,18 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ClawHubStarResponse"]; + "*/*": components["schemas"]["ApiResponseValidationFindingResponse"]; }; }; }; }; - unstarSkill_2: { + applyFindingFix_1: { parameters: { query?: never; header?: never; path: { - canonicalSlug: string; + runId: number; + findingId: number; }; cookie?: never; }; @@ -11416,22 +12427,18 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ClawHubUnstarResponse"]; + "*/*": components["schemas"]["ApiResponseValidationFindingResponse"]; }; }; }; }; - listSkills: { + cancelValidationRun: { parameters: { - query?: { - page?: number; - limit?: number; - sort?: string; - /** @description Optional response expansions. Supported value: labels */ - include?: string[]; - }; + query?: never; header?: never; - path?: never; + path: { + runId: number; + }; cookie?: never; }; requestBody?: never; @@ -11442,20 +12449,18 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ClawHubSkillListResponse"]; + "*/*": components["schemas"]["ApiResponseValidationRunResponse"]; }; }; }; }; - publishSkill: { + cancelValidationRun_1: { parameters: { - query: { - payload: string; - files: string[]; - confirmWarnings?: boolean; - }; + query?: never; header?: never; - path?: never; + path: { + runId: number; + }; cookie?: never; }; requestBody?: never; @@ -11466,22 +12471,25 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ClawHubPublishResponse"]; + "*/*": components["schemas"]["ApiResponseValidationRunResponse"]; }; }; }; }; - retrySecurityScan: { + submitValidatedDraft: { parameters: { query?: never; header?: never; path: { - skillId: number; - versionId: number; + draftId: number; }; cookie?: never; }; - requestBody?: never; + requestBody?: { + content: { + "application/json": components["schemas"]["SubmitDraftRequest"]; + }; + }; responses: { /** @description OK */ 200: { @@ -11489,21 +12497,25 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseSkillLifecycleMutationResponse"]; + "*/*": components["schemas"]["ApiResponseSubmitDraftResponse"]; }; }; }; }; - undeleteSkill: { + submitValidatedDraft_1: { parameters: { query?: never; header?: never; path: { - canonicalSlug: string; + draftId: number; }; cookie?: never; }; - requestBody?: never; + requestBody?: { + content: { + "application/json": components["schemas"]["SubmitDraftRequest"]; + }; + }; responses: { /** @description OK */ 200: { @@ -11511,29 +12523,21 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ClawHubDeleteResponse"]; + "*/*": components["schemas"]["ApiResponseSubmitDraftResponse"]; }; }; }; }; - publish_2: { + listValidationRuns: { parameters: { - query: { - namespace: string; - confirmWarnings?: boolean; - }; + query?: never; header?: never; - path?: never; - cookie?: never; - }; - requestBody?: { - content: { - "application/json": { - /** Format: binary */ - file: string; - }; + path: { + draftId: number; }; + cookie?: never; }; + requestBody?: never; responses: { /** @description OK */ 200: { @@ -11541,23 +12545,21 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ClawHubPublishResponse"]; + "*/*": components["schemas"]["ApiResponseListValidationRunResponse"]; }; }; }; }; - authorizeDevice: { + startValidationRun: { parameters: { query?: never; header?: never; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["AuthorizeRequest"]; + path: { + draftId: number; }; + cookie?: never; }; + requestBody?: never; responses: { /** @description OK */ 200: { @@ -11565,23 +12567,21 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseMessageResponse"]; + "*/*": components["schemas"]["ApiResponseValidationRunResponse"]; }; }; }; }; - bootstrapSession: { + listValidationRuns_1: { parameters: { query?: never; header?: never; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["SessionBootstrapRequest"]; + path: { + draftId: number; }; + cookie?: never; }; + requestBody?: never; responses: { /** @description OK */ 200: { @@ -11589,23 +12589,21 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseAuthMeResponse"]; + "*/*": components["schemas"]["ApiResponseListValidationRunResponse"]; }; }; }; }; - register: { + startValidationRun_1: { parameters: { query?: never; header?: never; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["LocalRegisterRequest"]; + path: { + draftId: number; }; + cookie?: never; }; + requestBody?: never; responses: { /** @description OK */ 200: { @@ -11613,23 +12611,19 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseAuthMeResponse"]; + "*/*": components["schemas"]["ApiResponseValidationRunResponse"]; }; }; }; }; - requestPasswordReset: { + listAuthoringDrafts: { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["PasswordResetRequestDto"]; - }; - }; + requestBody?: never; responses: { /** @description OK */ 200: { @@ -11637,12 +12631,12 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseVoid"]; + "*/*": components["schemas"]["ApiResponseListDraftResponse"]; }; }; }; }; - confirmPasswordReset: { + createAuthoringDraft: { parameters: { query?: never; header?: never; @@ -11651,7 +12645,7 @@ export interface operations { }; requestBody: { content: { - "application/json": components["schemas"]["PasswordResetConfirmRequest"]; + "application/json": components["schemas"]["CreateDraftRequest"]; }; }; responses: { @@ -11661,23 +12655,19 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseVoid"]; + "*/*": components["schemas"]["ApiResponseDraftResponse"]; }; }; }; }; - login: { + listAuthoringDrafts_1: { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["LocalLoginRequest"]; - }; - }; + requestBody?: never; responses: { /** @description OK */ 200: { @@ -11685,12 +12675,12 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseAuthMeResponse"]; + "*/*": components["schemas"]["ApiResponseListDraftResponse"]; }; }; }; }; - changePassword: { + createAuthoringDraft_1: { parameters: { query?: never; header?: never; @@ -11699,7 +12689,7 @@ export interface operations { }; requestBody: { content: { - "application/json": components["schemas"]["ChangePasswordRequest"]; + "application/json": components["schemas"]["CreateDraftRequest"]; }; }; responses: { @@ -11709,23 +12699,22 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseVoid"]; + "*/*": components["schemas"]["ApiResponseDraftResponse"]; }; }; }; }; - directLogin: { + list_4: { parameters: { - query?: never; + query?: { + page?: number; + size?: number; + }; header?: never; path?: never; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["DirectLoginRequest"]; - }; - }; + requestBody?: never; responses: { /** @description OK */ 200: { @@ -11733,12 +12722,12 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseAuthMeResponse"]; + "*/*": components["schemas"]["ApiResponsePageResponseTokenSummaryResponse"]; }; }; }; }; - pollToken: { + create: { parameters: { query?: never; header?: never; @@ -11747,7 +12736,7 @@ export interface operations { }; requestBody: { content: { - "application/json": components["schemas"]["TokenRequest"]; + "application/json": components["schemas"]["TokenCreateRequest"]; }; }; responses: { @@ -11757,16 +12746,18 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseDeviceTokenResponse"]; + "*/*": components["schemas"]["ApiResponseTokenCreateResponse"]; }; }; }; }; - requestDeviceCode: { + starSkill_2: { parameters: { query?: never; header?: never; - path?: never; + path: { + canonicalSlug: string; + }; cookie?: never; }; requestBody?: never; @@ -11777,17 +12768,17 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseDeviceCodeResponse"]; + "*/*": components["schemas"]["ClawHubStarResponse"]; }; }; }; }; - triggerPasswordReset: { + unstarSkill_2: { parameters: { query?: never; header?: never; path: { - userId: string; + canonicalSlug: string; }; cookie?: never; }; @@ -11799,18 +12790,22 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseVoid"]; + "*/*": components["schemas"]["ClawHubUnstarResponse"]; }; }; }; }; - enableUser: { + listSkills: { parameters: { - query?: never; - header?: never; - path: { - userId: string; + query?: { + page?: number; + limit?: number; + sort?: string; + /** @description Optional response expansions. Supported value: labels */ + include?: string[]; }; + header?: never; + path?: never; cookie?: never; }; requestBody?: never; @@ -11821,18 +12816,20 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseAdminUserMutationResponse"]; + "*/*": components["schemas"]["ClawHubSkillListResponse"]; }; }; }; }; - disableUser: { + publishSkill: { parameters: { - query?: never; - header?: never; - path: { - userId: string; + query: { + payload: string; + files: string[]; + confirmWarnings?: boolean; }; + header?: never; + path?: never; cookie?: never; }; requestBody?: never; @@ -11843,17 +12840,18 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseAdminUserMutationResponse"]; + "*/*": components["schemas"]["ClawHubPublishResponse"]; }; }; }; }; - approveUser: { + retrySecurityScan: { parameters: { query?: never; header?: never; path: { - userId: string; + skillId: number; + versionId: number; }; cookie?: never; }; @@ -11865,17 +12863,17 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseAdminUserMutationResponse"]; + "*/*": components["schemas"]["ApiResponseSkillLifecycleMutationResponse"]; }; }; }; }; - unhideSkill: { + undeleteSkill: { parameters: { query?: never; header?: never; path: { - skillId: number; + canonicalSlug: string; }; cookie?: never; }; @@ -11887,23 +12885,27 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseAdminSkillMutationResponse"]; + "*/*": components["schemas"]["ClawHubDeleteResponse"]; }; }; }; }; - hideSkill: { + publish_2: { parameters: { - query?: never; - header?: never; - path: { - skillId: number; + query: { + namespace: string; + confirmWarnings?: boolean; }; + header?: never; + path?: never; cookie?: never; }; requestBody?: { content: { - "application/json": components["schemas"]["AdminSkillActionRequest"]; + "application/json": { + /** Format: binary */ + file: string; + }; }; }; responses: { @@ -11913,23 +12915,21 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseAdminSkillMutationResponse"]; + "*/*": components["schemas"]["ClawHubPublishResponse"]; }; }; }; }; - yankVersion_2: { + authorizeDevice: { parameters: { query?: never; header?: never; - path: { - versionId: number; - }; + path?: never; cookie?: never; }; - requestBody?: { + requestBody: { content: { - "application/json": components["schemas"]["AdminSkillActionRequest"]; + "application/json": components["schemas"]["AuthorizeRequest"]; }; }; responses: { @@ -11939,21 +12939,23 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseAdminSkillMutationResponse"]; + "*/*": components["schemas"]["ApiResponseMessageResponse"]; }; }; }; }; - restore: { + bootstrapSession: { parameters: { query?: never; header?: never; - path: { - reviewId: number; - }; + path?: never; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["SessionBootstrapRequest"]; + }; + }; responses: { /** @description OK */ 200: { @@ -11961,23 +12963,21 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseSkillReviewResponse"]; + "*/*": components["schemas"]["ApiResponseAuthMeResponse"]; }; }; }; }; - hide: { + register: { parameters: { query?: never; header?: never; - path: { - reviewId: number; - }; + path?: never; cookie?: never; }; - requestBody?: { + requestBody: { content: { - "application/json": components["schemas"]["SkillReviewModerationRequest"]; + "application/json": components["schemas"]["LocalRegisterRequest"]; }; }; responses: { @@ -11987,23 +12987,21 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseSkillReviewResponse"]; + "*/*": components["schemas"]["ApiResponseAuthMeResponse"]; }; }; }; }; - resolveReport: { + requestPasswordReset: { parameters: { query?: never; header?: never; - path: { - reportId: number; - }; + path?: never; cookie?: never; }; - requestBody?: { + requestBody: { content: { - "application/json": components["schemas"]["AdminSkillReportActionRequest"]; + "application/json": components["schemas"]["PasswordResetRequestDto"]; }; }; responses: { @@ -12013,23 +13011,21 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseSkillReportMutationResponse"]; + "*/*": components["schemas"]["ApiResponseVoid"]; }; }; }; }; - dismissReport: { + confirmPasswordReset: { parameters: { query?: never; header?: never; - path: { - reportId: number; - }; + path?: never; cookie?: never; }; - requestBody?: { + requestBody: { content: { - "application/json": components["schemas"]["AdminSkillReportActionRequest"]; + "application/json": components["schemas"]["PasswordResetConfirmRequest"]; }; }; responses: { @@ -12039,19 +13035,23 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseSkillReportMutationResponse"]; + "*/*": components["schemas"]["ApiResponseVoid"]; }; }; }; }; - rebuildAll: { + login: { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["LocalLoginRequest"]; + }; + }; responses: { /** @description OK */ 200: { @@ -12059,23 +13059,21 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseVoid"]; + "*/*": components["schemas"]["ApiResponseAuthMeResponse"]; }; }; }; }; - reject: { + changePassword: { parameters: { query?: never; header?: never; - path: { - id: number; - }; + path?: never; cookie?: never; }; requestBody: { content: { - "application/json": components["schemas"]["ProfileReviewRejectRequest"]; + "application/json": components["schemas"]["ChangePasswordRequest"]; }; }; responses: { @@ -12085,21 +13083,23 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseProfileReviewMutationResponse"]; + "*/*": components["schemas"]["ApiResponseVoid"]; }; }; }; }; - approve: { + directLogin: { parameters: { query?: never; header?: never; - path: { - id: number; - }; + path?: never; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["DirectLoginRequest"]; + }; + }; responses: { /** @description OK */ 200: { @@ -12107,12 +13107,12 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseProfileReviewMutationResponse"]; + "*/*": components["schemas"]["ApiResponseAuthMeResponse"]; }; }; }; }; - createOrganization: { + pollToken: { parameters: { query?: never; header?: never; @@ -12121,7 +13121,7 @@ export interface operations { }; requestBody: { content: { - "application/json": components["schemas"]["OrganizationCreateRequest"]; + "application/json": components["schemas"]["TokenRequest"]; }; }; responses: { @@ -12131,18 +13131,16 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseOrganizationCreateResponse"]; + "*/*": components["schemas"]["ApiResponseDeviceTokenResponse"]; }; }; }; }; - unfreezeNamespace_2: { + requestDeviceCode: { parameters: { query?: never; header?: never; - path: { - slug: string; - }; + path?: never; cookie?: never; }; requestBody?: never; @@ -12153,25 +13151,21 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseAdminNamespaceDetailResponse"]; + "*/*": components["schemas"]["ApiResponseDeviceCodeResponse"]; }; }; }; }; - transferOwnership_2: { + triggerPasswordReset: { parameters: { query?: never; header?: never; path: { - slug: string; + userId: string; }; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["TransferOwnershipRequest"]; - }; - }; + requestBody?: never; responses: { /** @description OK */ 200: { @@ -12179,17 +13173,17 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseMessageResponse"]; + "*/*": components["schemas"]["ApiResponseVoid"]; }; }; }; }; - restoreNamespace_2: { + enableUser: { parameters: { query?: never; header?: never; path: { - slug: string; + userId: string; }; cookie?: never; }; @@ -12201,20 +13195,17 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseAdminNamespaceDetailResponse"]; + "*/*": components["schemas"]["ApiResponseAdminUserMutationResponse"]; }; }; }; }; - listMembers_2: { + disableUser: { parameters: { - query?: { - page?: number; - size?: number; - }; + query?: never; header?: never; path: { - slug: string; + userId: string; }; cookie?: never; }; @@ -12226,25 +13217,21 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponsePageResponseMemberResponse"]; + "*/*": components["schemas"]["ApiResponseAdminUserMutationResponse"]; }; }; }; }; - addMember_2: { + approveUser: { parameters: { query?: never; header?: never; path: { - slug: string; + userId: string; }; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["MemberRequest"]; - }; - }; + requestBody?: never; responses: { /** @description OK */ 200: { @@ -12252,25 +13239,21 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseMemberResponse"]; + "*/*": components["schemas"]["ApiResponseAdminUserMutationResponse"]; }; }; }; }; - batchAddMembers_2: { + unhideSkill: { parameters: { query?: never; header?: never; path: { - slug: string; + skillId: number; }; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["BatchMemberRequest"]; - }; - }; + requestBody?: never; responses: { /** @description OK */ 200: { @@ -12278,23 +13261,23 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseBatchMemberResponse"]; + "*/*": components["schemas"]["ApiResponseAdminSkillMutationResponse"]; }; }; }; }; - freezeNamespace_2: { + hideSkill: { parameters: { query?: never; header?: never; path: { - slug: string; + skillId: number; }; cookie?: never; }; requestBody?: { content: { - "application/json": components["schemas"]["NamespaceLifecycleRequest"]; + "application/json": components["schemas"]["AdminSkillActionRequest"]; }; }; responses: { @@ -12304,23 +13287,23 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseAdminNamespaceDetailResponse"]; + "*/*": components["schemas"]["ApiResponseAdminSkillMutationResponse"]; }; }; }; }; - archiveNamespace_2: { + yankVersion_2: { parameters: { query?: never; header?: never; path: { - slug: string; + versionId: number; }; cookie?: never; }; requestBody?: { content: { - "application/json": components["schemas"]["NamespaceLifecycleRequest"]; + "application/json": components["schemas"]["AdminSkillActionRequest"]; }; }; responses: { @@ -12330,16 +13313,18 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseAdminNamespaceDetailResponse"]; + "*/*": components["schemas"]["ApiResponseAdminSkillMutationResponse"]; }; }; }; }; - listLabels_2: { + restore: { parameters: { query?: never; header?: never; - path?: never; + path: { + reviewId: number; + }; cookie?: never; }; requestBody?: never; @@ -12350,21 +13335,23 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseListLabelDefinitionResponse"]; + "*/*": components["schemas"]["ApiResponseSkillReviewResponse"]; }; }; }; }; - createLabel: { + hide: { parameters: { query?: never; header?: never; - path?: never; + path: { + reviewId: number; + }; cookie?: never; }; - requestBody: { + requestBody?: { content: { - "application/json": components["schemas"]["AdminLabelCreateRequest"]; + "application/json": components["schemas"]["SkillReviewModerationRequest"]; }; }; responses: { @@ -12374,21 +13361,23 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseLabelDefinitionResponse"]; + "*/*": components["schemas"]["ApiResponseSkillReviewResponse"]; }; }; }; }; - verify: { + resolveReport: { parameters: { query?: never; header?: never; - path?: never; + path: { + reportId: number; + }; cookie?: never; }; - requestBody: { + requestBody?: { content: { - "application/json": components["schemas"]["MergeVerifyRequest"]; + "application/json": components["schemas"]["AdminSkillReportActionRequest"]; }; }; responses: { @@ -12398,21 +13387,23 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseMessageResponse"]; + "*/*": components["schemas"]["ApiResponseSkillReportMutationResponse"]; }; }; }; }; - initiate: { + dismissReport: { parameters: { query?: never; header?: never; - path?: never; + path: { + reportId: number; + }; cookie?: never; }; - requestBody: { + requestBody?: { content: { - "application/json": components["schemas"]["MergeInitiateRequest"]; + "application/json": components["schemas"]["AdminSkillReportActionRequest"]; }; }; responses: { @@ -12422,23 +13413,19 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseMergeInitiateResponse"]; + "*/*": components["schemas"]["ApiResponseSkillReportMutationResponse"]; }; }; }; }; - confirm: { + rebuildAll: { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["ConfirmMergeRequest"]; - }; - }; + requestBody?: never; responses: { /** @description OK */ 200: { @@ -12446,21 +13433,23 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseMessageResponse"]; + "*/*": components["schemas"]["ApiResponseVoid"]; }; }; }; }; - cancel: { + reject: { parameters: { query?: never; header?: never; - path?: never; + path: { + id: number; + }; cookie?: never; }; requestBody: { content: { - "application/json": components["schemas"]["CancelMergeRequest"]; + "application/json": components["schemas"]["ProfileReviewRejectRequest"]; }; }; responses: { @@ -12470,29 +13459,21 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseMessageResponse"]; + "*/*": components["schemas"]["ApiResponseProfileReviewMutationResponse"]; }; }; }; }; - publish_3: { + approve: { parameters: { query?: never; header?: never; path: { - namespace: string; + id: number; }; cookie?: never; }; - requestBody?: { - content: { - "multipart/form-data": { - /** Format: binary */ - file: string; - visibility?: string; - }; - }; - }; + requestBody?: never; responses: { /** @description OK */ 200: { @@ -12500,27 +13481,21 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseCliPublishResponse"]; + "*/*": components["schemas"]["ApiResponseProfileReviewMutationResponse"]; }; }; }; }; - validatePublish: { + createOrganization: { parameters: { query?: never; header?: never; - path: { - namespace: string; - }; + path?: never; cookie?: never; }; - requestBody?: { + requestBody: { content: { - "multipart/form-data": { - /** Format: binary */ - file: string; - visibility?: string; - }; + "application/json": components["schemas"]["OrganizationCreateRequest"]; }; }; responses: { @@ -12530,16 +13505,18 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseCliDryRunResponse"]; + "*/*": components["schemas"]["ApiResponseOrganizationCreateResponse"]; }; }; }; }; - getProfile: { + unfreezeNamespace_2: { parameters: { query?: never; header?: never; - path?: never; + path: { + slug: string; + }; cookie?: never; }; requestBody?: never; @@ -12550,12 +13527,209 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseUserProfileResponse"]; + "*/*": components["schemas"]["ApiResponseAdminNamespaceDetailResponse"]; }; }; }; }; - updateProfile: { + transferOwnership_2: { + parameters: { + query?: never; + header?: never; + path: { + slug: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["TransferOwnershipRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseMessageResponse"]; + }; + }; + }; + }; + restoreNamespace_2: { + parameters: { + query?: never; + header?: never; + path: { + slug: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseAdminNamespaceDetailResponse"]; + }; + }; + }; + }; + listMembers_2: { + parameters: { + query?: { + page?: number; + size?: number; + }; + header?: never; + path: { + slug: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponsePageResponseMemberResponse"]; + }; + }; + }; + }; + addMember_2: { + parameters: { + query?: never; + header?: never; + path: { + slug: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["MemberRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseMemberResponse"]; + }; + }; + }; + }; + batchAddMembers_2: { + parameters: { + query?: never; + header?: never; + path: { + slug: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["BatchMemberRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseBatchMemberResponse"]; + }; + }; + }; + }; + freezeNamespace_2: { + parameters: { + query?: never; + header?: never; + path: { + slug: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["NamespaceLifecycleRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseAdminNamespaceDetailResponse"]; + }; + }; + }; + }; + archiveNamespace_2: { + parameters: { + query?: never; + header?: never; + path: { + slug: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["NamespaceLifecycleRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseAdminNamespaceDetailResponse"]; + }; + }; + }; + }; + listLabels_2: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseListLabelDefinitionResponse"]; + }; + }; + }; + }; + createLabel: { parameters: { query?: never; header?: never; @@ -12564,9 +13738,537 @@ export interface operations { }; requestBody: { content: { - "application/json": components["schemas"]["UpdateProfileRequest"]; + "application/json": components["schemas"]["AdminLabelCreateRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseLabelDefinitionResponse"]; + }; + }; + }; + }; + verify: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["MergeVerifyRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseMessageResponse"]; + }; + }; + }; + }; + initiate: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["MergeInitiateRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseMergeInitiateResponse"]; + }; + }; + }; + }; + confirm: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ConfirmMergeRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseMessageResponse"]; + }; + }; + }; + }; + cancel: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["CancelMergeRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseMessageResponse"]; + }; + }; + }; + }; + publish_3: { + parameters: { + query?: never; + header?: never; + path: { + namespace: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "multipart/form-data": { + /** Format: binary */ + file: string; + visibility?: string; + }; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseCliPublishResponse"]; + }; + }; + }; + }; + validatePublish: { + parameters: { + query?: never; + header?: never; + path: { + namespace: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "multipart/form-data": { + /** Format: binary */ + file: string; + visibility?: string; + }; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseCliDryRunResponse"]; + }; + }; + }; + }; + getProfile: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseUserProfileResponse"]; + }; + }; + }; + }; + updateProfile: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["UpdateProfileRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseUpdateProfileResponse"]; + }; + }; + }; + }; + listSkillSuiteVersions: { + parameters: { + query?: never; + header?: never; + path: { + namespace: string; + slug: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Suite version history returned */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseListSkillSuiteVersionSummaryResponse"]; + }; + }; + }; + }; + listSkillSuiteVersions_1: { + parameters: { + query?: never; + header?: never; + path: { + namespace: string; + slug: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Suite version history returned */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseListSkillSuiteVersionSummaryResponse"]; + }; + }; + }; + }; + listSkillSuiteLabels: { + parameters: { + query?: never; + header?: never; + path: { + namespace: string; + slug: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseListSkillLabelDto"]; + }; + }; + }; + }; + listSkillSuiteLabels_1: { + parameters: { + query?: never; + header?: never; + path: { + namespace: string; + slug: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseListSkillLabelDto"]; + }; + }; + }; + }; + getSkillSuite: { + parameters: { + query?: { + version?: string; + }; + header?: never; + path: { + namespace: string; + slug: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Suite version returned */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseSkillSuiteResponse"]; + }; + }; + }; + }; + getSkillSuite_1: { + parameters: { + query?: { + version?: string; + }; + header?: never; + path: { + namespace: string; + slug: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Suite version returned */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseSkillSuiteResponse"]; + }; + }; + }; + }; + searchSkillSuiteMemberCandidates: { + parameters: { + query: { + suiteNamespace: string; + visibility: "PUBLIC" | "NAMESPACE_ONLY" | "PRIVATE"; + q?: string; + size?: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Eligible member candidates returned */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseListSkillSuiteMemberCandidateResponse"]; + }; + }; + }; + }; + searchSkillSuiteMemberCandidates_1: { + parameters: { + query: { + suiteNamespace: string; + visibility: "PUBLIC" | "NAMESPACE_ONLY" | "PRIVATE"; + q?: string; + size?: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Eligible member candidates returned */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseListSkillSuiteMemberCandidateResponse"]; + }; + }; + }; + }; + getSkillSuiteBundleOperation: { + parameters: { + query?: never; + header?: never; + path: { + operationId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseSkillSuiteBundleOperationDetailResponse"]; + }; + }; + }; + }; + getSkillSuiteBundleOperation_1: { + parameters: { + query?: never; + header?: never; + path: { + operationId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseSkillSuiteBundleOperationDetailResponse"]; + }; + }; + }; + }; + listMySkillSuiteBundleOperations: { + parameters: { + query?: { + page?: number; + size?: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseSkillSuiteBundleOperationPageResponse"]; + }; + }; + }; + }; + listMySkillSuiteBundleOperations_1: { + parameters: { + query?: { + page?: number; + size?: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseSkillSuiteBundleOperationPageResponse"]; + }; + }; + }; + }; + listActiveSkillSuiteBundleOperations: { + parameters: { + query?: { + page?: number; + size?: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponsePageResponseSkillSuiteBundleOperationSummaryResponse"]; + }; + }; + }; + }; + listActiveSkillSuiteBundleOperations_1: { + parameters: { + query?: { + page?: number; + size?: number; }; + header?: never; + path?: never; + cookie?: never; }; + requestBody?: never; responses: { /** @description OK */ 200: { @@ -12574,64 +14276,74 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseUpdateProfileResponse"]; + "*/*": components["schemas"]["ApiResponsePageResponseSkillSuiteBundleOperationSummaryResponse"]; }; }; }; }; - listSkillSuiteVersions: { + search: { parameters: { - query?: never; - header?: never; - path: { - namespace: string; - slug: string; + query?: { + q?: string; + namespace?: string; + label?: string[]; + /** @description Optional response expansions. Supported value: labels */ + include?: string[]; + sort?: string; + page?: number; + size?: number; }; + header?: never; + path?: never; cookie?: never; }; requestBody?: never; responses: { - /** @description Suite version history returned */ + /** @description OK */ 200: { headers: { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseListSkillSuiteVersionSummaryResponse"]; + "*/*": components["schemas"]["ApiResponseSearchResponse"]; }; }; }; }; - listSkillSuiteVersions_1: { + list: { parameters: { - query?: never; + query?: { + page?: number; + size?: number; + }; header?: never; path: { - namespace: string; - slug: string; + skillId: number; }; cookie?: never; }; requestBody?: never; responses: { - /** @description Suite version history returned */ + /** @description OK */ 200: { headers: { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseListSkillSuiteVersionSummaryResponse"]; + "*/*": components["schemas"]["ApiResponsePageResponseSkillReviewResponse"]; }; }; }; }; - listSkillSuiteLabels: { + list_1: { parameters: { - query?: never; + query?: { + page?: number; + size?: number; + }; header?: never; path: { - namespace: string; - slug: string; + skillId: number; }; cookie?: never; }; @@ -12643,18 +14355,19 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseListSkillLabelDto"]; + "*/*": components["schemas"]["ApiResponsePageResponseSkillReviewResponse"]; }; }; }; }; - listSkillSuiteLabels_1: { + listFiles: { parameters: { query?: never; header?: never; path: { namespace: string; slug: string; + version: string; }; cookie?: never; }; @@ -12666,117 +14379,119 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseListSkillLabelDto"]; + "*/*": components["schemas"]["ApiResponseListSkillFileResponse"]; }; }; }; }; - getSkillSuite: { + listFiles_1: { parameters: { - query?: { - version?: string; - }; + query?: never; header?: never; path: { namespace: string; slug: string; + version: string; }; cookie?: never; }; requestBody?: never; responses: { - /** @description Suite version returned */ + /** @description OK */ 200: { headers: { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseSkillSuiteResponse"]; + "*/*": components["schemas"]["ApiResponseListSkillFileResponse"]; }; }; }; }; - getSkillSuite_1: { + getFileContent: { parameters: { - query?: { - version?: string; + query: { + path: string; }; header?: never; path: { namespace: string; slug: string; + version: string; }; cookie?: never; }; requestBody?: never; responses: { - /** @description Suite version returned */ + /** @description OK */ 200: { headers: { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseSkillSuiteResponse"]; + "*/*": string; }; }; }; }; - searchSkillSuiteMemberCandidates: { + getFileContent_1: { parameters: { query: { - suiteNamespace: string; - visibility: "PUBLIC" | "NAMESPACE_ONLY" | "PRIVATE"; - q?: string; - size?: number; + path: string; }; header?: never; - path?: never; + path: { + namespace: string; + slug: string; + version: string; + }; cookie?: never; }; requestBody?: never; responses: { - /** @description Eligible member candidates returned */ + /** @description OK */ 200: { headers: { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseListSkillSuiteMemberCandidateResponse"]; + "*/*": string; }; }; }; }; - searchSkillSuiteMemberCandidates_1: { + downloadVersion: { parameters: { - query: { - suiteNamespace: string; - visibility: "PUBLIC" | "NAMESPACE_ONLY" | "PRIVATE"; - q?: string; - size?: number; - }; + query?: never; header?: never; - path?: never; + path: { + namespace: string; + slug: string; + version: string; + }; cookie?: never; }; requestBody?: never; responses: { - /** @description Eligible member candidates returned */ + /** @description OK */ 200: { headers: { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseListSkillSuiteMemberCandidateResponse"]; + "*/*": string; }; }; }; }; - getSkillSuiteBundleOperation: { + downloadVersion_1: { parameters: { query?: never; header?: never; path: { - operationId: string; + namespace: string; + slug: string; + version: string; }; cookie?: never; }; @@ -12788,17 +14503,19 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseSkillSuiteBundleOperationDetailResponse"]; + "*/*": string; }; }; }; }; - getSkillSuiteBundleOperation_1: { + getVersionDetail: { parameters: { query?: never; header?: never; path: { - operationId: string; + namespace: string; + slug: string; + version: string; }; cookie?: never; }; @@ -12810,19 +14527,20 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseSkillSuiteBundleOperationDetailResponse"]; + "*/*": components["schemas"]["ApiResponseSkillVersionDetailResponse"]; }; }; }; }; - listMySkillSuiteBundleOperations: { + deleteVersion: { parameters: { - query?: { - page?: number; - size?: number; - }; + query?: never; header?: never; - path?: never; + path: { + namespace: string; + slug: string; + version: string; + }; cookie?: never; }; requestBody?: never; @@ -12833,19 +14551,20 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseSkillSuiteBundleOperationPageResponse"]; + "*/*": components["schemas"]["ApiResponseSkillLifecycleMutationResponse"]; }; }; }; }; - listMySkillSuiteBundleOperations_1: { + getVersionDetail_1: { parameters: { - query?: { - page?: number; - size?: number; - }; + query?: never; header?: never; - path?: never; + path: { + namespace: string; + slug: string; + version: string; + }; cookie?: never; }; requestBody?: never; @@ -12856,19 +14575,20 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseSkillSuiteBundleOperationPageResponse"]; + "*/*": components["schemas"]["ApiResponseSkillVersionDetailResponse"]; }; }; }; }; - listActiveSkillSuiteBundleOperations: { + deleteVersion_1: { parameters: { - query?: { - page?: number; - size?: number; - }; + query?: never; header?: never; - path?: never; + path: { + namespace: string; + slug: string; + version: string; + }; cookie?: never; }; requestBody?: never; @@ -12879,19 +14599,22 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponsePageResponseSkillSuiteBundleOperationSummaryResponse"]; + "*/*": components["schemas"]["ApiResponseSkillLifecycleMutationResponse"]; }; }; }; }; - listActiveSkillSuiteBundleOperations_1: { + compareVersions: { parameters: { - query?: { - page?: number; - size?: number; + query: { + from: string; + to: string; }; header?: never; - path?: never; + path: { + namespace: string; + slug: string; + }; cookie?: never; }; requestBody?: never; @@ -12902,25 +14625,22 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponsePageResponseSkillSuiteBundleOperationSummaryResponse"]; + "*/*": components["schemas"]["ApiResponseSkillVersionCompareResponse"]; }; }; }; }; - search: { + compareVersions_1: { parameters: { - query?: { - q?: string; - namespace?: string; - label?: string[]; - /** @description Optional response expansions. Supported value: labels */ - include?: string[]; - sort?: string; - page?: number; - size?: number; + query: { + from: string; + to: string; }; header?: never; - path?: never; + path: { + namespace: string; + slug: string; + }; cookie?: never; }; requestBody?: never; @@ -12931,12 +14651,12 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseSearchResponse"]; + "*/*": components["schemas"]["ApiResponseSkillVersionCompareResponse"]; }; }; }; }; - list: { + listVersions: { parameters: { query?: { page?: number; @@ -12944,7 +14664,8 @@ export interface operations { }; header?: never; path: { - skillId: number; + namespace: string; + slug: string; }; cookie?: never; }; @@ -12956,12 +14677,12 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponsePageResponseSkillReviewResponse"]; + "*/*": components["schemas"]["ApiResponsePageResponseSkillVersionResponse"]; }; }; }; }; - list_1: { + listVersions_1: { parameters: { query?: { page?: number; @@ -12969,7 +14690,8 @@ export interface operations { }; header?: never; path: { - skillId: number; + namespace: string; + slug: string; }; cookie?: never; }; @@ -12981,19 +14703,19 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponsePageResponseSkillReviewResponse"]; + "*/*": components["schemas"]["ApiResponsePageResponseSkillVersionResponse"]; }; }; }; }; - listFiles: { + listFilesByTag: { parameters: { query?: never; header?: never; path: { namespace: string; slug: string; - version: string; + tagName: string; }; cookie?: never; }; @@ -13010,14 +14732,14 @@ export interface operations { }; }; }; - listFiles_1: { + listFilesByTag_1: { parameters: { query?: never; header?: never; path: { namespace: string; slug: string; - version: string; + tagName: string; }; cookie?: never; }; @@ -13034,7 +14756,7 @@ export interface operations { }; }; }; - getFileContent: { + getFileContentByTag: { parameters: { query: { path: string; @@ -13043,7 +14765,7 @@ export interface operations { path: { namespace: string; slug: string; - version: string; + tagName: string; }; cookie?: never; }; @@ -13060,7 +14782,7 @@ export interface operations { }; }; }; - getFileContent_1: { + getFileContentByTag_1: { parameters: { query: { path: string; @@ -13069,7 +14791,7 @@ export interface operations { path: { namespace: string; slug: string; - version: string; + tagName: string; }; cookie?: never; }; @@ -13086,14 +14808,14 @@ export interface operations { }; }; }; - downloadVersion: { + downloadByTag: { parameters: { query?: never; header?: never; path: { namespace: string; slug: string; - version: string; + tagName: string; }; cookie?: never; }; @@ -13110,14 +14832,14 @@ export interface operations { }; }; }; - downloadVersion_1: { + downloadByTag_1: { parameters: { query?: never; header?: never; path: { namespace: string; slug: string; - version: string; + tagName: string; }; cookie?: never; }; @@ -13134,14 +14856,13 @@ export interface operations { }; }; }; - getVersionDetail: { + listTags: { parameters: { query?: never; header?: never; path: { namespace: string; slug: string; - version: string; }; cookie?: never; }; @@ -13153,19 +14874,18 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseSkillVersionDetailResponse"]; + "*/*": components["schemas"]["ApiResponseListTagResponse"]; }; }; }; }; - deleteVersion: { + listTags_1: { parameters: { query?: never; header?: never; path: { namespace: string; slug: string; - version: string; }; cookie?: never; }; @@ -13177,19 +14897,21 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseSkillLifecycleMutationResponse"]; + "*/*": components["schemas"]["ApiResponseListTagResponse"]; }; }; }; }; - getVersionDetail_1: { + listSuiteMemberships: { parameters: { - query?: never; + query?: { + page?: number; + size?: number; + }; header?: never; path: { namespace: string; slug: string; - version: string; }; cookie?: never; }; @@ -13201,19 +14923,21 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseSkillVersionDetailResponse"]; + "*/*": components["schemas"]["ApiResponsePageResponseSkillSuiteReferenceResponse"]; }; }; }; }; - deleteVersion_1: { + listSuiteMemberships_1: { parameters: { - query?: never; + query?: { + page?: number; + size?: number; + }; header?: never; path: { namespace: string; slug: string; - version: string; }; cookie?: never; }; @@ -13225,16 +14949,17 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseSkillLifecycleMutationResponse"]; + "*/*": components["schemas"]["ApiResponsePageResponseSkillSuiteReferenceResponse"]; }; }; }; }; - compareVersions: { + resolveVersion: { parameters: { - query: { - from: string; - to: string; + query?: { + version?: string; + tag?: string; + hash?: string; }; header?: never; path: { @@ -13251,16 +14976,17 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseSkillVersionCompareResponse"]; + "*/*": components["schemas"]["ApiResponseResolveVersionResponse"]; }; }; }; }; - compareVersions_1: { + resolveVersion_1: { parameters: { - query: { - from: string; - to: string; + query?: { + version?: string; + tag?: string; + hash?: string; }; header?: never; path: { @@ -13277,17 +15003,14 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseSkillVersionCompareResponse"]; + "*/*": components["schemas"]["ApiResponseResolveVersionResponse"]; }; }; }; }; - listVersions: { + listLabels: { parameters: { - query?: { - page?: number; - size?: number; - }; + query?: never; header?: never; path: { namespace: string; @@ -13303,17 +15026,14 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponsePageResponseSkillVersionResponse"]; + "*/*": components["schemas"]["ApiResponseListSkillLabelDto"]; }; }; }; }; - listVersions_1: { + listLabels_1: { parameters: { - query?: { - page?: number; - size?: number; - }; + query?: never; header?: never; path: { namespace: string; @@ -13329,19 +15049,18 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponsePageResponseSkillVersionResponse"]; + "*/*": components["schemas"]["ApiResponseListSkillLabelDto"]; }; }; }; }; - listFilesByTag: { + downloadLatest: { parameters: { query?: never; header?: never; path: { namespace: string; slug: string; - tagName: string; }; cookie?: never; }; @@ -13353,19 +15072,18 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseListSkillFileResponse"]; + "*/*": string; }; }; }; }; - listFilesByTag_1: { + downloadLatest_1: { parameters: { query?: never; header?: never; path: { namespace: string; slug: string; - tagName: string; }; cookie?: never; }; @@ -13377,21 +15095,18 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseListSkillFileResponse"]; + "*/*": string; }; }; }; }; - getFileContentByTag: { + getSkillDetail: { parameters: { - query: { - path: string; - }; + query?: never; header?: never; path: { namespace: string; slug: string; - tagName: string; }; cookie?: never; }; @@ -13403,21 +15118,20 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": string; + "*/*": components["schemas"]["ApiResponseSkillDetailResponse"]; }; }; }; }; - getFileContentByTag_1: { + deleteSkill_1: { parameters: { - query: { - path: string; + query?: { + ownerId?: string; }; header?: never; path: { namespace: string; slug: string; - tagName: string; }; cookie?: never; }; @@ -13429,19 +15143,18 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": string; + "*/*": components["schemas"]["ApiResponseSkillDeleteResponse"]; }; }; }; }; - downloadByTag: { + getSkillDetail_1: { parameters: { query?: never; header?: never; path: { namespace: string; slug: string; - tagName: string; }; cookie?: never; }; @@ -13453,19 +15166,20 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": string; + "*/*": components["schemas"]["ApiResponseSkillDetailResponse"]; }; }; }; }; - downloadByTag_1: { + deleteSkill: { parameters: { - query?: never; + query?: { + ownerId?: string; + }; header?: never; path: { namespace: string; slug: string; - tagName: string; }; cookie?: never; }; @@ -13477,18 +15191,17 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": string; + "*/*": components["schemas"]["ApiResponseSkillDeleteResponse"]; }; }; }; }; - listTags: { + getReviewSkillDetail: { parameters: { query?: never; header?: never; path: { - namespace: string; - slug: string; + id: number; }; cookie?: never; }; @@ -13500,18 +15213,17 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseListTagResponse"]; + "*/*": components["schemas"]["ApiResponseReviewSkillDetailResponse"]; }; }; }; }; - listTags_1: { + getReviewSkillDetail_1: { parameters: { query?: never; header?: never; path: { - namespace: string; - slug: string; + id: number; }; cookie?: never; }; @@ -13523,21 +15235,19 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseListTagResponse"]; + "*/*": components["schemas"]["ApiResponseReviewSkillDetailResponse"]; }; }; }; }; - listSuiteMemberships: { + getReviewFile: { parameters: { - query?: { - page?: number; - size?: number; + query: { + path: string; }; header?: never; path: { - namespace: string; - slug: string; + id: number; }; cookie?: never; }; @@ -13549,21 +15259,19 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponsePageResponseSkillSuiteReferenceResponse"]; + "*/*": string; }; }; }; }; - listSuiteMemberships_1: { + getReviewFile_1: { parameters: { - query?: { - page?: number; - size?: number; + query: { + path: string; }; header?: never; path: { - namespace: string; - slug: string; + id: number; }; cookie?: never; }; @@ -13575,22 +15283,17 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponsePageResponseSkillSuiteReferenceResponse"]; + "*/*": string; }; }; }; }; - resolveVersion: { + downloadReviewVersion: { parameters: { - query?: { - version?: string; - tag?: string; - hash?: string; - }; + query?: never; header?: never; path: { - namespace: string; - slug: string; + id: number; }; cookie?: never; }; @@ -13602,22 +15305,17 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseResolveVersionResponse"]; + "*/*": string; }; }; }; }; - resolveVersion_1: { + downloadReviewVersion_1: { parameters: { - query?: { - version?: string; - tag?: string; - hash?: string; - }; + query?: never; header?: never; path: { - namespace: string; - slug: string; + id: number; }; cookie?: never; }; @@ -13629,18 +15327,17 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseResolveVersionResponse"]; + "*/*": string; }; }; }; }; - listLabels: { + listReviewAttempts: { parameters: { query?: never; header?: never; path: { - namespace: string; - slug: string; + id: number; }; cookie?: never; }; @@ -13652,18 +15349,17 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseListSkillLabelDto"]; + "*/*": components["schemas"]["ApiResponseListReviewTaskResponse"]; }; }; }; }; - listLabels_1: { + listReviewAttempts_1: { parameters: { query?: never; header?: never; path: { - namespace: string; - slug: string; + id: number; }; cookie?: never; }; @@ -13675,18 +15371,17 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseListSkillLabelDto"]; + "*/*": components["schemas"]["ApiResponseListReviewTaskResponse"]; }; }; }; }; - downloadLatest: { + getReviewDetail: { parameters: { query?: never; header?: never; path: { - namespace: string; - slug: string; + id: number; }; cookie?: never; }; @@ -13698,18 +15393,17 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": string; + "*/*": components["schemas"]["ApiResponseReviewTaskResponse"]; }; }; }; }; - downloadLatest_1: { + getReviewDetail_1: { parameters: { query?: never; header?: never; path: { - namespace: string; - slug: string; + id: number; }; cookie?: never; }; @@ -13721,19 +15415,20 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": string; + "*/*": components["schemas"]["ApiResponseReviewTaskResponse"]; }; }; }; }; - getSkillDetail: { + listPendingReviews: { parameters: { - query?: never; - header?: never; - path: { - namespace: string; - slug: string; + query: { + namespaceId: number; + page?: number; + size?: number; }; + header?: never; + path?: never; cookie?: never; }; requestBody?: never; @@ -13744,21 +15439,20 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseSkillDetailResponse"]; + "*/*": components["schemas"]["ApiResponsePageResponseReviewTaskResponse"]; }; }; }; }; - deleteSkill_1: { + listPendingReviews_1: { parameters: { - query?: { - ownerId?: string; + query: { + namespaceId: number; + page?: number; + size?: number; }; header?: never; - path: { - namespace: string; - slug: string; - }; + path?: never; cookie?: never; }; requestBody?: never; @@ -13769,19 +15463,19 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseSkillDeleteResponse"]; + "*/*": components["schemas"]["ApiResponsePageResponseReviewTaskResponse"]; }; }; }; }; - getSkillDetail_1: { + listMySubmissions: { parameters: { - query?: never; - header?: never; - path: { - namespace: string; - slug: string; + query?: { + page?: number; + size?: number; }; + header?: never; + path?: never; cookie?: never; }; requestBody?: never; @@ -13792,21 +15486,19 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseSkillDetailResponse"]; + "*/*": components["schemas"]["ApiResponsePageResponseReviewTaskResponse"]; }; }; }; }; - deleteSkill: { + listMySubmissions_1: { parameters: { query?: { - ownerId?: string; + page?: number; + size?: number; }; header?: never; - path: { - namespace: string; - slug: string; - }; + path?: never; cookie?: never; }; requestBody?: never; @@ -13817,12 +15509,12 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseSkillDeleteResponse"]; + "*/*": components["schemas"]["ApiResponsePageResponseReviewTaskResponse"]; }; }; }; }; - getReviewSkillDetail: { + listMyAttempts: { parameters: { query?: never; header?: never; @@ -13839,12 +15531,12 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseReviewSkillDetailResponse"]; + "*/*": components["schemas"]["ApiResponseListReviewTaskResponse"]; }; }; }; }; - getReviewSkillDetail_1: { + listMyAttempts_1: { parameters: { query?: never; header?: never; @@ -13861,20 +15553,22 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseReviewSkillDetailResponse"]; + "*/*": components["schemas"]["ApiResponseListReviewTaskResponse"]; }; }; }; }; - getReviewFile: { + listMyProgress: { parameters: { - query: { - path: string; + query?: { + subjectType?: string; + status?: string; + q?: string; + page?: number; + size?: number; }; header?: never; - path: { - id: number; - }; + path?: never; cookie?: never; }; requestBody?: never; @@ -13885,20 +15579,22 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": string; + "*/*": components["schemas"]["ApiResponseReviewProgressPageResponse"]; }; }; }; }; - getReviewFile_1: { + listMyProgress_1: { parameters: { - query: { - path: string; + query?: { + subjectType?: string; + status?: string; + q?: string; + page?: number; + size?: number; }; header?: never; - path: { - id: number; - }; + path?: never; cookie?: never; }; requestBody?: never; @@ -13909,56 +15605,68 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": string; + "*/*": components["schemas"]["ApiResponseReviewProgressPageResponse"]; }; }; }; }; - downloadReviewVersion: { + searchResources: { parameters: { - query?: never; - header?: never; - path: { - id: number; + query?: { + q?: string; + namespace?: string; + resourceType?: string; + sort?: string; + page?: number; + size?: number; + label?: string[]; }; + header?: never; + path?: never; cookie?: never; }; requestBody?: never; responses: { - /** @description OK */ + /** @description Resource page returned */ 200: { headers: { [name: string]: unknown; }; content: { - "*/*": string; + "*/*": components["schemas"]["ApiResponseResourceSearchResponse"]; }; }; }; }; - downloadReviewVersion_1: { + searchResources_1: { parameters: { - query?: never; - header?: never; - path: { - id: number; + query?: { + q?: string; + namespace?: string; + resourceType?: string; + sort?: string; + page?: number; + size?: number; + label?: string[]; }; + header?: never; + path?: never; cookie?: never; }; requestBody?: never; responses: { - /** @description OK */ + /** @description Resource page returned */ 200: { headers: { [name: string]: unknown; }; content: { - "*/*": string; + "*/*": components["schemas"]["ApiResponseResourceSearchResponse"]; }; }; }; }; - listReviewAttempts: { + getPromotionDetail: { parameters: { query?: never; header?: never; @@ -13975,12 +15683,12 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseListReviewTaskResponse"]; + "*/*": components["schemas"]["ApiResponsePromotionResponseDto"]; }; }; }; }; - listReviewAttempts_1: { + getPromotionDetail_1: { parameters: { query?: never; header?: never; @@ -13997,18 +15705,19 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseListReviewTaskResponse"]; + "*/*": components["schemas"]["ApiResponsePromotionResponseDto"]; }; }; }; }; - getReviewDetail: { + listPendingPromotions: { parameters: { - query?: never; - header?: never; - path: { - id: number; + query?: { + page?: number; + size?: number; }; + header?: never; + path?: never; cookie?: never; }; requestBody?: never; @@ -14019,18 +15728,19 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseReviewTaskResponse"]; + "*/*": components["schemas"]["ApiResponsePageResponsePromotionResponseDto"]; }; }; }; }; - getReviewDetail_1: { + listPendingPromotions_1: { parameters: { - query?: never; - header?: never; - path: { - id: number; + query?: { + page?: number; + size?: number; }; + header?: never; + path?: never; cookie?: never; }; requestBody?: never; @@ -14041,18 +15751,14 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseReviewTaskResponse"]; + "*/*": components["schemas"]["ApiResponsePageResponsePromotionResponseDto"]; }; }; }; }; - listPendingReviews: { + unreadCount: { parameters: { - query: { - namespaceId: number; - page?: number; - size?: number; - }; + query?: never; header?: never; path?: never; cookie?: never; @@ -14065,18 +15771,14 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponsePageResponseReviewTaskResponse"]; + "*/*": components["schemas"]["ApiResponseMapStringLong"]; }; }; }; }; - listPendingReviews_1: { + unreadCount_1: { parameters: { - query: { - namespaceId: number; - page?: number; - size?: number; - }; + query?: never; header?: never; path?: never; cookie?: never; @@ -14089,14 +15791,15 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponsePageResponseReviewTaskResponse"]; + "*/*": components["schemas"]["ApiResponseMapStringLong"]; }; }; }; }; - listMySubmissions: { + list_2: { parameters: { query?: { + category?: string; page?: number; size?: number; }; @@ -14112,14 +15815,15 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponsePageResponseReviewTaskResponse"]; + "*/*": components["schemas"]["ApiResponsePageResponseNotificationResponse"]; }; }; }; }; - listMySubmissions_1: { + list_3: { parameters: { query?: { + category?: string; page?: number; size?: number; }; @@ -14135,17 +15839,20 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponsePageResponseReviewTaskResponse"]; + "*/*": components["schemas"]["ApiResponsePageResponseNotificationResponse"]; }; }; }; }; - listMyAttempts: { + searchMemberCandidates: { parameters: { - query?: never; + query: { + search: string; + size?: number; + }; header?: never; path: { - id: number; + slug: string; }; cookie?: never; }; @@ -14157,17 +15864,20 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseListReviewTaskResponse"]; + "*/*": components["schemas"]["ApiResponseListNamespaceCandidateUserResponse"]; }; }; }; }; - listMyAttempts_1: { + searchMemberCandidates_1: { parameters: { - query?: never; + query: { + search: string; + size?: number; + }; header?: never; path: { - id: number; + slug: string; }; cookie?: never; }; @@ -14179,17 +15889,16 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseListReviewTaskResponse"]; + "*/*": components["schemas"]["ApiResponseListNamespaceCandidateUserResponse"]; }; }; }; }; - listMyProgress: { + listMySkillSuiteWorkspace: { parameters: { query?: { - subjectType?: string; - status?: string; q?: string; + state?: string; page?: number; size?: number; }; @@ -14205,17 +15914,16 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseReviewProgressPageResponse"]; + "*/*": components["schemas"]["ApiResponseMySkillSuiteWorkspaceResponse"]; }; }; }; }; - listMyProgress_1: { + listMySkillSuiteWorkspace_1: { parameters: { query?: { - subjectType?: string; - status?: string; q?: string; + state?: string; page?: number; size?: number; }; @@ -14231,21 +15939,17 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseReviewProgressPageResponse"]; + "*/*": components["schemas"]["ApiResponseMySkillSuiteWorkspaceResponse"]; }; }; }; }; - searchResources: { + listMySkillSuites: { parameters: { query?: { q?: string; - namespace?: string; - resourceType?: string; - sort?: string; page?: number; size?: number; - label?: string[]; }; header?: never; path?: never; @@ -14253,27 +15957,23 @@ export interface operations { }; requestBody?: never; responses: { - /** @description Resource page returned */ + /** @description Manageable Suite page returned */ 200: { headers: { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseResourceSearchResponse"]; + "*/*": components["schemas"]["ApiResponsePageResponseMySkillSuiteSummaryResponse"]; }; }; }; }; - searchResources_1: { + listMySkillSuites_1: { parameters: { query?: { q?: string; - namespace?: string; - resourceType?: string; - sort?: string; page?: number; size?: number; - label?: string[]; }; header?: never; path?: never; @@ -14281,24 +15981,25 @@ export interface operations { }; requestBody?: never; responses: { - /** @description Resource page returned */ + /** @description Manageable Suite page returned */ 200: { headers: { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseResourceSearchResponse"]; + "*/*": components["schemas"]["ApiResponsePageResponseMySkillSuiteSummaryResponse"]; }; }; }; }; - getPromotionDetail: { + listMySubscriptions: { parameters: { - query?: never; - header?: never; - path: { - id: number; + query?: { + page?: number; + size?: number; }; + header?: never; + path?: never; cookie?: never; }; requestBody?: never; @@ -14309,18 +16010,19 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponsePromotionResponseDto"]; + "*/*": components["schemas"]["ApiResponsePageResponseSkillSummaryResponse"]; }; }; }; }; - getPromotionDetail_1: { + listMySubscriptions_1: { parameters: { - query?: never; - header?: never; - path: { - id: number; + query?: { + page?: number; + size?: number; }; + header?: never; + path?: never; cookie?: never; }; requestBody?: never; @@ -14331,12 +16033,12 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponsePromotionResponseDto"]; + "*/*": components["schemas"]["ApiResponsePageResponseSkillSummaryResponse"]; }; }; }; }; - listPendingPromotions: { + listMyStars: { parameters: { query?: { page?: number; @@ -14354,12 +16056,12 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponsePageResponsePromotionResponseDto"]; + "*/*": components["schemas"]["ApiResponsePageResponseSkillSummaryResponse"]; }; }; }; }; - listPendingPromotions_1: { + listMyStars_1: { parameters: { query?: { page?: number; @@ -14377,14 +16079,20 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponsePageResponsePromotionResponseDto"]; + "*/*": components["schemas"]["ApiResponsePageResponseSkillSummaryResponse"]; }; }; }; }; - unreadCount: { + listMySkills: { parameters: { - query?: never; + query?: { + page?: number; + size?: number; + filter?: string; + q?: string; + namespace?: string; + }; header?: never; path?: never; cookie?: never; @@ -14397,14 +16105,20 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseMapStringLong"]; + "*/*": components["schemas"]["ApiResponsePageResponseSkillSummaryResponse"]; }; }; }; }; - unreadCount_1: { + listMySkills_1: { parameters: { - query?: never; + query?: { + page?: number; + size?: number; + filter?: string; + q?: string; + namespace?: string; + }; header?: never; path?: never; cookie?: never; @@ -14417,17 +16131,15 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseMapStringLong"]; + "*/*": components["schemas"]["ApiResponsePageResponseSkillSummaryResponse"]; }; }; }; }; - list_2: { + listMyNamespacesPage: { parameters: { - query?: { - category?: string; - page?: number; - size?: number; + query: { + pageable: components["schemas"]["Pageable"]; }; header?: never; path?: never; @@ -14441,17 +16153,15 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponsePageResponseNotificationResponse"]; + "*/*": components["schemas"]["ApiResponsePageResponseMyNamespaceResponse"]; }; }; }; }; - list_3: { + listMyNamespacesPage_1: { parameters: { - query?: { - category?: string; - page?: number; - size?: number; + query: { + pageable: components["schemas"]["Pageable"]; }; header?: never; path?: never; @@ -14465,21 +16175,16 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponsePageResponseNotificationResponse"]; + "*/*": components["schemas"]["ApiResponsePageResponseMyNamespaceResponse"]; }; }; }; }; - searchMemberCandidates: { + listMyNamespaces: { parameters: { - query: { - search: string; - size?: number; - }; + query?: never; header?: never; - path: { - slug: string; - }; + path?: never; cookie?: never; }; requestBody?: never; @@ -14490,21 +16195,16 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseListNamespaceCandidateUserResponse"]; + "*/*": components["schemas"]["ApiResponseListMyNamespaceResponse"]; }; }; }; }; - searchMemberCandidates_1: { + listMyNamespaces_1: { parameters: { - query: { - search: string; - size?: number; - }; + query?: never; header?: never; - path: { - slug: string; - }; + path?: never; cookie?: never; }; requestBody?: never; @@ -14515,19 +16215,14 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseListNamespaceCandidateUserResponse"]; + "*/*": components["schemas"]["ApiResponseListMyNamespaceResponse"]; }; }; }; }; - listMySkillSuiteWorkspace: { + listVisibleLabels: { parameters: { - query?: { - q?: string; - state?: string; - page?: number; - size?: number; - }; + query?: never; header?: never; path?: never; cookie?: never; @@ -14540,19 +16235,14 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseMySkillSuiteWorkspaceResponse"]; + "*/*": components["schemas"]["ApiResponseListSkillLabelDto"]; }; }; }; }; - listMySkillSuiteWorkspace_1: { + listVisibleLabels_1: { parameters: { - query?: { - q?: string; - state?: string; - page?: number; - size?: number; - }; + query?: never; header?: never; path?: never; cookie?: never; @@ -14565,60 +16255,52 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseMySkillSuiteWorkspaceResponse"]; + "*/*": components["schemas"]["ApiResponseListSkillLabelDto"]; }; }; }; }; - listMySkillSuites: { + summary: { parameters: { - query?: { - q?: string; - page?: number; - size?: number; - }; + query?: never; header?: never; path?: never; cookie?: never; }; requestBody?: never; responses: { - /** @description Manageable Suite page returned */ + /** @description OK */ 200: { headers: { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponsePageResponseMySkillSuiteSummaryResponse"]; + "*/*": components["schemas"]["ApiResponseGovernanceSummaryResponse"]; }; }; }; }; - listMySkillSuites_1: { + summary_1: { parameters: { - query?: { - q?: string; - page?: number; - size?: number; - }; + query?: never; header?: never; path?: never; cookie?: never; }; requestBody?: never; responses: { - /** @description Manageable Suite page returned */ + /** @description OK */ 200: { headers: { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponsePageResponseMySkillSuiteSummaryResponse"]; + "*/*": components["schemas"]["ApiResponseGovernanceSummaryResponse"]; }; }; }; }; - listMySubscriptions: { + notifications: { parameters: { query?: { page?: number; @@ -14636,12 +16318,12 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponsePageResponseSkillSummaryResponse"]; + "*/*": components["schemas"]["ApiResponsePageResponseGovernanceNotificationResponse"]; }; }; }; }; - listMySubscriptions_1: { + notifications_1: { parameters: { query?: { page?: number; @@ -14659,14 +16341,15 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponsePageResponseSkillSummaryResponse"]; + "*/*": components["schemas"]["ApiResponsePageResponseGovernanceNotificationResponse"]; }; }; }; }; - listMyStars: { + inbox: { parameters: { query?: { + type?: string; page?: number; size?: number; }; @@ -14682,14 +16365,15 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponsePageResponseSkillSummaryResponse"]; + "*/*": components["schemas"]["ApiResponsePageResponseGovernanceInboxItemResponse"]; }; }; }; }; - listMyStars_1: { + inbox_1: { parameters: { query?: { + type?: string; page?: number; size?: number; }; @@ -14705,19 +16389,16 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponsePageResponseSkillSummaryResponse"]; + "*/*": components["schemas"]["ApiResponsePageResponseGovernanceInboxItemResponse"]; }; }; }; }; - listMySkills: { + activity: { parameters: { query?: { page?: number; size?: number; - filter?: string; - q?: string; - namespace?: string; }; header?: never; path?: never; @@ -14731,19 +16412,16 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponsePageResponseSkillSummaryResponse"]; + "*/*": components["schemas"]["ApiResponsePageResponseGovernanceActivityItemResponse"]; }; }; }; }; - listMySkills_1: { + activity_1: { parameters: { query?: { page?: number; size?: number; - filter?: string; - q?: string; - namespace?: string; }; header?: never; path?: never; @@ -14757,18 +16435,18 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponsePageResponseSkillSummaryResponse"]; + "*/*": components["schemas"]["ApiResponsePageResponseGovernanceActivityItemResponse"]; }; }; }; }; - listMyNamespacesPage: { + listValidationFindings: { parameters: { - query: { - pageable: components["schemas"]["Pageable"]; - }; + query?: never; header?: never; - path?: never; + path: { + runId: number; + }; cookie?: never; }; requestBody?: never; @@ -14779,18 +16457,18 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponsePageResponseMyNamespaceResponse"]; + "*/*": components["schemas"]["ApiResponseListValidationFindingResponse"]; }; }; }; }; - listMyNamespacesPage_1: { + listValidationFindings_1: { parameters: { - query: { - pageable: components["schemas"]["Pageable"]; - }; + query?: never; header?: never; - path?: never; + path: { + runId: number; + }; cookie?: never; }; requestBody?: never; @@ -14801,16 +16479,22 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponsePageResponseMyNamespaceResponse"]; + "*/*": components["schemas"]["ApiResponseListValidationFindingResponse"]; }; }; }; }; - listMyNamespaces: { + streamValidationEvents: { parameters: { - query?: never; - header?: never; - path?: never; + query?: { + afterSeq?: number; + }; + header?: { + "Last-Event-ID"?: string; + }; + path: { + runId: number; + }; cookie?: never; }; requestBody?: never; @@ -14821,16 +16505,22 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseListMyNamespaceResponse"]; + "text/event-stream": components["schemas"]["SseEmitter"]; }; }; }; }; - listMyNamespaces_1: { + streamValidationEvents_1: { parameters: { - query?: never; - header?: never; - path?: never; + query?: { + afterSeq?: number; + }; + header?: { + "Last-Event-ID"?: string; + }; + path: { + runId: number; + }; cookie?: never; }; requestBody?: never; @@ -14841,16 +16531,20 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseListMyNamespaceResponse"]; + "text/event-stream": components["schemas"]["SseEmitter"]; }; }; }; }; - listVisibleLabels: { + listValidationEvents: { parameters: { - query?: never; + query?: { + afterSeq?: number; + }; header?: never; - path?: never; + path: { + runId: number; + }; cookie?: never; }; requestBody?: never; @@ -14861,16 +16555,20 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseListSkillLabelDto"]; + "*/*": components["schemas"]["ApiResponseListValidationEventResponse"]; }; }; }; }; - listVisibleLabels_1: { + listValidationEvents_1: { parameters: { - query?: never; + query?: { + afterSeq?: number; + }; header?: never; - path?: never; + path: { + runId: number; + }; cookie?: never; }; requestBody?: never; @@ -14881,16 +16579,18 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseListSkillLabelDto"]; + "*/*": components["schemas"]["ApiResponseListValidationEventResponse"]; }; }; }; }; - summary: { + getValidationRun: { parameters: { query?: never; header?: never; - path?: never; + path: { + runId: number; + }; cookie?: never; }; requestBody?: never; @@ -14901,16 +16601,18 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseGovernanceSummaryResponse"]; + "*/*": components["schemas"]["ApiResponseValidationRunResponse"]; }; }; }; }; - summary_1: { + getValidationRun_1: { parameters: { query?: never; header?: never; - path?: never; + path: { + runId: number; + }; cookie?: never; }; requestBody?: never; @@ -14921,19 +16623,20 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseGovernanceSummaryResponse"]; + "*/*": components["schemas"]["ApiResponseValidationRunResponse"]; }; }; }; }; - notifications: { + readDraftFile: { parameters: { - query?: { - page?: number; - size?: number; + query: { + path: string; }; header?: never; - path?: never; + path: { + draftId: number; + }; cookie?: never; }; requestBody?: never; @@ -14944,19 +16647,20 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponsePageResponseGovernanceNotificationResponse"]; + "*/*": components["schemas"]["ApiResponseDraftFileContentResponse"]; }; }; }; }; - notifications_1: { + readDraftFile_1: { parameters: { - query?: { - page?: number; - size?: number; + query: { + path: string; }; header?: never; - path?: never; + path: { + draftId: number; + }; cookie?: never; }; requestBody?: never; @@ -14967,20 +16671,18 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponsePageResponseGovernanceNotificationResponse"]; + "*/*": components["schemas"]["ApiResponseDraftFileContentResponse"]; }; }; }; }; - inbox: { + getAuthoringDraft: { parameters: { - query?: { - type?: string; - page?: number; - size?: number; - }; + query?: never; header?: never; - path?: never; + path: { + draftId: number; + }; cookie?: never; }; requestBody?: never; @@ -14991,20 +16693,18 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponsePageResponseGovernanceInboxItemResponse"]; + "*/*": components["schemas"]["ApiResponseDraftResponse"]; }; }; }; }; - inbox_1: { + deleteAuthoringDraft: { parameters: { - query?: { - type?: string; - page?: number; - size?: number; - }; + query?: never; header?: never; - path?: never; + path: { + draftId: number; + }; cookie?: never; }; requestBody?: never; @@ -15015,19 +16715,18 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponsePageResponseGovernanceInboxItemResponse"]; + "*/*": components["schemas"]["ApiResponseVoid"]; }; }; }; }; - activity: { + getAuthoringDraft_1: { parameters: { - query?: { - page?: number; - size?: number; - }; + query?: never; header?: never; - path?: never; + path: { + draftId: number; + }; cookie?: never; }; requestBody?: never; @@ -15038,19 +16737,18 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponsePageResponseGovernanceActivityItemResponse"]; + "*/*": components["schemas"]["ApiResponseDraftResponse"]; }; }; }; }; - activity_1: { + deleteAuthoringDraft_1: { parameters: { - query?: { - page?: number; - size?: number; - }; + query?: never; header?: never; - path?: never; + path: { + draftId: number; + }; cookie?: never; }; requestBody?: never; @@ -15061,7 +16759,7 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponsePageResponseGovernanceActivityItemResponse"]; + "*/*": components["schemas"]["ApiResponseVoid"]; }; }; }; diff --git a/web/src/api/types.ts b/web/src/api/types.ts index b9978ef27..92f95291d 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -715,3 +715,123 @@ export interface NotificationPreferenceItem { export interface NotificationUnreadCount { count: number } + +// Authoring workbench types (draft create/edit/validate/submit flow) +export interface AuthoringDraft { + id: number + namespaceId: number + name: string + requirement?: string + revision: number + contentDigest: string + validated: boolean + validatedRevision?: number + validatedRunId?: number + submittedSkillId?: number + submittedVersionId?: number + submittedAt?: string + createdAt: string + updatedAt: string +} + +export interface DraftFileSummary { + id?: number + path: string + sha256: string + size: number + contentType?: string + updatedAt?: string +} + +export interface DraftFileContent { + path: string + sha256: string + size: number + contentType?: string + content: string +} + +export interface SaveDraftFileOutcome { + draft: AuthoringDraft + file: DraftFileSummary + created: boolean + revisionAdvanced: boolean +} + +export interface RuntimeBindingInfo { + // null while the draft has no saved binding yet (the form falls back to + // its local-script default) + agentType: string | null + config: Record + toolAllowlist: string[] + mcpServers: Record[] + updatedAt?: string +} + +export type ValidationRunStatus = + | 'QUEUED' + | 'PREPARING' + | 'RUNNING' + | 'SUCCEEDED' + | 'FAILED' + | 'CANCELLED' + | 'TIMED_OUT' + +export interface ValidationRunInfo { + id: number + draftId: number + draftRevision: number + status: ValidationRunStatus + cancelRequested: boolean + active: boolean + terminal: boolean + errorCount: number + warningCount: number + triggeredBy: string + startedAt?: string + finishedAt?: string + createdAt: string + summary: Record +} + +export interface ValidationEventInfo { + seq: number + type: string + phase?: string + payload: Record + createdAt?: string +} + +export interface FilePatch { + filePath: string + oldSha256?: string + oldValue?: string + newValue?: string +} + +export interface FixSuggestionInfo { + description?: string + patches?: FilePatch[] +} + +export interface ValidationFindingInfo { + id: number + runId: number + layer: 'STRUCTURE' | 'CONFIG' | 'BEHAVIOR' + ruleCode: string + severity: 'ERROR' | 'WARNING' + filePath?: string + location?: string + message: string + suggestion?: FixSuggestionInfo | null + status: 'OPEN' | 'APPLIED' | 'DISMISSED' + appliedRevision?: number + createdAt: string +} + +export interface SubmitDraftOutcome { + skillId: number + versionId: number + slug: string + version: string +} diff --git a/web/src/app/router.tsx b/web/src/app/router.tsx index 489efd734..c77e4de50 100644 --- a/web/src/app/router.tsx +++ b/web/src/app/router.tsx @@ -176,6 +176,17 @@ const MyStarsPage = createLazyRouteComponent(() => import('@/pages/dashboard/sta const MySubscriptionsPage = createLazyRouteComponent(() => import('@/pages/dashboard/subscriptions'), 'MySubscriptionsPage', dashboardRouteOptions) const NotificationsPage = createLazyRouteComponent(() => import('@/pages/notifications'), 'NotificationsPage') const TokensPage = createLazyRouteComponent(() => import('@/pages/dashboard/tokens'), 'TokensPage', dashboardRouteOptions) +const AuthoringDraftsPage = createLazyRouteComponent(() => import('@/pages/authoring/drafts'), 'DraftsPage', dashboardRouteOptions) +const AuthoringDraftDetailPage = createLazyRouteComponent( + () => import('@/pages/authoring/draft-detail'), + 'DraftDetailPage', + dashboardRouteOptions, +) +const AuthoringRunDetailPage = createLazyRouteComponent( + () => import('@/pages/authoring/run-detail'), + 'RunDetailPage', + dashboardRouteOptions, +) const CliAuthPage = createLazyRouteComponent(() => import('@/pages/cli-auth'), 'CliAuthPage') const SecuritySettingsPage = createLazyRouteComponent( () => import('@/pages/settings/security'), @@ -575,6 +586,27 @@ const dashboardTokensRoute = createRoute({ component: TokensPage, }) +const authoringDraftsRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'dashboard/authoring', + beforeLoad: requireAuth, + component: AuthoringDraftsPage, +}) + +const authoringDraftDetailRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'dashboard/authoring/$draftId', + beforeLoad: requireAuth, + component: AuthoringDraftDetailPage, +}) + +const authoringRunDetailRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'dashboard/authoring/$draftId/runs/$runId', + beforeLoad: requireAuth, + component: AuthoringRunDetailPage, +}) + const cliAuthRoute = createRoute({ getParentRoute: () => rootRoute, path: 'cli/auth', @@ -691,6 +723,9 @@ const routeTree = rootRoute.addChildren([ dashboardSubscriptionsRoute, dashboardNotificationsRoute, dashboardTokensRoute, + authoringDraftsRoute, + authoringDraftDetailRoute, + authoringRunDetailRoute, cliAuthRoute, deviceAuthRoute, settingsSecurityRoute, diff --git a/web/src/features/authoring/binary-file.test.ts b/web/src/features/authoring/binary-file.test.ts new file mode 100644 index 000000000..9e076707d --- /dev/null +++ b/web/src/features/authoring/binary-file.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest' +import { formatBytes, isBinaryDraftPath } from './binary-file' + +describe('isBinaryDraftPath', () => { + it('marks image and office extensions as binary', () => { + expect(isBinaryDraftPath('assets/logo.png')).toBe(true) + expect(isBinaryDraftPath('assets/logo.PNG')).toBe(true) + expect(isBinaryDraftPath('docs/report.pdf')).toBe(true) + expect(isBinaryDraftPath('assets/sheet.xlsx')).toBe(true) + }) + + it('keeps text formats editable', () => { + expect(isBinaryDraftPath('SKILL.md')).toBe(false) + expect(isBinaryDraftPath('references/metrics.md')).toBe(false) + expect(isBinaryDraftPath('assets/diagram.svg')).toBe(false) + expect(isBinaryDraftPath('scripts/greet.sh')).toBe(false) + expect(isBinaryDraftPath('validation.yaml')).toBe(false) + }) +}) + +describe('formatBytes', () => { + it('formats byte sizes for humans', () => { + expect(formatBytes(512)).toBe('512 B') + expect(formatBytes(2048)).toBe('2.0 KB') + expect(formatBytes(3 * 1024 * 1024)).toBe('3.0 MB') + }) +}) diff --git a/web/src/features/authoring/binary-file.ts b/web/src/features/authoring/binary-file.ts new file mode 100644 index 000000000..5cb9b65b0 --- /dev/null +++ b/web/src/features/authoring/binary-file.ts @@ -0,0 +1,56 @@ +/** + * Binary draft files (images, office documents, PDFs) round-trip through the + * API as base64 and cannot be edited in the plain-text editor; the workbench + * shows them read-only with an upload-to-replace action instead. + */ + +const BINARY_EXTENSIONS = new Set([ + '.png', '.jpg', '.jpeg', '.gif', '.webp', '.ico', + '.pdf', '.doc', '.xls', '.ppt', '.docx', '.xlsx', '.pptx', +]) + +export function isBinaryDraftPath(path: string): boolean { + const lower = path.toLowerCase() + for (const extension of BINARY_EXTENSIONS) { + if (lower.endsWith(extension)) { + return true + } + } + return false +} + +export function formatBytes(size: number): string { + if (size < 1024) { + return `${size} B` + } + if (size < 1024 * 1024) { + return `${(size / 1024).toFixed(1)} KB` + } + return `${(size / (1024 * 1024)).toFixed(1)} MB` +} + +/** Reads a browser File as base64 (no data: prefix) for the API's base64 encoding. */ +export function fileToBase64(file: File): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader() + reader.onerror = () => reject(reader.error ?? new Error('failed to read file')) + reader.onload = () => { + const buffer = reader.result + if (!(buffer instanceof ArrayBuffer)) { + reject(new Error('failed to read file')) + return + } + resolve(bytesToBase64(new Uint8Array(buffer))) + } + reader.readAsArrayBuffer(file) + }) +} + +function bytesToBase64(bytes: Uint8Array): string { + let binary = '' + const chunkSize = 0x8000 + for (let offset = 0; offset < bytes.length; offset += chunkSize) { + binary += String.fromCharCode(...bytes.subarray(offset, offset + chunkSize)) + } + return btoa(binary) +} diff --git a/web/src/features/authoring/draft-file-editor.tsx b/web/src/features/authoring/draft-file-editor.tsx new file mode 100644 index 000000000..d54cd1b3f --- /dev/null +++ b/web/src/features/authoring/draft-file-editor.tsx @@ -0,0 +1,352 @@ +import { useEffect, useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { FilePlus2, Save, Trash2, Upload } from 'lucide-react' +import { authoringApi } from '@/api/client' +import type { DraftFileSummary } from '@/api/types' +import { Button } from '@/shared/ui/button' +import { Input } from '@/shared/ui/input' +import { Textarea } from '@/shared/ui/textarea' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/shared/ui/dialog' +import { toast } from '@/shared/lib/toast' +import { fileToBase64, formatBytes, isBinaryDraftPath } from './binary-file' + +/** + * Draft file editor: a file picker plus a plain-text editor for text formats + * and an upload-to-replace view for binary assets (images, documents). Saves + * go through the draft's optimistic-concurrency revision so concurrent edits + * surface as errors instead of silently overwriting each other. + */ +export function DraftFileEditor({ + draftId, + files, + revision, + onSaved, + onDeleted, +}: { + draftId: number + files: DraftFileSummary[] + revision: number + onSaved: (path: string) => void + onDeleted: (path: string) => void +}) { + const { t } = useTranslation() + const sortedFiles = useMemo( + () => [...files].sort((a, b) => a.path.localeCompare(b.path)), + [files], + ) + const [selectedPath, setSelectedPath] = useState(null) + const [content, setContent] = useState('') + const [dirty, setDirty] = useState(false) + const [saving, setSaving] = useState(false) + const [newPath, setNewPath] = useState('') + const [creating, setCreating] = useState(false) + + const [uploadOpen, setUploadOpen] = useState(false) + const [uploadFile, setUploadFile] = useState(null) + const [uploadPath, setUploadPath] = useState('') + const [uploading, setUploading] = useState(false) + + const selected = sortedFiles.find((file) => file.path === selectedPath) ?? null + const binarySelected = selectedPath != null && isBinaryDraftPath(selectedPath) + + useEffect(() => { + if (sortedFiles.length === 0) { + setSelectedPath(null) + return + } + if (selectedPath == null) { + setSelectedPath(sortedFiles[0].path) + } + }, [sortedFiles, selectedPath]) + + useEffect(() => { + if (selectedPath == null || isBinaryDraftPath(selectedPath)) { + return + } + let cancelled = false + authoringApi + .readFile(draftId, selectedPath) + .then((file) => { + if (!cancelled) { + setContent(file.content) + setDirty(false) + } + }) + .catch((error) => { + if (!cancelled) { + toast.error(error instanceof Error ? error.message : String(error)) + } + }) + return () => { + cancelled = true + } + }, [draftId, selectedPath]) + + const save = async () => { + if (selectedPath == null) { + return + } + setSaving(true) + try { + await authoringApi.saveFile(draftId, { + path: selectedPath, + content, + expectedRevision: revision, + }) + setDirty(false) + onSaved(selectedPath) + toast.success(t('authoring.files.saved')) + } catch (error) { + toast.error(error instanceof Error ? error.message : String(error)) + } finally { + setSaving(false) + } + } + + const createFile = async () => { + const path = newPath.trim() + if (!path) { + return + } + setCreating(true) + try { + const outcome = await authoringApi.saveFile(draftId, { + path, + content: '', + expectedRevision: revision, + }) + setNewPath('') + onSaved(path) + setSelectedPath(path) + toast.success( + outcome.created ? t('authoring.files.created') : t('authoring.files.exists'), + ) + } catch (error) { + toast.error(error instanceof Error ? error.message : String(error)) + } finally { + setCreating(false) + } + } + + const remove = async () => { + if (selectedPath == null) { + return + } + const removedPath = selectedPath + try { + await authoringApi.deleteFile(draftId, removedPath, revision) + // pick the next selection from the files we know remain — the list prop + // is still stale at this point, so the removed path itself must be + // excluded explicitly + const next = sortedFiles.find((file) => file.path !== removedPath)?.path ?? null + onDeleted(removedPath) + setSelectedPath(next) + toast.success(t('authoring.files.deleted')) + } catch (error) { + toast.error(error instanceof Error ? error.message : String(error)) + } + } + + const openUpload = (prefillPath?: string) => { + setUploadFile(null) + setUploadPath(prefillPath ?? '') + setUploadOpen(true) + } + + const chooseUploadFile = (file: File | null) => { + setUploadFile(file) + if (file && !uploadPath.trim()) { + setUploadPath(`assets/${file.name}`) + } + } + + const upload = async () => { + if (!uploadFile) { + return + } + const path = uploadPath.trim() + if (!path) { + return + } + setUploading(true) + try { + const base64 = await fileToBase64(uploadFile) + const outcome = await authoringApi.saveFile(draftId, { + path, + content: base64, + base64: true, + contentType: uploadFile.type || undefined, + expectedRevision: revision, + }) + setUploadOpen(false) + onSaved(path) + setSelectedPath(path) + toast.success( + outcome.created ? t('authoring.files.uploaded') : t('authoring.files.replaced'), + ) + } catch (error) { + toast.error(error instanceof Error ? error.message : String(error)) + } finally { + setUploading(false) + } + } + + return ( +

+
+
    + {sortedFiles.map((file) => ( +
  • + +
  • + ))} +
+
+ setNewPath(event.target.value)} + placeholder="scripts/run.py" + className="h-8 font-mono text-xs" + onKeyDown={(event) => { + if (event.key === 'Enter') { + void createFile() + } + }} + /> + +
+ +
+ +
+ {selectedPath == null ? ( +

{t('authoring.files.empty')}

+ ) : binarySelected && selected ? ( +
+

{selected.path}

+
+
{t('authoring.files.size')}
+
{formatBytes(selected.size)}
+
sha256
+
+ {selected.sha256.slice(0, 16)}… +
+
{t('authoring.files.contentType')}
+
{selected.contentType || '—'}
+
+

{t('authoring.files.binaryHint')}

+
+ + +
+
+ ) : ( + <> +