feat(openapi): workspace skill list, export, import and publish ops (#44941)

This commit is contained in:
Xiyuan Chen authored and GitHub committed 2026-10-30 08:24:55 +00:00
1 parent f3064fc12e
commit b92228f6c2
25 files changed
+3199 -184

No files matched your search

+2
View File
@@ -7,6 +7,8 @@ UUID_NIL = "00000000-0000-0000-0000-000000000000"
DEFAULT_FILE_NUMBER_LIMITS = 3
BYTES_PER_MIB = 1024 * 1024
_IMAGE_EXTENSION_BASE: frozenset[str] = frozenset(("jpg", "jpeg", "png", "webp", "gif", "svg"))
_VIDEO_EXTENSION_BASE: frozenset[str] = frozenset(("mp4", "mov", "mpeg", "webm"))
_AUDIO_EXTENSION_BASE: frozenset[str] = frozenset(("mp3", "m4a", "wav", "amr", "mpga"))
+30 -1
View File
@@ -8,7 +8,7 @@ from controllers.common.access_response import register_auth_error_response
# Register response/query models BEFORE importing controller modules so that
# @openapi_ns.response / @openapi_ns.expect decorators can resolve model names.
from controllers.common.fields import EventStreamResponse, SimpleResultResponse
from controllers.common.fields import BinaryFileResponse, EventStreamResponse, SimpleResultResponse
from controllers.common.schema import register_enum_models, register_response_schema_models, register_schema_models
from controllers.openapi._catalog import attach_catalog
from controllers.openapi._errors import ErrorBody, OpenApiErrorCode, OpenApiErrorFormatter
@@ -125,6 +125,18 @@ from controllers.openapi._models import (
SessionListQuery,
SessionListResponse,
SessionRow,
SkillDescribeResponse,
SkillExportQuery,
SkillImportPayload,
SkillImportResponse,
SkillListQuery,
SkillListResponse,
SkillPublishPayload,
SkillPublishResponse,
SkillRow,
SkillVersionListQuery,
SkillVersionListResponse,
SkillVersionRow,
TaskStopResponse,
ToolCredentialCreatePayload,
ToolCredentialUpdatePayload,
@@ -245,12 +257,18 @@ register_schema_models(
PublishPayload,
RunListQuery,
SessionListQuery,
SkillExportQuery,
SkillImportPayload,
SkillListQuery,
SkillPublishPayload,
SkillVersionListQuery,
VersionListQuery,
WorkflowRunPayload,
WorkspaceListQuery,
)
register_response_schema_models(
openapi_ns,
BinaryFileResponse,
ErrorBody,
EventStreamResponse,
SimpleResultResponse,
@@ -343,6 +361,13 @@ register_response_schema_models(
KnowledgeRetrievalFragment,
LlmModelBlock,
MultipleRetrievalFragment,
SkillRow,
SkillImportResponse,
SkillListResponse,
SkillVersionRow,
SkillDescribeResponse,
SkillVersionListResponse,
SkillPublishResponse,
ToolProviderDetailResponse,
ToolProviderRow,
)
@@ -375,6 +400,7 @@ def _register_controllers() -> tuple[ModuleType, ...]:
oauth_device,
oauth_device_sso,
plugins,
skills,
tool_providers,
workflow_events,
workspaces,
@@ -401,6 +427,7 @@ def _register_controllers() -> tuple[ModuleType, ...]:
oauth_device,
oauth_device_sso,
plugins,
skills,
tool_providers,
workflow_events,
workspaces,
@@ -428,6 +455,7 @@ def _register_controllers() -> tuple[ModuleType, ...]:
oauth_device,
oauth_device_sso,
plugins,
skills,
tool_providers,
workflow_events,
workspaces,
@@ -456,6 +484,7 @@ __all__ = [
"oauth_device",
"oauth_device_sso",
"plugins",
"skills",
"tool_providers",
"workflow_events",
"workspaces",
+32 -5
View File
@@ -18,7 +18,7 @@ from __future__ import annotations
import inspect
from collections.abc import Callable, Sequence
from functools import wraps
from typing import Any, Final, cast, override
from typing import Any, Final, cast, get_args, get_origin, override
from flask import request
from flask_restx import abort
@@ -26,6 +26,7 @@ from pydantic import BaseModel, ValidationError
from sqlalchemy.orm import Session
from werkzeug.exceptions import RequestEntityTooLarge
from constants import BYTES_PER_MIB
from controllers.common.schema import query_params_from_model, query_params_from_request
from controllers.openapi import openapi_ns
from controllers.openapi._errors import ErrorBody, RequestOverRouteLimits
@@ -40,10 +41,22 @@ from controllers.openapi.auth.spec import AdmissionContext, CatalogMeta, Effect,
from controllers.openapi.auth.subjects import Subject
from enums import DeploymentEdition
__all__ = ["CapRequestBody", "Effect", "Example", "Kind", "accepts", "endpoint", "op_of", "paginated", "returns"]
__all__ = [
"MULTIPART_HEADER_BYTES_PER_PART",
"CapRequestBody",
"Effect",
"Example",
"Kind",
"accepts",
"endpoint",
"multipart_header_allowance",
"op_of",
"paginated",
"returns",
]
_INJECTED_KWARGS: Final = frozenset({"ctx", "query", "body"})
_BYTES_PER_MIB: Final = 1024 * 1024
MULTIPART_HEADER_BYTES_PER_PART: Final = 1024
def paginated(op: str) -> Callable:
@@ -82,6 +95,10 @@ class CapRequestBody(Requirement):
request.max_form_parts = self.max_parts()
def multipart_header_allowance(parts: int) -> int:
return parts * MULTIPART_HEADER_BYTES_PER_PART
def _read_body(body_file_fields: frozenset[str]) -> dict[str, Any]:
try:
return body_from_request(file_fields=body_file_fields)
@@ -91,21 +108,31 @@ def _read_body(body_file_fields: frozenset[str]) -> dict[str, Any]:
raise
raise RequestOverRouteLimits(
f"{RequestOverRouteLimits.description}: at most {request.max_form_parts} parts"
f" and {limit / _BYTES_PER_MIB:g} MiB"
f" and {limit / BYTES_PER_MIB:g} MiB"
) from exc
def _list_fields(model: type[BaseModel]) -> tuple[str, ...]:
"""Query fields typed as a list, which a repeated parameter fills."""
return tuple(
name
for name, field in model.model_fields.items()
if any(get_origin(part) is list for part in (field.annotation, *get_args(field.annotation)))
)
def accepts(*, query: type[BaseModel] | None = None, body: type[BaseModel] | None = None) -> Callable:
"""Validate ``query``/``body`` against the models and inject them as keyword-only kwargs."""
def decorator(view: Callable) -> Callable:
body_file_fields = file_fields(body) if body is not None else frozenset()
query_list_fields = _list_fields(query) if query is not None else ()
@wraps(view)
def wrapper(*args: Any, **kwargs: Any) -> Any:
try:
if query is not None:
kwargs["query"] = query_params_from_request(query)
kwargs["query"] = query_params_from_request(query, list_fields=query_list_fields)
if body is not None:
kwargs["body"] = body.model_validate(_read_body(body_file_fields))
except ValidationError as exc:
+73
View File
@@ -27,6 +27,7 @@ import contextlib
import logging
from enum import StrEnum
from typing import Any
from uuid import UUID
from pydantic import BaseModel
from werkzeug.exceptions import HTTPException
@@ -107,6 +108,13 @@ class OpenApiErrorCode(StrEnum):
DRAFT_CHANGED = "draft_changed"
APP_MODE_MISMATCH = "app_mode_mismatch"
SERVICE_API_DISABLED = "service_api_disabled"
SKILLS_DISABLED = "skills_disabled"
SKILL_NOT_FOUND = "skill_not_found"
SKILL_VERSION_NOT_FOUND = "skill_version_not_found"
SKILL_NAME_CONFLICT = "skill_name_conflict"
SKILL_NAME_MISMATCH = "skill_name_mismatch"
SKILL_PACKAGE_INVALID = "skill_package_invalid"
SKILL_REQUEST_REJECTED = "skill_request_rejected"
class ErrorDetail(BaseModel):
@@ -541,3 +549,68 @@ class ServiceApiDisabled(OpenApiError): # noqa: N818
error_code = OpenApiErrorCode.SERVICE_API_DISABLED
description = "This app's Service API is turned off."
hint = "A workspace admin can turn it on; hints holds the op."
class SkillsDisabled(OpenApiError): # noqa: N818
code = 503
error_code = OpenApiErrorCode.SKILLS_DISABLED
description = "Skills are turned off on this server."
hint = "Ask an admin to turn on Skills."
class SkillNotFound(OpenApiError): # noqa: N818
code = 404
error_code = OpenApiErrorCode.SKILL_NOT_FOUND
description = "No skill with this id exists in this workspace."
hint = "List them with get.skill."
class SkillVersionNotFound(OpenApiError): # noqa: N818
code = 404
error_code = OpenApiErrorCode.SKILL_VERSION_NOT_FOUND
description = "No version with this id exists for this skill."
hint = "List them with get.skill.version."
class SkillNameConflict(OpenApiError): # noqa: N818
code = 409
error_code = OpenApiErrorCode.SKILL_NAME_CONFLICT
description = "A skill with this name already exists in this workspace."
class SkillNameMismatch(OpenApiError): # noqa: N818
code = 400
error_code = OpenApiErrorCode.SKILL_NAME_MISMATCH
description = "The name in SKILL.md is not the name of the skill being overwritten."
class SkillPackageInvalid(OpenApiError): # noqa: N818
code = 400
error_code = OpenApiErrorCode.SKILL_PACKAGE_INVALID
description = "The skill folder is not a valid skill."
class SkillPackageTooLarge(OpenApiError): # noqa: N818
code = 413
error_code = OpenApiErrorCode.REQUEST_TOO_LARGE
description = "The skill folder is over a limit of this server."
class SkillRequestRejected(OpenApiError): # noqa: N818
error_code = OpenApiErrorCode.SKILL_REQUEST_REJECTED
description = "The skill request was refused."
def __init__(
self, message: str | None = None, *, status_code: int, details: list[ErrorDetail] | None = None
) -> None:
super().__init__(message, details=details)
self.code = status_code
def require_uuid(value: str, *, not_found: type[OpenApiError]) -> str:
"""Parse a path id as a UUID or raise 404; an unparsed id would otherwise reach
a UUID-typed database column and fail as a 500."""
try:
return str(UUID(value))
except ValueError as e:
raise not_found() from e
+90 -1
View File
@@ -7,7 +7,7 @@ from datetime import datetime
from enum import StrEnum
from typing import Any, Final, Literal, Protocol, Self
from pydantic import BaseModel, ConfigDict, Field, PrivateAttr, field_validator, model_validator
from pydantic import BaseModel, ConfigDict, Field, PrivateAttr, ValidationInfo, field_validator, model_validator
from constants.languages import supported_language
from constants.oauth_bearer import SubjectType
@@ -1330,3 +1330,92 @@ class ModelCredentialCreatePayload(ModelRef):
class ModelCredentialUpdatePayload(ModelRef):
credentials: dict[str, Any] = Field(description=_CREDENTIALS_UPDATE_DESCRIPTION)
name: str | None = None
class SkillListQuery(PageQuery):
model_config = ConfigDict(extra="forbid")
keyword: str | None = Field(
None, max_length=200, description="Words to match in the name, display name or description"
)
tag: list[str] | None = Field(None, description="Only skills with every one of these tags; repeat to give several")
class SkillRow(BaseModel):
id: str
name: str
display_name: str
description: str
icon: str | None
tags: list[str]
updated_at: int
latest_published_version_id: str | None = Field(description="Null while the skill has never been published")
class SkillListResponse(PaginationEnvelope[SkillRow]):
pass
class SkillVersionRow(BaseModel):
id: str
version_number: int
version_name: str | None
publish_note: str
created_at: int
created_by: str | None = Field(validation_alias="published_by", description="Account id that published it")
class SkillDescribeResponse(SkillRow, Hinted):
draft_hash: str = Field(description="Opaque; names the draft this was read from")
latest_version: SkillVersionRow | None = Field(description="Null while the skill has never been published")
class SkillVersionListQuery(PageQuery):
model_config = ConfigDict(extra="forbid")
class SkillVersionListResponse(PaginationEnvelope[SkillVersionRow]):
pass
class SkillExportQuery(BaseModel):
model_config = ConfigDict(extra="forbid")
version_id: UUIDStr | None = Field(None, description="Export this published version instead of the draft")
class SkillPublishPayload(BaseModel):
model_config = ConfigDict(extra="forbid")
version_name: str | None = Field(None, max_length=128, description="Name for the new version")
publish_note: str = Field("", max_length=1024, description="Note for the new version")
class SkillPublishResponse(SkillVersionRow, Hinted):
pass
class SkillImportPayload(BaseModel):
model_config = ConfigDict(extra="forbid")
files: UploadParts = Field(description="The skill folder, one part per file; SKILL.md at its root")
skill_id: UUIDStr | None = Field(None, description="Overwrite this skill's draft; omit to create a new skill")
draft_hash: str | None = Field(
None,
pattern=r"^[0-9]+$",
description="From describe.skill; rejects the overwrite if the draft changed since",
)
@field_validator("draft_hash")
@classmethod
def _needs_skill_id(cls, value: str | None, info: ValidationInfo) -> str | None:
if value is not None and info.data.get("skill_id") is None:
raise ValueError("draft_hash only applies to an overwrite; give skill_id too")
return value
class SkillImportResponse(Hinted):
id: str
name: str
draft_hash: str = Field(description="Opaque; names the draft this wrote")
file_count: int
+11 -1
View File
@@ -21,12 +21,13 @@ from __future__ import annotations
import json
import re
from collections.abc import Collection
from typing import Any, Final
from typing import Any, BinaryIO, Final, cast
from flask import request
from werkzeug.datastructures import FileStorage
from controllers.openapi._errors import InvalidFilePart
from controllers.openapi._upload import UploadParts
LIST_SUFFIX: Final = "[]"
_PART_NAME_RE: Final = re.compile(r"^(?P<field>[^\[\]]+)(?:\[(?P<key>[^\[\]]+)\])?(?P<many>\[\])?$")
@@ -66,3 +67,12 @@ def body_from_request(*, file_fields: Collection[str] = ()) -> dict[str, Any]:
for name in request.files:
_place(body, name, request.files.getlist(name), file_fields=file_fields)
return body
def folder_streams(parts: UploadParts) -> dict[str, BinaryIO]:
streams: dict[str, BinaryIO] = {}
for path, part in parts.items():
if isinstance(part, list):
raise InvalidFilePart(f"{path}: given more than once")
streams[path] = cast(BinaryIO, part.stream)
return streams
+1 -2
View File
@@ -25,7 +25,7 @@ from controllers.common.rbac import PlainApp, RBACCheck, RBACPermission
from controllers.openapi import openapi_ns
from controllers.openapi._audit import emit_app_run
from controllers.openapi._contract import Effect, Example, Kind, endpoint
from controllers.openapi._errors import AppModeMismatch, RunNotFound, RunNotStoppable
from controllers.openapi._errors import AppModeMismatch, RunNotFound, RunNotStoppable, require_uuid
from controllers.openapi._files import end_read_transaction, materialize, merge_files
from controllers.openapi._hints import attach_stream_hints
from controllers.openapi._models import (
@@ -38,7 +38,6 @@ from controllers.openapi._models import (
TaskStopResponse,
WorkflowRunPayload,
)
from controllers.openapi.app_workflow import require_uuid
from controllers.openapi.auth.context import Context
from controllers.openapi.auth.requirements import (
EDITOR_ROLES,
+1 -11
View File
@@ -6,7 +6,6 @@ from __future__ import annotations
from collections.abc import Iterable
from http import HTTPStatus
from typing import Any, Final
from uuid import UUID
from flask_restx import Resource
from werkzeug.exceptions import BadRequest
@@ -21,12 +20,12 @@ from controllers.openapi._errors import (
DraftNotFound,
EnvVariableNotFound,
NodeNotFound,
OpenApiError,
RunNotFound,
SecretMaskNotSecret,
SecretMaskUnknownId,
VersionNotFound,
VersionNotRestorable,
require_uuid,
)
from controllers.openapi._files import end_read_transaction, merge_files
from controllers.openapi._hints import cursor_after, next_page_hint, page_after
@@ -89,15 +88,6 @@ _ENV_WRITE_GUARDS: Final = account_app_guards(
)
def require_uuid(value: str, *, not_found: type[OpenApiError]) -> str:
"""Parse a path id as a UUID or raise 404; an unparsed id would otherwise reach
a UUID-typed database column and fail as a 500."""
try:
return str(UUID(value))
except ValueError as e:
raise not_found() from e
def _require_run(ctx: Context, run_id: str) -> WorkflowRun:
run = application_services().workflow_runs.get_workflow_run(
ctx.request_context, app_id=ctx.app.id, run_id=require_uuid(run_id, not_found=RunNotFound)
@@ -27,6 +27,7 @@ from controllers.openapi._errors import (
MemberLimitExceeded,
PluginInstallForbidden,
ServiceApiDisabled,
SkillsDisabled,
WebAppAccessRequiresEE,
)
from controllers.openapi._models import Hint
@@ -266,6 +267,17 @@ class CheckPluginInstallSetting(Requirement):
raise PluginInstallForbidden(f"Plugin install setting {unknown!r} is not known to this server.")
class CheckSkillEnabled(Requirement):
"""Ranked ahead of membership and permission checks so every caller gets 503 while Skills is off."""
rank = Rank.EARLY
@override
def run(self, subject: Subject, ctx: Context, session: Session) -> None:
if not dify_config.ENABLE_SKILL:
raise SkillsDisabled()
class CheckAppAccess(Requirement):
"""Run-scope comes from the declaration site, so it is not re-checked here.
+449
View File
@@ -0,0 +1,449 @@
"""Workspace skills: list, describe, export, import and publish."""
from __future__ import annotations
from collections.abc import Callable, Generator, Mapping
from contextlib import contextmanager
from enum import StrEnum
from functools import partial
from http import HTTPStatus
from io import BytesIO
from types import MappingProxyType
from typing import Final
from flask import send_file
from flask_restx import Resource
from configs import dify_config
from constants import BYTES_PER_MIB
from controllers.common.fields import BinaryFileResponse
from controllers.common.rbac import RBACCheck, RBACPermission, Workspace
from controllers.openapi import openapi_ns
from controllers.openapi._contract import (
CapRequestBody,
Example,
Kind,
endpoint,
multipart_header_allowance,
op_of,
)
from controllers.openapi._errors import (
DraftChanged,
ErrorDetail,
OpenApiError,
SkillNameConflict,
SkillNameMismatch,
SkillNotFound,
SkillPackageInvalid,
SkillPackageTooLarge,
SkillRequestRejected,
SkillVersionNotFound,
require_uuid,
)
from controllers.openapi._models import (
Hint,
SkillDescribeResponse,
SkillExportQuery,
SkillImportPayload,
SkillImportResponse,
SkillListQuery,
SkillListResponse,
SkillPublishPayload,
SkillPublishResponse,
SkillRow,
SkillVersionListQuery,
SkillVersionListResponse,
SkillVersionRow,
)
from controllers.openapi._multipart import folder_streams
from controllers.openapi._upload import UploadParts, file_fields
from controllers.openapi.auth.requirements import (
EDITOR_ROLES,
WORKSPACE_READ_GUARDS,
CheckRBACPermission,
CheckSkillEnabled,
Requirement,
workspace_write_guards,
)
from libs.archive_writer import (
ArchiveEmptyError,
ArchiveError,
ArchiveLimitKind,
ArchiveLimits,
ArchiveOptions,
ArchivePathError,
ArchiveTooLargeError,
StrictPathPolicy,
entries_from_folder,
write_archive,
)
from machinery.context import RequestContext
from services.skill_management_service import (
SkillErrorDetail,
SkillManagementService,
SkillManagementServiceError,
SkillOverwritePayload,
SkillServiceErrorCode,
max_skill_archive_bytes,
)
from services.skill_management_service import SkillImportPayload as ServiceSkillImportPayload
from services.skill_management_service import SkillPublishPayload as ServiceSkillPublishPayload
class ServiceRejection(StrEnum):
"""Service error codes this surface answers with its own error; any other code is relayed."""
error: type[OpenApiError]
NOT_FOUND = (SkillServiceErrorCode.SKILL_NOT_FOUND, SkillNotFound)
VERSION_NOT_FOUND = (SkillServiceErrorCode.SKILL_VERSION_NOT_FOUND, SkillVersionNotFound)
CONFLICT = (SkillServiceErrorCode.SKILL_CONFLICT, DraftChanged)
NAME_CONFLICT = (SkillServiceErrorCode.SKILL_NAME_CONFLICT, SkillNameConflict)
NAME_MISMATCH = (SkillServiceErrorCode.SKILL_NAME_MISMATCH, SkillNameMismatch)
MISSING_SKILL_MD = (SkillServiceErrorCode.MISSING_SKILL_MD, SkillPackageInvalid)
INVALID_SKILL_MD = (SkillServiceErrorCode.INVALID_SKILL_MD, SkillPackageInvalid)
MISSING_SKILL_NAME = (SkillServiceErrorCode.MISSING_SKILL_NAME, SkillPackageInvalid)
INVALID_SKILL_NAME = (SkillServiceErrorCode.INVALID_SKILL_NAME, SkillPackageInvalid)
MISSING_SKILL_DESCRIPTION = (SkillServiceErrorCode.MISSING_SKILL_DESCRIPTION, SkillPackageInvalid)
INVALID_SKILL_DESCRIPTION = (SkillServiceErrorCode.INVALID_SKILL_DESCRIPTION, SkillPackageInvalid)
INVALID_SKILL_PACKAGE = (SkillServiceErrorCode.INVALID_SKILL_PACKAGE, SkillPackageInvalid)
DUPLICATE_FILE_PATH = (SkillServiceErrorCode.DUPLICATE_FILE_PATH, SkillPackageInvalid)
FILE_PATH_CONFLICT = (SkillServiceErrorCode.FILE_PATH_CONFLICT, SkillPackageInvalid)
INVALID_SKILL_FILE = (SkillServiceErrorCode.INVALID_SKILL_FILE, SkillPackageInvalid)
ARCHIVE_TOO_LARGE = (SkillServiceErrorCode.ARCHIVE_TOO_LARGE, SkillPackageTooLarge)
SKILL_TOO_LARGE = (SkillServiceErrorCode.SKILL_TOO_LARGE, SkillPackageTooLarge)
TOO_MANY_FILES = (SkillServiceErrorCode.TOO_MANY_FILES, SkillPackageTooLarge)
def __new__(cls, value: str, error: type[OpenApiError]) -> ServiceRejection:
member = str.__new__(cls, value)
member._value_ = value
member.error = error
return member
type HintsFor = Callable[[SkillManagementServiceError], list[Hint]]
_NO_HINTS: Final[Mapping[type[OpenApiError], HintsFor]] = MappingProxyType({})
_IMPORT_FILENAME: Final = "skill.zip"
_LIMIT_MESSAGES: Final[Mapping[ArchiveLimitKind, Callable[[int], str]]] = {
ArchiveLimitKind.ENTRIES: lambda limit: f"The skill folder has more than {limit} files",
ArchiveLimitKind.BYTES: lambda limit: f"The skill folder is over {limit // BYTES_PER_MIB} MiB",
}
_READ_GUARDS: Final = (
CheckSkillEnabled(),
*WORKSPACE_READ_GUARDS,
CheckRBACPermission(RBACCheck(RBACPermission.SKILL_VIEW, Workspace())),
)
def _edit_guards(permission: RBACPermission) -> tuple[Requirement, ...]:
return (CheckSkillEnabled(), *workspace_write_guards(permission, roles=EDITOR_ROLES))
def _details(error: SkillManagementServiceError) -> list[ErrorDetail]:
return [
ErrorDetail(type=error.code, msg=error.message),
*(ErrorDetail(type=error.code, loc=[key], msg=str(value)) for key, value in error.details.items()),
]
def _rejection(error: SkillManagementServiceError) -> OpenApiError:
try:
known = ServiceRejection(error.code)
except ValueError:
return SkillRequestRejected(error.message, status_code=error.status_code, details=_details(error))
return known.error(error.message, details=_details(error))
@contextmanager
def _relaying_service_errors(*, hints: Mapping[type[OpenApiError], HintsFor] = _NO_HINTS) -> Generator[None]:
try:
yield
except SkillManagementServiceError as error:
rejection = _rejection(error)
hints_for = hints.get(type(rejection))
if hints_for is not None:
rejection.hints = hints_for(error)
raise rejection from error
def _name_conflict_hints(error: SkillManagementServiceError, *, workspace_id: str) -> list[Hint]:
skill_id = error.details.get(SkillErrorDetail.SKILL_ID)
if skill_id is not None:
return [
Hint(
summary="Overwrite the existing skill instead",
op=op_of(SkillImportApi.post),
input={"workspace_id": workspace_id, "skill_id": skill_id, "files": None},
)
]
name = error.details.get(SkillErrorDetail.NAME)
if name is not None:
return [
Hint(
summary="Find the skill that has this name",
op=op_of(SkillListApi.get),
input={"workspace_id": workspace_id, "keyword": name},
)
]
return []
def _skill_upload_parts() -> int:
plain_fields = len(SkillImportPayload.model_fields) - len(file_fields(SkillImportPayload))
return dify_config.SKILL_PACKAGE_MAX_ENTRIES + plain_fields
_UPLOAD_CAP: Final = CapRequestBody(
max_bytes=lambda: max_skill_archive_bytes() + multipart_header_allowance(_skill_upload_parts()),
max_parts=_skill_upload_parts,
)
def _archive_rejection(error: ArchiveError) -> OpenApiError:
if isinstance(error, ArchiveTooLargeError):
return SkillPackageTooLarge(_LIMIT_MESSAGES[error.kind](error.limit))
if isinstance(error, ArchivePathError):
detail = ErrorDetail(type=error.reason.value, loc=[error.path], msg=error.reason.message)
return SkillPackageInvalid(str(error), details=[detail])
if isinstance(error, ArchiveEmptyError):
return SkillPackageInvalid("The skill folder has no files")
raise error
def _archive_of(folder: UploadParts) -> bytes:
options = ArchiveOptions(
StrictPathPolicy(),
ArchiveLimits(max_entries=dify_config.SKILL_PACKAGE_MAX_ENTRIES, max_bytes=max_skill_archive_bytes()),
)
try:
result = write_archive(entries_from_folder(folder_streams(folder)), options)
except ArchiveError as error:
raise _archive_rejection(error) from error
try:
return result.file.read()
finally:
result.file.close()
def _draft_hash(updated_at_epoch: int) -> str:
return str(updated_at_epoch)
def _expected_updated_at(draft_hash: str | None) -> int | None:
if draft_hash is None:
return None
return int(draft_hash)
@openapi_ns.route("/workspaces/<string:workspace_id>/skills")
class SkillListApi(Resource):
@endpoint(
context="workspace",
op="get.skill",
kind=Kind.LIST,
summary="Skills in the workspace, newest edit first",
examples=(
Example(title="List skills", input={}),
Example(title="Find skills by words and tag", input={"keyword": "expense", "tag": ["ops"]}),
),
requirements=_READ_GUARDS,
query=SkillListQuery,
returns=(HTTPStatus.OK, SkillListResponse, "Skills"),
)
def get(self, ctx: RequestContext, workspace_id: str, *, query: SkillListQuery):
with _relaying_service_errors():
result = SkillManagementService().list_skills(
tenant_id=ctx.active_workspace_id,
keyword=query.keyword,
tags=query.tag,
page=query.page,
limit=query.limit,
)
return SkillListResponse.build(
page=query.page,
limit=query.limit,
total=result["total"],
items=[SkillRow.model_validate(record) for record in result["data"]],
)
@openapi_ns.route("/workspaces/<string:workspace_id>/skills/<string:skill_id>")
class SkillDescribeApi(Resource):
@endpoint(
context="workspace",
op="describe.skill",
kind=Kind.OBJECT,
summary="One skill with its draft hash and latest published version",
examples=(Example(title="Describe a skill", input={"skill_id": "<skill_id>"}),),
requirements=_READ_GUARDS,
returns=(HTTPStatus.OK, SkillDescribeResponse, "Skill"),
)
def get(self, ctx: RequestContext, workspace_id: str, skill_id: str):
skill_id = require_uuid(skill_id, not_found=SkillNotFound)
service = SkillManagementService()
with _relaying_service_errors():
record = service.get_skill(tenant_id=ctx.active_workspace_id, skill_id=skill_id)
versions = service.list_versions(tenant_id=ctx.active_workspace_id, skill_id=skill_id)["data"]
latest = next((version for version in versions if version["is_latest"]), None)
return SkillDescribeResponse.model_validate(
{
**record,
"draft_hash": _draft_hash(record["updated_at"]),
"latest_version": SkillVersionRow.model_validate(latest) if latest else None,
}
)
@openapi_ns.route("/workspaces/<string:workspace_id>/skills/<string:skill_id>/versions")
class SkillVersionListApi(Resource):
@endpoint(
context="workspace",
op="get.skill.version",
kind=Kind.LIST,
summary="Published versions of a skill, newest first",
examples=(Example(title="List versions of a skill", input={"skill_id": "<skill_id>"}),),
requirements=_READ_GUARDS,
query=SkillVersionListQuery,
returns=(HTTPStatus.OK, SkillVersionListResponse, "Versions"),
)
def get(self, ctx: RequestContext, workspace_id: str, skill_id: str, *, query: SkillVersionListQuery):
skill_id = require_uuid(skill_id, not_found=SkillNotFound)
with _relaying_service_errors():
records = SkillManagementService().list_versions(tenant_id=ctx.active_workspace_id, skill_id=skill_id)
rows = [SkillVersionRow.model_validate(record) for record in records["data"]]
return SkillVersionListResponse.page_of(rows, query=query)
@openapi_ns.route("/workspaces/<string:workspace_id>/skills/<string:skill_id>/archive")
class SkillExportApi(Resource):
@endpoint(
context="workspace",
op="export.skill",
kind=Kind.FILE,
summary="Download a skill as a zip: its draft, or one published version",
examples=(
Example(title="Export the draft", input={"skill_id": "<skill_id>"}),
Example(title="Export a published version", input={"skill_id": "<skill_id>", "version_id": "<version_id>"}),
),
requirements=_READ_GUARDS,
query=SkillExportQuery,
returns=(HTTPStatus.OK, BinaryFileResponse, "Zip archive"),
)
def get(self, ctx: RequestContext, workspace_id: str, skill_id: str, *, query: SkillExportQuery):
skill_id = require_uuid(skill_id, not_found=SkillNotFound)
service = SkillManagementService()
with _relaying_service_errors():
if query.version_id is None:
archive = service.export_draft_archive(tenant_id=ctx.active_workspace_id, skill_id=skill_id)
else:
archive = service.export_version_archive(
tenant_id=ctx.active_workspace_id, skill_id=skill_id, version_id=query.version_id
)
return send_file(
BytesIO(archive.payload), mimetype=archive.mime_type, as_attachment=True, download_name=archive.filename
)
@openapi_ns.route("/workspaces/<string:workspace_id>/skills/<string:skill_id>:publish")
class SkillPublishApi(Resource):
@endpoint(
context="workspace",
op="publish.skill",
kind=Kind.OBJECT,
summary="Publish the draft as a new version",
examples=(
Example(title="Publish the draft", input={"skill_id": "<skill_id>"}),
Example(
title="Publish a named version",
input={"skill_id": "<skill_id>", "version_name": "v2", "publish_note": "Adds a retry rule"},
),
),
requirements=_edit_guards(RBACPermission.SKILL_PUBLISH),
body=SkillPublishPayload,
returns=(HTTPStatus.OK, SkillPublishResponse, "Published"),
)
def post(self, ctx: RequestContext, workspace_id: str, skill_id: str, *, body: SkillPublishPayload):
skill_id = require_uuid(skill_id, not_found=SkillNotFound)
with _relaying_service_errors():
record = SkillManagementService().publish_skill(
tenant_id=ctx.active_workspace_id,
user_id=ctx.account_id,
skill_id=skill_id,
payload=ServiceSkillPublishPayload(version_name=body.version_name, publish_note=body.publish_note),
)
return SkillPublishResponse.model_validate(record)
@openapi_ns.route("/workspaces/<string:workspace_id>/skills/imports")
class SkillImportApi(Resource):
@endpoint(
context="workspace",
op="import.skill",
kind=Kind.OBJECT,
summary="Create a skill from a folder, or overwrite a skill's draft with one",
examples=(
Example(title="Create a skill", input={"files": {"SKILL.md": "./my-skill/SKILL.md"}}),
Example(
title="Overwrite a skill's draft",
input={
"files": {"SKILL.md": "./my-skill/SKILL.md"},
"skill_id": "<skill_id>",
"draft_hash": "1761200000",
},
),
),
requirements=(*_edit_guards(RBACPermission.SKILL_EDIT), _UPLOAD_CAP),
body=SkillImportPayload,
returns=(
(HTTPStatus.CREATED, SkillImportResponse, "Created"),
(HTTPStatus.OK, SkillImportResponse, "Overwritten"),
),
)
def post(self, ctx: RequestContext, workspace_id: str, *, body: SkillImportPayload):
"""Import never publishes. ``draft_hash`` is the draft's ``updated_at`` in whole seconds,
so an edit made in the same second as the export is not detected.
"""
content = _archive_of(body.files)
workspace = ctx.active_workspace_id
name_conflict_hints: Mapping[type[OpenApiError], HintsFor] = {
SkillNameConflict: partial(_name_conflict_hints, workspace_id=workspace)
}
service = SkillManagementService()
with _relaying_service_errors(hints=name_conflict_hints):
if body.skill_id is None:
status = HTTPStatus.CREATED
record = service.import_skill(
tenant_id=workspace,
user_id=ctx.account_id,
payload=ServiceSkillImportPayload(content=content, filename=_IMPORT_FILENAME),
)
else:
status = HTTPStatus.OK
record = service.overwrite_skill(
tenant_id=workspace,
user_id=ctx.account_id,
skill_id=body.skill_id,
payload=SkillOverwritePayload(
content=content, expected_updated_at=_expected_updated_at(body.draft_hash)
),
)
return (
SkillImportResponse(
id=record["id"],
name=record["name"],
draft_hash=_draft_hash(record["updated_at"]),
file_count=len(record["files"]),
hints=[
Hint(
summary="Publish so agents use it",
op=op_of(SkillPublishApi.post),
input={"workspace_id": workspace, "skill_id": record["id"]},
)
],
),
status,
)
+1 -2
View File
@@ -20,8 +20,7 @@ from controllers.common.fields import EventStreamResponse
from controllers.common.rbac import PlainApp, RBACCheck, RBACPermission
from controllers.openapi import openapi_ns
from controllers.openapi._contract import Example, Kind, endpoint
from controllers.openapi._errors import RunNotFound
from controllers.openapi.app_workflow import require_uuid
from controllers.openapi._errors import RunNotFound, require_uuid
from controllers.openapi.auth.context import Context
from controllers.openapi.auth.requirements import (
CheckAppAccess,
+245
View File
@@ -1982,6 +1982,124 @@ Returns a depth- and page-size-bounded tree rooted at path, equivalent to difyct
| 422 | Validation error | **application/json**: [ErrorBody](#errorbody)<br> |
| default | Error | **application/json**: [ErrorBody](#errorbody)<br> |
### [GET] /workspaces/{workspace_id}/skills
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| keyword | query | Words to match in the name, display name or description | No | string |
| limit | query | | No | integer, <br>**Default:** 20 |
| page | query | | No | integer, <br>**Default:** 1 |
| tag | query | Only skills with every one of these tags; repeat to give several | No | [ string ] |
| workspace_id | path | | Yes | string |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Skills | **application/json**: [SkillListResponse](#skilllistresponse)<br> |
| 422 | Validation error | **application/json**: [ErrorBody](#errorbody)<br> |
| default | Error | **application/json**: [ErrorBody](#errorbody)<br> |
### [POST] /workspaces/{workspace_id}/skills/imports
**Import never publishes**
``draft_hash`` is the draft's ``updated_at`` in whole seconds,
so an edit made in the same second as the export is not detected.
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| workspace_id | path | | Yes | string |
#### Request Body
| Required | Schema |
| -------- | ------ |
| Yes | **application/json**: [SkillImportPayload](#skillimportpayload)<br>**multipart/form-data**: [SkillImportPayload](#skillimportpayload)<br> |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Overwritten | **application/json**: [SkillImportResponse](#skillimportresponse)<br> |
| 201 | Created | **application/json**: [SkillImportResponse](#skillimportresponse)<br> |
| 422 | Validation error | **application/json**: [ErrorBody](#errorbody)<br> |
| default | Error | **application/json**: [ErrorBody](#errorbody)<br> |
### [GET] /workspaces/{workspace_id}/skills/{skill_id}
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| skill_id | path | | Yes | string |
| workspace_id | path | | Yes | string |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Skill | **application/json**: [SkillDescribeResponse](#skilldescriberesponse)<br> |
| default | Error | **application/json**: [ErrorBody](#errorbody)<br> |
### [GET] /workspaces/{workspace_id}/skills/{skill_id}/archive
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| version_id | query | Export this published version instead of the draft | No | string (uuid) |
| skill_id | path | | Yes | string |
| workspace_id | path | | Yes | string |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Zip archive | **application/json**: [BinaryFileResponse](#binaryfileresponse)<br> |
| 422 | Validation error | **application/json**: [ErrorBody](#errorbody)<br> |
| default | Error | **application/json**: [ErrorBody](#errorbody)<br> |
### [GET] /workspaces/{workspace_id}/skills/{skill_id}/versions
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| limit | query | | No | integer, <br>**Default:** 20 |
| page | query | | No | integer, <br>**Default:** 1 |
| skill_id | path | | Yes | string |
| workspace_id | path | | Yes | string |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Versions | **application/json**: [SkillVersionListResponse](#skillversionlistresponse)<br> |
| 422 | Validation error | **application/json**: [ErrorBody](#errorbody)<br> |
| default | Error | **application/json**: [ErrorBody](#errorbody)<br> |
### [POST] /workspaces/{workspace_id}/skills/{skill_id}:publish
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| skill_id | path | | Yes | string |
| workspace_id | path | | Yes | string |
#### Request Body
| Required | Schema |
| -------- | ------ |
| Yes | **application/json**: [SkillPublishPayload](#skillpublishpayload)<br> |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Published | **application/json**: [SkillPublishResponse](#skillpublishresponse)<br> |
| 422 | Validation error | **application/json**: [ErrorBody](#errorbody)<br> |
| default | Error | **application/json**: [ErrorBody](#errorbody)<br> |
### [GET] /workspaces/{workspace_id}/tool-providers/{provider}
#### Parameters
@@ -2364,6 +2482,12 @@ mode is a closed enum of listable app types.
| max_active_requests | integer | | No |
| name | string | | Yes |
#### BinaryFileResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| BinaryFileResponse | string | | |
#### ChatAppInfo
| Name | Type | Description | Required |
@@ -3649,6 +3773,127 @@ Pagination for GET /account/sessions. Strict (extra='forbid').
| ---- | ---- | ----------- | -------- |
| result | string | Operation result. | Yes |
#### SkillDescribeResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| description | string | | Yes |
| display_name | string | | Yes |
| draft_hash | string | Opaque; names the draft this was read from | Yes |
| hints | [ [Hint](#hint) ] | Next steps the caller can take | No |
| icon | string | | Yes |
| id | string | | Yes |
| latest_published_version_id | string | Null while the skill has never been published | Yes |
| latest_version | [SkillVersionRow](#skillversionrow) | Null while the skill has never been published | Yes |
| name | string | | Yes |
| tags | [ string ] | | Yes |
| updated_at | integer | | Yes |
#### SkillExportQuery
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| version_id | string | Export this published version instead of the draft | No |
#### SkillImportPayload
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| draft_hash | string | From describe.skill; rejects the overwrite if the draft changed since | No |
| files | object | The skill folder, one part per file; SKILL.md at its root | Yes |
| skill_id | string | Overwrite this skill's draft; omit to create a new skill | No |
#### SkillImportResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| draft_hash | string | Opaque; names the draft this wrote | Yes |
| file_count | integer | | Yes |
| hints | [ [Hint](#hint) ] | Next steps the caller can take | No |
| id | string | | Yes |
| name | string | | Yes |
#### SkillListQuery
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| keyword | string | Words to match in the name, display name or description | No |
| limit | integer, <br>**Default:** 20 | | No |
| page | integer, <br>**Default:** 1 | | No |
| tag | [ string ] | Only skills with every one of these tags; repeat to give several | No |
#### SkillListResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| data | [ [SkillRow](#skillrow) ] | | Yes |
| has_more | boolean | | Yes |
| hints | [ [Hint](#hint) ] | Next steps the caller can take | No |
| limit | integer | | Yes |
| page | integer | | Yes |
| total | integer | | Yes |
#### SkillPublishPayload
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| publish_note | string | Note for the new version | No |
| version_name | string | Name for the new version | No |
#### SkillPublishResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| created_at | integer | | Yes |
| created_by | string | Account id that published it | Yes |
| hints | [ [Hint](#hint) ] | Next steps the caller can take | No |
| id | string | | Yes |
| publish_note | string | | Yes |
| version_name | string | | Yes |
| version_number | integer | | Yes |
#### SkillRow
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| description | string | | Yes |
| display_name | string | | Yes |
| icon | string | | Yes |
| id | string | | Yes |
| latest_published_version_id | string | Null while the skill has never been published | Yes |
| name | string | | Yes |
| tags | [ string ] | | Yes |
| updated_at | integer | | Yes |
#### SkillVersionListQuery
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| limit | integer, <br>**Default:** 20 | | No |
| page | integer, <br>**Default:** 1 | | No |
#### SkillVersionListResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| data | [ [SkillVersionRow](#skillversionrow) ] | | Yes |
| has_more | boolean | | Yes |
| hints | [ [Hint](#hint) ] | Next steps the caller can take | No |
| limit | integer | | Yes |
| page | integer | | Yes |
| total | integer | | Yes |
#### SkillVersionRow
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| created_at | integer | | Yes |
| created_by | string | Account id that published it | Yes |
| id | string | | Yes |
| publish_note | string | | Yes |
| version_name | string | | Yes |
| version_number | integer | | Yes |
#### SubjectType
| Name | Type | Description | Required |
+416 -137
View File
@@ -21,9 +21,9 @@ import re
import xml.etree.ElementTree as ET
import zipfile
from base64 import b64encode
from collections.abc import Generator
from collections.abc import Generator, Mapping
from contextlib import contextmanager, suppress
from dataclasses import dataclass
from dataclasses import dataclass, field
from datetime import datetime
from enum import StrEnum
from typing import Any, Literal
@@ -39,6 +39,7 @@ from sqlalchemy.orm import Session, object_session
from yaml.error import MarkedYAMLError
from configs import dify_config
from constants import BYTES_PER_MIB
from core.credit_usage import CreditUsageCreatedBy
from core.db.session_factory import session_factory
from core.errors.error import ProviderTokenNotInitError
@@ -55,6 +56,7 @@ from graphon.model_runtime.entities.message_entities import (
from graphon.model_runtime.entities.model_entities import ModelFeature, ModelType
from graphon.model_runtime.errors.invoke import InvokeError
from graphon.nodes.llm.reasoning import split_reasoning
from libs.archive_writer import is_os_junk
from libs.datetime_utils import naive_utc_now
from libs.helper import escape_like_pattern
from models.account import Account
@@ -99,6 +101,7 @@ from services.file_service import FileService
logger = logging.getLogger(__name__)
_SKILL_MD = "SKILL.md"
_ZIP_MIME_TYPE = "application/zip"
_MAX_FILE_CHECK_ITEMS = 100
_MAX_SKILLS_PER_WORKSPACE = 500
_MAX_AGENT_SKILLS = 20
@@ -235,6 +238,34 @@ _MAX_ASSISTANT_PDF_PAGES = 100
_SKILL_ASSISTANT_ROLE = "__skill_authoring_assistant__"
class SkillServiceErrorCode(StrEnum):
SKILL_NOT_FOUND = "skill_not_found"
SKILL_VERSION_NOT_FOUND = "skill_version_not_found"
SKILL_CONFLICT = "skill_conflict"
SKILL_NAME_CONFLICT = "skill_name_conflict"
SKILL_NAME_MISMATCH = "skill_name_mismatch"
MISSING_SKILL_MD = "missing_skill_md"
INVALID_SKILL_MD = "invalid_skill_md"
MISSING_SKILL_NAME = "missing_skill_name"
INVALID_SKILL_NAME = "invalid_skill_name"
MISSING_SKILL_DESCRIPTION = "missing_skill_description"
INVALID_SKILL_DESCRIPTION = "invalid_skill_description"
INVALID_SKILL_PACKAGE = "invalid_skill_package"
ARCHIVE_TOO_LARGE = "archive_too_large"
SKILL_TOO_LARGE = "skill_too_large"
TOO_MANY_FILES = "too_many_files"
DUPLICATE_FILE_PATH = "duplicate_file_path"
FILE_PATH_CONFLICT = "file_path_conflict"
INVALID_SKILL_FILE = "invalid_skill_file"
class SkillErrorDetail(StrEnum):
"""Keys of ``SkillManagementServiceError.details`` that callers read back."""
SKILL_ID = "skill_id"
NAME = "name"
class SkillManagementServiceError(Exception):
"""Skill operation failure mapped to HTTP status at controller boundaries."""
@@ -402,6 +433,13 @@ class SkillImportPayload(BaseModel):
filename: str
class SkillOverwritePayload(BaseModel):
model_config = ConfigDict(arbitrary_types_allowed=True, extra="forbid")
content: bytes
expected_updated_at: int | None = None
class SkillRestorePayload(BaseModel):
model_config = ConfigDict(extra="forbid")
@@ -528,6 +566,43 @@ class RuntimeAgentSkillArchive:
hash: str
@dataclass(frozen=True, slots=True)
class _BinaryArchiveEntry:
path: str
payload: bytes
@dataclass(frozen=True, slots=True)
class _SkillArchiveContents:
"""Directories and text files are final draft items; binaries still need a stored tool file."""
entries: tuple[SkillDraftTreeItemPayload | _BinaryArchiveEntry, ...]
metadata: dict[str, Any]
skill_md_content: str
@dataclass(frozen=True, slots=True)
class _SkillPackage:
contents: _SkillArchiveContents
name: str
description: str
@dataclass(slots=True)
class _ToolFileUploads:
"""Tool files one request stores or reuses.
``candidate_ids`` maps a draft row's claimed ``(sha256, size)`` to its tool file ids. The claim is
client-set, so ``stored_digests`` caches the sha256 of each candidate's real bytes (None if unreadable).
"""
tenant_id: str
user_id: str
candidate_ids: Mapping[tuple[str, int], tuple[str, ...]] = field(default_factory=dict)
stored_digests: dict[str, str | None] = field(default_factory=dict)
created: list[ToolFile] = field(default_factory=list)
@dataclass(frozen=True, slots=True)
class SkillFileContent:
filename: str
@@ -539,6 +614,30 @@ class SkillFileContent:
hash: str
def max_skill_archive_bytes() -> int:
return dify_config.UPLOAD_SKILL_FILE_SIZE_LIMIT * BYTES_PER_MIB
def _size_text(size_bytes: int) -> str:
if size_bytes % BYTES_PER_MIB:
return f"{size_bytes} bytes"
return f"{size_bytes // BYTES_PER_MIB} MiB"
def _too_many_files_error() -> SkillManagementServiceError:
return SkillManagementServiceError(
SkillServiceErrorCode.TOO_MANY_FILES,
f"skill has more than {dify_config.SKILL_PACKAGE_MAX_ENTRIES} files",
)
def _skill_too_large_error() -> SkillManagementServiceError:
return SkillManagementServiceError(
SkillServiceErrorCode.SKILL_TOO_LARGE,
f"skill is over {_size_text(dify_config.SKILL_PACKAGE_MAX_UNCOMPRESSED_BYTES)} uncompressed",
)
def validate_skill_name(name: str) -> str:
"""Validate the PRD Skill name using the existing config-skill name baseline."""
normalized = validate_config_skill_name(name)
@@ -1739,26 +1838,69 @@ class SkillManagementService:
skill = self._require_skill(session, tenant_id=tenant_id, skill_id=skill_id)
self._check_expected_updated_at(skill, payload.expected_updated_at)
files = self._build_draft_rows_from_tree(skill=skill, payload=payload, strict_frontmatter=False)
session.execute(delete(SkillDraftFile).where(SkillDraftFile.skill_id == skill.id))
session.flush()
for file in files:
session.add(file)
skill.updated_by = user_id
skill.updated_at = naive_utc_now()
session.flush()
try:
session.commit()
except IntegrityError as exc:
session.rollback()
raise SkillManagementServiceError("skill_name_conflict", "skill name already exists") from exc
return {
**self._serialize_skill(
skill,
tags=self._skill_tags_by_id(session, tenant_id=tenant_id, skill_ids=[skill.id]).get(skill.id, []),
accounts=self._skill_accounts(session, skill=skill),
),
"files": [self._serialize_file(file) for file in sorted(files, key=lambda item: item.path)],
}
self._replace_draft_rows(session, skill=skill, files=files, user_id=user_id)
return self._serialize_skill_with_files(session, tenant_id=tenant_id, skill=skill, files=files)
def overwrite_skill(
self,
*,
tenant_id: str,
user_id: str,
skill_id: str,
payload: SkillOverwritePayload,
) -> dict[str, Any]:
with self._session_scope() as session:
skill = self._require_skill(session, tenant_id=tenant_id, skill_id=skill_id)
self._check_expected_updated_at(skill, payload.expected_updated_at)
package = self._read_skill_package(payload.content)
if package.name != skill.name:
raise SkillManagementServiceError(
SkillServiceErrorCode.SKILL_NAME_MISMATCH,
"skill package name does not match the existing skill",
details={"expected": skill.name, "got": package.name},
)
files = self._replace_draft_from_archive(
session,
skill=skill,
user_id=user_id,
contents=package.contents,
display_name=self._display_name_from_frontmatter(metadata=package.contents.metadata, name=package.name),
)
return self._serialize_skill_with_files(session, tenant_id=tenant_id, skill=skill, files=files)
def _serialize_skill_with_files(
self,
session,
*,
tenant_id: str,
skill: Skill,
files: list[SkillDraftFile],
) -> dict[str, Any]:
return {
**self._serialize_skill(
skill,
tags=self._skill_tags_by_id(session, tenant_id=tenant_id, skill_ids=[skill.id]).get(skill.id, []),
accounts=self._skill_accounts(session, skill=skill),
),
"files": [self._serialize_file(file) for file in sorted(files, key=lambda item: item.path)],
}
@staticmethod
def _replace_draft_rows(session, *, skill: Skill, files: list[SkillDraftFile], user_id: str) -> None:
session.execute(delete(SkillDraftFile).where(SkillDraftFile.skill_id == skill.id))
session.flush()
for file in files:
session.add(file)
skill.updated_by = user_id
skill.updated_at = naive_utc_now()
session.flush()
try:
session.commit()
except IntegrityError as exc:
session.rollback()
raise SkillManagementServiceError(
SkillServiceErrorCode.SKILL_NAME_CONFLICT, "skill name already exists"
) from exc
def apply_draft_file_operation(
self,
@@ -1878,7 +2020,7 @@ class SkillManagementService:
tenant_id=tenant_id,
conversation_id=None,
file_binary=archive_bytes,
mimetype="application/zip",
mimetype=_ZIP_MIME_TYPE,
filename=f"{skill_name}.zip",
)
with self._session_scope() as session:
@@ -2123,40 +2265,41 @@ class SkillManagementService:
}
def import_skill(self, *, tenant_id: str, user_id: str, payload: SkillImportPayload) -> dict[str, Any]:
max_archive_bytes = dify_config.UPLOAD_SKILL_FILE_SIZE_LIMIT * 1024 * 1024
if len(payload.content) > max_archive_bytes:
raise SkillManagementServiceError("archive_too_large", "skill archive exceeds size limit")
draft_payload, metadata, skill_md_content = self._draft_payload_from_zip(
tenant_id=tenant_id,
user_id=user_id,
archive_bytes=payload.content,
)
name = validate_skill_name(str(metadata.get("name") or ""))
description = self._require_frontmatter_description(metadata, content=skill_md_content)
display_name = self._display_name_from_frontmatter(metadata=metadata, name=name)
package = self._read_skill_package(payload.content)
display_name = self._display_name_from_frontmatter(metadata=package.contents.metadata, name=package.name)
with self._session_scope() as session:
self._enforce_workspace_skill_limit(session, tenant_id=tenant_id)
skill = Skill(
tenant_id=tenant_id,
name=name,
display_name=display_name,
icon="📄",
description=description[:1024],
name_manually_edited=True,
created_by=user_id,
updated_by=user_id,
)
session.add(skill)
try:
session.flush()
files = self._build_draft_rows_from_tree(skill=skill, payload=draft_payload)
for file in files:
session.add(file)
session.commit()
except IntegrityError as exc:
session.rollback()
raise SkillManagementServiceError("skill_name_conflict", "skill name already exists") from exc
self._reject_taken_skill_name(session, tenant_id=tenant_id, name=package.name)
# Ends the pre-check read transaction so none stays open while the binaries upload.
session.commit()
uploads = _ToolFileUploads(tenant_id=tenant_id, user_id=user_id)
with self._discard_uploads_on_failure(session, uploads=uploads):
draft_payload = self._store_archive_entries(package.contents, uploads=uploads)
skill = Skill(
tenant_id=tenant_id,
name=package.name,
display_name=display_name,
icon="📄",
description=package.description[:1024],
name_manually_edited=True,
created_by=user_id,
updated_by=user_id,
)
session.add(skill)
try:
session.flush()
files = self._build_draft_rows_from_tree(skill=skill, payload=draft_payload)
for file in files:
session.add(file)
session.commit()
except IntegrityError as exc:
session.rollback()
# The name was free at the pre-check, so which skill took it since is not known here.
raise SkillManagementServiceError(
SkillServiceErrorCode.SKILL_NAME_CONFLICT,
"skill name already exists",
details={SkillErrorDetail.NAME: package.name},
) from exc
session.refresh(skill)
return {
**self._serialize_skill(skill, tags=[], accounts=self._skill_accounts(session, skill=skill)),
@@ -2206,19 +2349,8 @@ class SkillManagementService:
archive_bytes = self._load_tool_file_bytes(tenant_id=tenant_id, file_id=archive_file_id)
with self._session_scope() as session:
skill = self._require_skill(session, tenant_id=tenant_id, skill_id=skill_id)
draft_files = self._draft_rows_from_archive_bytes(
tenant_id=tenant_id,
user_id=user_id,
skill=skill,
archive_bytes=archive_bytes,
)
session.execute(delete(SkillDraftFile).where(SkillDraftFile.skill_id == skill.id))
session.flush()
for file in draft_files:
session.add(file)
skill.updated_by = user_id
skill.updated_at = naive_utc_now()
session.commit()
contents = self._read_skill_archive(archive_bytes=archive_bytes)
self._replace_draft_from_archive(session, skill=skill, user_id=user_id, contents=contents)
# Restore only replaces the editable draft. Publishing remains an explicit
# follow-up action so restoring history cannot unexpectedly activate it.
@@ -2236,7 +2368,7 @@ class SkillManagementService:
draft_files = [self._copy_draft_file(file, skill_id=skill.id) for file in files]
filename = f"{skill.name}.zip"
archive_bytes, _ = self._build_archive_from_draft(skill=draft_skill, files=draft_files)
return DraftSkillArchive(filename=filename, mime_type="application/zip", payload=archive_bytes)
return DraftSkillArchive(filename=filename, mime_type=_ZIP_MIME_TYPE, payload=archive_bytes)
def pull_published_archive(self, *, tenant_id: str, skill_id: str) -> PublishedSkillArchive:
with self._session_scope() as session:
@@ -2252,10 +2384,22 @@ class SkillManagementService:
)
tool_file_id = version.archive_tool_file_id
filename = f"{skill.name}.zip"
return self._stored_archive(tenant_id=tenant_id, tool_file_id=tool_file_id, filename=filename)
def export_version_archive(self, *, tenant_id: str, skill_id: str, version_id: str) -> PublishedSkillArchive:
with self._session_scope() as session:
skill = self._require_skill(session, tenant_id=tenant_id, skill_id=skill_id)
version = self._require_version(session, skill_id=skill.id, version_id=version_id)
tool_file_id = version.archive_tool_file_id
filename = f"{skill.name}-v{version.version_number}.zip"
return self._stored_archive(tenant_id=tenant_id, tool_file_id=tool_file_id, filename=filename)
@classmethod
def _stored_archive(cls, *, tenant_id: str, tool_file_id: str, filename: str) -> PublishedSkillArchive:
return PublishedSkillArchive(
filename=filename,
mime_type="application/zip",
payload=self._load_tool_file_bytes(tenant_id=tenant_id, file_id=tool_file_id),
mime_type=_ZIP_MIME_TYPE,
payload=cls._load_tool_file_bytes(tenant_id=tenant_id, file_id=tool_file_id),
)
def list_runtime_agent_skills(
@@ -2275,7 +2419,7 @@ class SkillManagementService:
"description": item.description,
"size": item.size,
"hash": item.hash,
"mime_type": "application/zip",
"mime_type": _ZIP_MIME_TYPE,
}
for item in self.list_runtime_agent_skill_archives(
tenant_id=tenant_id,
@@ -2394,7 +2538,7 @@ class SkillManagementService:
filename = f"{published_name}.zip"
return PublishedSkillArchive(
filename=filename,
mime_type="application/zip",
mime_type=_ZIP_MIME_TYPE,
payload=archive_bytes,
)
@@ -3396,7 +3540,7 @@ class SkillManagementService:
def _require_skill(session, *, tenant_id: str, skill_id: str) -> Skill:
skill = session.scalar(select(Skill).where(Skill.tenant_id == tenant_id, Skill.id == skill_id))
if skill is None:
raise SkillManagementServiceError("skill_not_found", "skill not found", status_code=404)
raise SkillManagementServiceError(SkillServiceErrorCode.SKILL_NOT_FOUND, "skill not found", status_code=404)
return skill
@staticmethod
@@ -3539,13 +3683,25 @@ class SkillManagementService:
if skill_count is not None and skill_count >= _MAX_SKILLS_PER_WORKSPACE:
raise SkillManagementServiceError("skill_limit_exceeded", "workspace skill limit exceeded")
@staticmethod
def _reject_taken_skill_name(session, *, tenant_id: str, name: str) -> None:
taken_id = session.scalar(select(Skill.id).where(Skill.tenant_id == tenant_id, Skill.name == name).limit(1))
if taken_id is not None:
raise SkillManagementServiceError(
SkillServiceErrorCode.SKILL_NAME_CONFLICT,
"skill name already exists",
details={SkillErrorDetail.SKILL_ID: taken_id},
)
@staticmethod
def _require_version(session, *, skill_id: str, version_id: str) -> SkillVersion:
version = session.scalar(
select(SkillVersion).where(SkillVersion.skill_id == skill_id, SkillVersion.id == version_id)
)
if version is None:
raise SkillManagementServiceError("skill_version_not_found", "skill version not found", status_code=404)
raise SkillManagementServiceError(
SkillServiceErrorCode.SKILL_VERSION_NOT_FOUND, "skill version not found", status_code=404
)
return version
@staticmethod
@@ -3809,21 +3965,35 @@ class SkillManagementService:
# The explicit wrapper directory represents the new root, not a draft item.
return {path: path.removeprefix(prefix) for path in paths if path.startswith(prefix)}
def _draft_payload_from_zip(
self,
*,
tenant_id: str,
user_id: str,
archive_bytes: bytes,
) -> tuple[SkillDraftTreePayload, dict[str, Any], str]:
@classmethod
def _read_skill_package(cls, content: bytes) -> _SkillPackage:
"""Read an uploaded skill zip and validate the name and description in its SKILL.md."""
max_bytes = max_skill_archive_bytes()
if len(content) > max_bytes:
raise SkillManagementServiceError(
SkillServiceErrorCode.ARCHIVE_TOO_LARGE, f"skill archive is over {_size_text(max_bytes)}"
)
contents = cls._read_skill_archive(archive_bytes=content)
return _SkillPackage(
contents=contents,
name=cls._require_frontmatter_name(contents.metadata, content=contents.skill_md_content),
description=cls._require_frontmatter_description(contents.metadata, content=contents.skill_md_content),
)
@classmethod
def _read_skill_archive(cls, *, archive_bytes: bytes) -> _SkillArchiveContents:
try:
with zipfile.ZipFile(io.BytesIO(archive_bytes)) as archive:
infos = self._validate_archive_limits(archive)
infos = [
info
for info in cls._validate_archive_limits(archive)
if not is_os_junk(normalize_skill_file_path(info.filename.strip("/")))
]
raw_paths = [normalize_skill_file_path(info.filename.strip("/")) for info in infos]
path_map = self._strip_single_root(raw_paths)
path_map = cls._strip_single_root(raw_paths)
if _SKILL_MD not in set(path_map.values()):
raise SkillManagementServiceError("missing_skill_md", "Skill package must contain SKILL.md")
items: list[SkillDraftTreeItemPayload] = []
entries: list[SkillDraftTreeItemPayload | _BinaryArchiveEntry] = []
metadata: dict[str, Any] = {}
skill_md_content = ""
for info in infos:
@@ -3832,47 +4002,168 @@ class SkillManagementService:
continue
path = normalize_skill_file_path(path_map[raw_path])
if info.is_dir():
items.append(SkillDraftTreeItemPayload(path=path, kind=SkillFileKind.DIRECTORY))
entries.append(SkillDraftTreeItemPayload(path=path, kind=SkillFileKind.DIRECTORY))
continue
payload = archive.read(info)
text = self._decode_text_payload(path, payload)
text = cls._decode_text_payload(path, payload)
if path == _SKILL_MD:
if text is None:
raise SkillManagementServiceError("invalid_skill_md", "SKILL.md must be UTF-8 text")
text = self._normalize_newlines(text)
metadata = self._parse_frontmatter(text)
text = cls._normalize_newlines(text)
metadata = cls._parse_frontmatter(text)
skill_md_content = text
if text is not None:
items.append(
SkillDraftTreeItemPayload(
path=path,
storage=SkillFileStorage.TEXT,
mime_type=self._guess_mime_type(path),
content=text,
)
)
else:
tool_file = self._tool_files.create_file_by_raw(
user_id=user_id,
tenant_id=tenant_id,
conversation_id=None,
file_binary=payload,
mimetype=self._guess_mime_type(path),
filename=path.rsplit("/", 1)[-1],
)
items.append(
SkillDraftTreeItemPayload(
path=path,
storage=SkillFileStorage.TOOL_FILE,
mime_type=self._guess_mime_type(path),
tool_file_id=tool_file.id,
size=len(payload),
hash=hashlib.sha256(payload).hexdigest(),
)
if text is None:
entries.append(_BinaryArchiveEntry(path=path, payload=payload))
continue
entries.append(
SkillDraftTreeItemPayload(
path=path,
storage=SkillFileStorage.TEXT,
mime_type=cls._guess_mime_type(path),
content=text,
)
)
except zipfile.BadZipFile as exc:
raise SkillManagementServiceError("invalid_skill_package", "skill package must be a valid zip") from exc
return SkillDraftTreePayload(files=items), metadata, skill_md_content
return _SkillArchiveContents(entries=tuple(entries), metadata=metadata, skill_md_content=skill_md_content)
def _store_archive_entries(
self, contents: _SkillArchiveContents, *, uploads: _ToolFileUploads
) -> SkillDraftTreePayload:
items: list[SkillDraftTreeItemPayload] = []
for entry in contents.entries:
if isinstance(entry, _BinaryArchiveEntry):
items.append(self._store_binary_entry(entry, uploads=uploads))
continue
items.append(entry)
return SkillDraftTreePayload(files=items)
def _store_binary_entry(
self, entry: _BinaryArchiveEntry, *, uploads: _ToolFileUploads
) -> SkillDraftTreeItemPayload:
digest = hashlib.sha256(entry.payload).hexdigest()
size = len(entry.payload)
mime_type = self._guess_mime_type(entry.path)
tool_file_id = self._reusable_tool_file_id(uploads, digest=digest, size=size)
if tool_file_id is None:
tool_file = self._tool_files.create_file_by_raw(
user_id=uploads.user_id,
tenant_id=uploads.tenant_id,
conversation_id=None,
file_binary=entry.payload,
mimetype=mime_type,
filename=entry.path.rsplit("/", 1)[-1],
)
uploads.created.append(tool_file)
tool_file_id = tool_file.id
return SkillDraftTreeItemPayload(
path=entry.path,
storage=SkillFileStorage.TOOL_FILE,
mime_type=mime_type,
tool_file_id=tool_file_id,
size=size,
hash=digest,
)
def _reusable_tool_file_id(self, uploads: _ToolFileUploads, *, digest: str, size: int) -> str | None:
for candidate_id in uploads.candidate_ids.get((digest, size), ()):
if candidate_id not in uploads.stored_digests:
uploads.stored_digests[candidate_id] = self._stored_tool_file_digest(
tenant_id=uploads.tenant_id, tool_file_id=candidate_id
)
if uploads.stored_digests[candidate_id] == digest:
return candidate_id
return None
@classmethod
def _stored_tool_file_digest(cls, *, tenant_id: str, tool_file_id: str) -> str | None:
try:
payload = cls._load_draft_tool_file_bytes(tenant_id=tenant_id, file_id=tool_file_id)
except Exception:
# Reuse is only an optimization; an unreadable candidate means storing a fresh copy.
logger.warning("Skill draft tool file %s could not be read for reuse", tool_file_id, exc_info=True)
return None
return hashlib.sha256(payload).hexdigest()
def _replace_draft_from_archive(
self,
session,
*,
skill: Skill,
user_id: str,
contents: _SkillArchiveContents,
display_name: str | None = None,
) -> list[SkillDraftFile]:
"""``display_name`` replaces the skill's unless SKILL.md names one; None keeps the skill's.
It is set only after the uploads, so no pending skill update holds a row lock while they run.
"""
uploads = _ToolFileUploads(
tenant_id=skill.tenant_id,
user_id=user_id,
candidate_ids=self._reuse_candidate_ids(session, skill=skill),
)
with self._discard_uploads_on_failure(session, uploads=uploads):
draft_payload = self._store_archive_entries(contents, uploads=uploads)
if display_name is not None:
skill.display_name = display_name
files = self._build_draft_rows_from_tree(
skill=skill,
payload=draft_payload,
sync_frontmatter_name=True,
strict_frontmatter=True,
)
self._replace_draft_rows(session, skill=skill, files=files, user_id=user_id)
return files
@staticmethod
def _reuse_candidate_ids(session, *, skill: Skill) -> dict[tuple[str, int], tuple[str, ...]]:
"""Draft rows whose tool file exists in the skill's tenant with the size the row claims."""
rows = session.execute(
select(SkillDraftFile.hash, SkillDraftFile.size, SkillDraftFile.tool_file_id)
.join(ToolFile, ToolFile.id == SkillDraftFile.tool_file_id)
.where(
SkillDraftFile.skill_id == skill.id,
SkillDraftFile.storage == SkillFileStorage.TOOL_FILE,
SkillDraftFile.hash != "",
ToolFile.tenant_id == skill.tenant_id,
ToolFile.size == SkillDraftFile.size,
)
).all()
candidates: dict[tuple[str, int], tuple[str, ...]] = {}
for row in rows:
key = (row.hash, row.size)
candidates[key] = (*candidates.get(key, ()), row.tool_file_id)
return candidates
@contextmanager
def _discard_uploads_on_failure(self, session, *, uploads: _ToolFileUploads) -> Generator[None]:
"""Delete the tool files this request created when the block fails, then re-raise.
Reused files are never in ``uploads.created``: other draft rows or features may reference them.
"""
try:
yield
except Exception:
if uploads.created:
self._delete_created_tool_files(session, uploads=uploads)
raise
@staticmethod
def _delete_created_tool_files(session, *, uploads: _ToolFileUploads) -> None:
tool_file_ids = [tool_file.id for tool_file in uploads.created]
try:
# Release the failed transaction's row locks before the slower storage deletes.
session.rollback()
for tool_file in uploads.created:
with suppress(FileNotFoundError):
storage.delete(tool_file.file_key)
with session_factory.create_session() as cleanup_session, cleanup_session.begin():
cleanup_session.execute(
delete(ToolFile).where(ToolFile.tenant_id == uploads.tenant_id, ToolFile.id.in_(tool_file_ids))
)
except Exception:
logger.exception("Failed to delete tool files stored by a failed skill archive request: %s", tool_file_ids)
def _version_files_from_archive_bytes(self, archive_bytes: bytes) -> list[dict[str, Any]]:
try:
@@ -3935,7 +4226,7 @@ class SkillManagementService:
"""Validate ZIP metadata before any member is decompressed or persisted."""
infos = archive.infolist()
if len(infos) > dify_config.SKILL_PACKAGE_MAX_ENTRIES:
raise SkillManagementServiceError("too_many_files", "skill file count limit exceeded")
raise _too_many_files_error()
total_uncompressed = 0
for info in infos:
@@ -3944,7 +4235,7 @@ class SkillManagementService:
total_uncompressed += info.file_size
if total_uncompressed > dify_config.SKILL_PACKAGE_MAX_UNCOMPRESSED_BYTES:
raise SkillManagementServiceError("skill_too_large", "skill exceeds uncompressed size limit")
raise _skill_too_large_error()
if info.is_dir() or info.file_size == 0:
continue
@@ -3952,21 +4243,6 @@ class SkillManagementService:
raise SkillManagementServiceError("invalid_skill_package", "skill package has invalid ZIP metadata")
return infos
def _draft_rows_from_archive_bytes(
self,
*,
tenant_id: str,
user_id: str,
skill: Skill,
archive_bytes: bytes,
) -> list[SkillDraftFile]:
payload, _metadata, _skill_md_content = self._draft_payload_from_zip(
tenant_id=tenant_id,
user_id=user_id,
archive_bytes=archive_bytes,
)
return self._build_draft_rows_from_tree(skill=skill, payload=payload, sync_frontmatter_name=True)
@staticmethod
def _draft_payload_items_from_rows(files: list[SkillDraftFile]) -> list[SkillDraftTreeItemPayload]:
return [
@@ -4140,7 +4416,7 @@ class SkillManagementService:
parent = posixpath.dirname(parent)
if len(entries_by_path) > dify_config.SKILL_PACKAGE_MAX_ENTRIES:
raise SkillManagementServiceError("too_many_files", "skill file count limit exceeded")
raise _too_many_files_error()
rows: list[SkillDraftFile] = []
total_size = 0
@@ -4173,7 +4449,7 @@ class SkillManagementService:
)
if total_size > dify_config.SKILL_PACKAGE_MAX_UNCOMPRESSED_BYTES:
raise SkillManagementServiceError("skill_too_large", "skill exceeds uncompressed size limit")
raise _skill_too_large_error()
return rows
def _sync_skill_metadata_from_draft_skill_md(self, *, skill: Skill, content: str) -> None:
@@ -4434,7 +4710,7 @@ class SkillManagementService:
def _enforce_total_size(files: list[SkillDraftFile]) -> None:
total = sum(file.size or 0 for file in {file.path: file for file in files}.values())
if total > dify_config.SKILL_PACKAGE_MAX_UNCOMPRESSED_BYTES:
raise SkillManagementServiceError("skill_too_large", "skill exceeds uncompressed size limit")
raise _skill_too_large_error()
@staticmethod
def _load_tool_file_bytes(*, tenant_id: str, file_id: str) -> bytes:
@@ -4889,13 +5165,16 @@ __all__ = [
"SkillDraftFileOperationPayload",
"SkillDraftTreeItemPayload",
"SkillDraftTreePayload",
"SkillErrorDetail",
"SkillImportPayload",
"SkillManagementService",
"SkillManagementServiceError",
"SkillMetadataPayload",
"SkillPublishPayload",
"SkillRestorePayload",
"SkillServiceErrorCode",
"SkillVersionUpdatePayload",
"max_skill_archive_bytes",
"normalize_skill_file_path",
"validate_skill_description",
"validate_skill_name",
@@ -27,11 +27,12 @@ from controllers.common.rbac import AgentBehindApp, PlainApp, RBACCheck, RBACPer
from controllers.openapi import bp as openapi_bp
from controllers.openapi._app_settings import REGULAR_MODES
from controllers.openapi._catalog import CATALOG_HEADER, catalog_for
from controllers.openapi._contract import op_of
from controllers.openapi._contract import CapRequestBody, op_of
from controllers.openapi._errors import (
OpenApiErrorCode,
OpenApiErrorFormatter,
ServiceApiDisabled,
SkillsDisabled,
WebAppAccessRequiresEE,
)
from controllers.openapi._models import ServiceApiPatch
@@ -46,6 +47,7 @@ from controllers.openapi.auth.requirements import (
CheckPluginInstallSetting,
CheckRBACPermission,
CheckScope,
CheckSkillEnabled,
CheckSubject,
CheckWebAppAuthEnterprise,
CheckWorkspaceInvitationQuota,
@@ -180,6 +182,19 @@ class TestRequirementsCases:
CheckAppApiEnabled().run(subject, make_ctx(sqlite_session, subject=subject, app_id=APP_ID), sqlite_session)
@staticmethod
def test_check_skill_enabled_follows_the_server_flag(
sqlite_session: Session, config_overrides: Callable[..., None]
) -> None:
subject = account_subject()
ctx = make_ctx(sqlite_session, subject=subject)
config_overrides(ENABLE_SKILL=True)
CheckSkillEnabled().run(subject, ctx, sqlite_session)
config_overrides(ENABLE_SKILL=False)
with pytest.raises(SkillsDisabled):
CheckSkillEnabled().run(subject, ctx, sqlite_session)
class TestCheckRBACPermission:
@staticmethod
def _requirement() -> CheckRBACPermission:
@@ -642,6 +657,26 @@ ROUTES: tuple[Route, ...] = (
"/workspaces/{workspace_id}/tool-providers/{provider}/credentials/{credential_id}",
frozenset({Trait.ACCOUNT_PRIMARY}),
),
Route("skills.list", "GET", "/workspaces/{workspace_id}/skills", frozenset({Trait.ACCOUNT_PRIMARY})),
Route("skills.describe", "GET", "/workspaces/{workspace_id}/skills/{skill_id}", frozenset({Trait.ACCOUNT_PRIMARY})),
Route(
"skills.versions",
"GET",
"/workspaces/{workspace_id}/skills/{skill_id}/versions",
frozenset({Trait.ACCOUNT_PRIMARY}),
),
Route(
"skills.export",
"GET",
"/workspaces/{workspace_id}/skills/{skill_id}/archive",
frozenset({Trait.ACCOUNT_PRIMARY}),
),
Route(
"skills.publish",
"POST",
"/workspaces/{workspace_id}/skills/{skill_id}:publish",
frozenset({Trait.ACCOUNT_PRIMARY}),
),
Route(
"knowledge_bases.list",
"GET",
@@ -666,6 +701,7 @@ ROUTES: tuple[Route, ...] = (
"/workspaces/{workspace_id}/model-providers/{provider}/models/credentials/{credential_id}",
frozenset({Trait.ACCOUNT_PRIMARY}),
),
Route("skills.import", "POST", "/workspaces/{workspace_id}/skills/imports", frozenset({Trait.ACCOUNT_PRIMARY})),
Route(
"app_dsl.import",
"POST",
@@ -1310,6 +1346,9 @@ MATRIX: dict[str, dict[Case, Expect]] = {
"workspaces.members.invite": dict(_ACCOUNT_MEMBER_WITH_ROLE),
"workspaces.members.update_role": dict(_ACCOUNT_MEMBER_WITH_ROLE),
"plugins.install": {**_ACCOUNT_MEMBER_NO_ROLE, Case.RBAC_ON_DENIED: DENY_RBAC},
"skills.list": {**_ACCOUNT_MEMBER_NO_ROLE, Case.RBAC_ON_DENIED: DENY_RBAC},
"skills.publish": dict(_ACCOUNT_MEMBER_WITH_ROLE),
"skills.import": dict(_ACCOUNT_MEMBER_WITH_ROLE),
"model_providers.credential.create": dict(_ACCOUNT_MEMBER_WITH_ROLE),
"app_dsl.import": dict(_ACCOUNT_MEMBER_WITH_ROLE),
"app_dsl.check": dict(_ACCOUNT_MEMBER_WITH_ROLE),
@@ -1395,6 +1434,11 @@ _REQ_ACCOUNT_WORKSPACE_READ_MEMBER = (
CheckScope(Scope.WORKSPACE_READ),
CheckWorkspaceMember(),
)
_REQ_SKILL_READ = (
CheckSkillEnabled(),
*_REQ_ACCOUNT_WORKSPACE_READ_MEMBER,
CheckRBACPermission(RBACCheck(RBACPermission.SKILL_VIEW, Workspace())),
)
_REQ_MEMBER_MANAGE = (
CheckSubject(allowed=_ACCOUNT),
CheckScope(Scope.WORKSPACE_WRITE),
@@ -1537,6 +1581,17 @@ def _req_admin_write(permission: RBACPermission) -> tuple[Requirement, ...]:
)
def _req_skill_write(permission: RBACPermission) -> tuple[Requirement, ...]:
return (
CheckSkillEnabled(),
CheckSubject(allowed=_ACCOUNT),
CheckScope(Scope.WORKSPACE_WRITE),
CheckWorkspaceMember(),
CheckRBACPermission(RBACCheck(permission, Workspace())),
CheckWorkspaceRole(_EDITOR_UP),
)
_REQ_PLUGIN_TASK = (
CheckSubject(allowed=_ACCOUNT),
CheckScope(Scope.WORKSPACE_READ),
@@ -1600,6 +1655,15 @@ DECLARED: dict[str, tuple[Requirement, ...]] = {
"tool_providers.describe": _REQ_ACCOUNT_WORKSPACE_READ_MEMBER,
"tool_providers.credential.create": _req_admin_write(RBACPermission.CREDENTIAL_CREATE),
"tool_providers.credential.set": _req_admin_write(RBACPermission.CREDENTIAL_MANAGE),
"skills.list": _REQ_SKILL_READ,
"skills.describe": _REQ_SKILL_READ,
"skills.versions": _REQ_SKILL_READ,
"skills.export": _REQ_SKILL_READ,
"skills.publish": _req_skill_write(RBACPermission.SKILL_PUBLISH),
"skills.import": (
*_req_skill_write(RBACPermission.SKILL_EDIT),
CapRequestBody(max_bytes=lambda: 0, max_parts=lambda: 0),
),
"knowledge_bases.list": _REQ_ACCOUNT_WORKSPACE_READ_MEMBER,
"models.list": _REQ_ACCOUNT_WORKSPACE_READ_MEMBER,
"models.credential.create": _req_admin_write(RBACPermission.CREDENTIAL_CREATE),
@@ -1930,6 +1994,7 @@ def _url(route: Route, world: World, scenario: Scenario, bearer: Bearer | None)
"form_token": uuid.uuid4().hex,
"run_id": str(uuid.uuid4()),
"version_id": str(uuid.uuid4()),
"skill_id": str(uuid.uuid4()),
"env_id": str(uuid.uuid4()),
"node_type": "llm",
"node_id": "node-1",
@@ -2137,6 +2202,12 @@ EXPECTED_RESPONSE_CODES: dict[tuple[str, str], frozenset[str]] = {
{"200", "422", "default"}
),
("get", "/workspaces/{workspace_id}/knowledge-bases"): frozenset({"200", "422", "default"}),
("get", "/workspaces/{workspace_id}/skills"): frozenset({"200", "422", "default"}),
("get", "/workspaces/{workspace_id}/skills/{skill_id}"): frozenset({"200", "default"}),
("get", "/workspaces/{workspace_id}/skills/{skill_id}/versions"): frozenset({"200", "422", "default"}),
("get", "/workspaces/{workspace_id}/skills/{skill_id}/archive"): frozenset({"200", "422", "default"}),
("post", "/workspaces/{workspace_id}/skills/{skill_id}:publish"): frozenset({"200", "422", "default"}),
("post", "/workspaces/{workspace_id}/skills/imports"): frozenset({"200", "201", "422", "default"}),
("get", "/workspaces/{workspace_id}/models"): frozenset({"200", "422", "default"}),
("post", "/workspaces/{workspace_id}/model-providers/{provider}/models/credentials"): frozenset(
{"201", "422", "default"}
@@ -18,7 +18,14 @@ from werkzeug.exceptions import RequestEntityTooLarge, UnprocessableEntity
from controllers.common.schema import register_response_schema_model, register_schema_model
from controllers.openapi import openapi_ns
from controllers.openapi._contract import CapRequestBody, accepts, paginated, returns
from controllers.openapi._contract import (
MULTIPART_HEADER_BYTES_PER_PART,
CapRequestBody,
accepts,
multipart_header_allowance,
paginated,
returns,
)
from controllers.openapi._errors import RequestOverRouteLimits
from controllers.openapi._models import Hint, PaginationEnvelope
from controllers.openapi._upload import UploadPart
@@ -129,6 +136,23 @@ def test_accepts_injects_validated_query_with_defaults_for_absent_fields(app):
assert result.limit == 20
class ContractListQuery(BaseModel):
tag: list[str] | None = None
word: str | None = None
def test_accepts_fills_list_typed_query_fields_from_repeated_parameters(app: Flask) -> None:
@accepts(query=ContractListQuery)
def view(*, query: ContractListQuery) -> ContractListQuery:
return query
with app.test_request_context("/?tag=a&tag=b&word=x&word=y"):
result = view()
assert result.tag == ["a", "b"]
assert result.word == "x"
@pytest.mark.parametrize("query_string", ["page=abc", "unknown=1"])
def test_accepts_rejects_invalid_query_with_422(app, query_string):
@accepts(query=ContractQuery)
@@ -278,3 +302,8 @@ def test_an_uncapped_route_keeps_the_generic_413(app: Flask, view: Callable[[],
with _post(app, 1, extra="y"), pytest.raises(RequestEntityTooLarge) as exc:
view()
assert not isinstance(exc.value, RequestOverRouteLimits)
def test_multipart_header_allowance_scales_with_the_part_count() -> None:
assert multipart_header_allowance(0) == 0
assert multipart_header_allowance(5002) == 5002 * MULTIPART_HEADER_BYTES_PER_PART
@@ -9,7 +9,8 @@ from flask.ctx import RequestContext
from werkzeug.datastructures import FileStorage
from controllers.openapi._errors import InvalidFilePart
from controllers.openapi._multipart import body_from_request
from controllers.openapi._multipart import body_from_request, folder_streams
from controllers.openapi._upload import UploadParts
_FILE_FIELDS = frozenset({"file", "files", "attachments"})
@@ -68,6 +69,18 @@ def test_multipart_rejects_malformed_bodies(app: Flask, data: Mapping[str, objec
body_from_request(file_fields=_FILE_FIELDS)
def test_folder_streams_maps_each_path_to_its_stream() -> None:
first, second = FileStorage(stream=BytesIO(b"a")), FileStorage(stream=BytesIO(b"b"))
streams = folder_streams({"SKILL.md": first, "scripts/run.py": second})
assert streams == {"SKILL.md": first.stream, "scripts/run.py": second.stream}
def test_folder_streams_rejects_a_path_given_more_than_once() -> None:
parts: UploadParts = {"SKILL.md": [FileStorage(stream=BytesIO(b"a")), FileStorage(stream=BytesIO(b"b"))]}
with pytest.raises(InvalidFilePart, match="SKILL.md"):
folder_streams(parts)
@pytest.mark.parametrize("direct", ["files", "files[]"])
@pytest.mark.parametrize("mapped", ["files[doc]", "files[doc][]"])
@pytest.mark.parametrize("mapped_first", [False, True], ids=["direct_first", "mapped_first"])
@@ -0,0 +1,501 @@
from __future__ import annotations
import io
import zipfile
from collections.abc import Callable, Mapping
from typing import Protocol, cast
import pytest
from flask import Flask, Response
from flask.ctx import RequestContext as FlaskRequestContext
from flask_restx import Resource
from pydantic import ValidationError
from sqlalchemy.orm import Session
from werkzeug.datastructures import FileStorage
from werkzeug.exceptions import UnprocessableEntity
from configs import dify_config
from constants import BYTES_PER_MIB
from controllers.openapi._contract import accepts
from controllers.openapi._errors import (
DraftChanged,
InvalidFilePart,
OpenApiError,
OpenApiErrorCode,
RequestOverRouteLimits,
SkillNameConflict,
SkillNameMismatch,
SkillNotFound,
SkillPackageInvalid,
SkillPackageTooLarge,
SkillRequestRejected,
SkillsDisabled,
SkillVersionNotFound,
)
from controllers.openapi._models import (
SkillDescribeResponse,
SkillExportQuery,
SkillImportPayload,
SkillImportResponse,
SkillListQuery,
SkillListResponse,
SkillPublishPayload,
SkillPublishResponse,
SkillVersionListQuery,
SkillVersionListResponse,
)
from controllers.openapi._upload import UploadParts
from controllers.openapi.auth.pipelines import Pipeline
from controllers.openapi.auth.spec import EndpointSpec
from controllers.openapi.skills import (
_UPLOAD_CAP,
ServiceRejection,
SkillDescribeApi,
SkillExportApi,
SkillImportApi,
SkillListApi,
SkillPublishApi,
SkillVersionListApi,
_draft_hash,
_expected_updated_at,
_rejection,
_skill_upload_parts,
)
from machinery.context import RequestContext
from services.skill_management_service import (
PublishedSkillArchive,
SkillErrorDetail,
SkillManagementService,
SkillManagementServiceError,
SkillOverwritePayload,
SkillServiceErrorCode,
)
from services.skill_management_service import SkillImportPayload as ServiceSkillImportPayload
from services.skill_management_service import SkillPublishPayload as ServiceSkillPublishPayload
from tests.helpers.unit.controllers.openapi.auth._world import TENANT_ID, account_subject, make_ctx, never_reached
WORKSPACE_ID = "ws-id"
SKILL_ID = "11111111-1111-4111-8111-111111111111"
EXISTING_ID = "22222222-2222-4222-8222-222222222222"
UPDATED_AT = 1761200000
SKILL_MD = {"SKILL.md": b"x"}
type Record = dict[str, object]
class Endpoint[T](Protocol):
__handler__: Callable[..., T]
__spec__: EndpointSpec
def _context() -> RequestContext:
return RequestContext(request_id="r", trace_id=None, account_id="acct-id", active_workspace_id=WORKSPACE_ID)
def _skill(**extra: object) -> Record:
return {
"id": SKILL_ID,
"name": "expense-sop",
"display_name": "Expense SOP",
"description": "How to file expenses",
"icon": None,
"tags": ["ops"],
"latest_published_version_id": "ver-2",
"updated_at": UPDATED_AT,
"files": [{"path": "SKILL.md"}],
**extra,
}
def _version(number: int, *, latest: bool = False) -> Record:
return {
"id": f"ver-{number}",
"version_number": number,
"version_name": None,
"publish_note": "",
"published_by": "acct-id",
"is_latest": latest,
"created_at": UPDATED_AT + number,
}
def _stub(monkeypatch: pytest.MonkeyPatch, name: str, replacement: Callable[..., object]) -> None:
def stand_in(_service: SkillManagementService, **kwargs: object) -> object:
return replacement(**kwargs)
monkeypatch.setattr(SkillManagementService, name, stand_in)
def _record(monkeypatch: pytest.MonkeyPatch, name: str, result: object) -> list[Record]:
"""Stub a service method that returns ``result`` and keeps the keyword arguments of each call."""
seen: list[Record] = []
def record(**kwargs: object) -> object:
seen.append(kwargs)
return result
_stub(monkeypatch, name, record)
return seen
def _reject(code: str, **details: object) -> Callable[..., object]:
def reject(**_kwargs: object) -> object:
raise SkillManagementServiceError(code, "rejected", details=details)
return reject
def _call(api: Resource, view: object, *args: object, **kwargs: object) -> object:
return cast(Endpoint[object], view).__handler__(api, _context(), WORKSPACE_ID, *args, **kwargs)
def _folder(files: Mapping[str, bytes]) -> UploadParts:
return {path: FileStorage(stream=io.BytesIO(data)) for path, data in files.items()}
def _import(files: Mapping[str, bytes], **fields: object) -> tuple[SkillImportResponse, int]:
body = SkillImportPayload.model_validate({"files": _folder(files), **fields})
return cast(tuple[SkillImportResponse, int], _call(SkillImportApi(), SkillImportApi.post, body=body))
def _archive(filename: str) -> PublishedSkillArchive:
buffer = io.BytesIO()
with zipfile.ZipFile(buffer, "w") as archive:
archive.writestr("expense-sop/SKILL.md", "---\nname: expense-sop\n---\n")
return PublishedSkillArchive(filename=filename, mime_type="application/zip", payload=buffer.getvalue())
_HANDLER_CALLS: Mapping[str, Callable[[], object]] = {
"get_skill": lambda: _call(SkillDescribeApi(), SkillDescribeApi.get, SKILL_ID),
"export_version_archive": lambda: _call(
SkillExportApi(), SkillExportApi.get, SKILL_ID, query=SkillExportQuery(version_id=SKILL_ID)
),
"publish_skill": lambda: _call(SkillPublishApi(), SkillPublishApi.post, SKILL_ID, body=SkillPublishPayload()),
"import_skill": lambda: _import(SKILL_MD),
"overwrite_skill": lambda: _import(SKILL_MD, skill_id=SKILL_ID),
}
def test_every_mapped_code_is_a_service_code() -> None:
assert {rejection.value for rejection in ServiceRejection} <= {code.value for code in SkillServiceErrorCode}
def test_rejection_relays_unknown_codes_with_their_status() -> None:
error = _rejection(SkillManagementServiceError("brand_new_code", "msg", status_code=422, details={"a": 1}))
assert isinstance(error, SkillRequestRejected)
assert (error.code, error.description) == (422, "msg")
assert error.details is not None
assert [(d.type, d.loc, d.msg) for d in error.details] == [
("brand_new_code", [], "msg"),
("brand_new_code", ["a"], "1"),
]
@pytest.mark.parametrize(
("method", "code", "error_type", "status"),
[
("get_skill", SkillServiceErrorCode.SKILL_NOT_FOUND, SkillNotFound, 404),
("export_version_archive", SkillServiceErrorCode.SKILL_VERSION_NOT_FOUND, SkillVersionNotFound, 404),
("overwrite_skill", SkillServiceErrorCode.SKILL_CONFLICT, DraftChanged, 409),
("import_skill", SkillServiceErrorCode.SKILL_NAME_CONFLICT, SkillNameConflict, 409),
("overwrite_skill", SkillServiceErrorCode.SKILL_NAME_MISMATCH, SkillNameMismatch, 400),
("publish_skill", SkillServiceErrorCode.INVALID_SKILL_MD, SkillPackageInvalid, 400),
("overwrite_skill", SkillServiceErrorCode.MISSING_SKILL_NAME, SkillPackageInvalid, 400),
("overwrite_skill", SkillServiceErrorCode.INVALID_SKILL_NAME, SkillPackageInvalid, 400),
*(("import_skill", code, code.error, 400) for code in ServiceRejection if code.error is SkillPackageInvalid),
*(("import_skill", code, code.error, 413) for code in ServiceRejection if code.error is SkillPackageTooLarge),
],
ids=lambda value: value.__name__ if isinstance(value, type) else None,
)
def test_handlers_answer_a_service_rejection_with_its_error_and_message(
app: Flask, monkeypatch: pytest.MonkeyPatch, method: str, code: str, error_type: type[OpenApiError], status: int
) -> None:
_stub(monkeypatch, method, _reject(code))
with app.test_request_context(), pytest.raises(error_type) as exc:
_HANDLER_CALLS[method]()
assert (exc.value.code, exc.value.description) == (status, "rejected")
def test_draft_hash_is_epoch_seconds() -> None:
assert _draft_hash(1761200000) == "1761200000"
assert _expected_updated_at("1761200000") == 1761200000
assert _expected_updated_at(None) is None
@pytest.mark.parametrize(
("api", "view", "kwargs"),
[
(SkillDescribeApi, SkillDescribeApi.get, {}),
(SkillVersionListApi, SkillVersionListApi.get, {"query": SkillVersionListQuery(page=1, limit=2)}),
(SkillExportApi, SkillExportApi.get, {"query": SkillExportQuery()}),
(SkillPublishApi, SkillPublishApi.post, {"body": SkillPublishPayload()}),
],
)
def test_non_uuid_skill_id_is_404(api: type[Resource], view: object, kwargs: dict[str, object]) -> None:
with pytest.raises(SkillNotFound):
_call(api(), view, "not-a-uuid", **kwargs)
def test_list_skills_passes_filters_and_pages(monkeypatch: pytest.MonkeyPatch) -> None:
page = {"data": [_skill()], "total": 11, "page": 2, "limit": 5, "has_more": True}
seen = _record(monkeypatch, "list_skills", page)
query = SkillListQuery.model_validate({"keyword": "sop", "tag": ["ops"], "page": 2, "limit": 5})
result = cast(SkillListResponse, _call(SkillListApi(), SkillListApi.get, query=query))
assert seen == [{"tenant_id": WORKSPACE_ID, "keyword": "sop", "tags": ["ops"], "page": 2, "limit": 5}]
assert (result.page, result.limit, result.total, result.has_more) == (2, 5, 11, True)
assert result.data[0].name == "expense-sop"
@pytest.mark.parametrize(
("versions", "latest"),
[([_version(2, latest=True), _version(1)], {"id": "ver-2", "created_by": "acct-id"}), ([], None)],
ids=["published", "never-published"],
)
def test_describe_skill_returns_draft_hash_and_latest_version(
monkeypatch: pytest.MonkeyPatch, versions: list[Record], latest: Record | None
) -> None:
_stub(monkeypatch, "get_skill", lambda **_: _skill())
_stub(monkeypatch, "list_versions", lambda **_: {"data": versions})
result = cast(SkillDescribeResponse, _call(SkillDescribeApi(), SkillDescribeApi.get, SKILL_ID))
assert result.draft_hash == str(UPDATED_AT)
assert result.model_dump(include={"latest_version": {"id", "created_by"}}) == {"latest_version": latest}
assert "files" not in result.model_dump()
def test_list_versions_newest_first_and_paged(monkeypatch: pytest.MonkeyPatch) -> None:
_stub(monkeypatch, "list_versions", lambda **_: {"data": [_version(3, latest=True), _version(2), _version(1)]})
query = SkillVersionListQuery(page=1, limit=2)
result = cast(
SkillVersionListResponse, _call(SkillVersionListApi(), SkillVersionListApi.get, SKILL_ID, query=query)
)
assert [row.version_number for row in result.data] == [3, 2]
assert (result.has_more, result.total) == (True, 3)
@pytest.mark.parametrize(
("method", "version", "filename"),
[("export_draft_archive", {}, "expense-sop.zip"), ("export_version_archive", {"version_id": SKILL_ID}, "v2.zip")],
ids=["draft", "version"],
)
def test_export_sends_the_zip_as_an_attachment(
app: Flask, monkeypatch: pytest.MonkeyPatch, method: str, version: dict[str, str], filename: str
) -> None:
seen = _record(monkeypatch, method, _archive(filename))
query = SkillExportQuery.model_validate(version)
with app.test_request_context():
response = cast(Response, _call(SkillExportApi(), SkillExportApi.get, SKILL_ID, query=query))
assert seen == [{"tenant_id": WORKSPACE_ID, "skill_id": SKILL_ID, **version}]
assert response.mimetype == "application/zip"
assert response.headers["Content-Disposition"] == f"attachment; filename={filename}"
response.direct_passthrough = False
assert zipfile.ZipFile(io.BytesIO(response.get_data())).namelist() == ["expense-sop/SKILL.md"]
def test_publish_passes_name_and_note(monkeypatch: pytest.MonkeyPatch) -> None:
seen = _record(monkeypatch, "publish_skill", _version(2, latest=True))
body = SkillPublishPayload(version_name="v2", publish_note="retry")
result = cast(SkillPublishResponse, _call(SkillPublishApi(), SkillPublishApi.post, SKILL_ID, body=body))
assert seen == [
{
"tenant_id": WORKSPACE_ID,
"user_id": "acct-id",
"skill_id": SKILL_ID,
"payload": ServiceSkillPublishPayload(version_name="v2", publish_note="retry"),
}
]
assert (result.version_number, result.created_by) == (2, "acct-id")
def test_import_folder_creates_skill(monkeypatch: pytest.MonkeyPatch) -> None:
seen = _record(monkeypatch, "import_skill", _skill(files=[{"path": "SKILL.md"}, {"path": "scripts/run.py"}]))
response, status = _import({"SKILL.md": b"---\nname: expense-sop\n---\n", "scripts/run.py": b"x"})
assert status == 201
[sent] = seen
assert (sent["tenant_id"], sent["user_id"]) == (WORKSPACE_ID, "acct-id")
payload = cast(ServiceSkillImportPayload, sent["payload"])
assert zipfile.ZipFile(io.BytesIO(payload.content)).namelist() == ["SKILL.md", "scripts/run.py"]
expected = (SKILL_ID, "expense-sop", str(UPDATED_AT), 2)
assert (response.id, response.name, response.draft_hash, response.file_count) == expected
[hint] = response.hints
assert (hint.op, hint.input) == ("publish.skill", {"workspace_id": WORKSPACE_ID, "skill_id": SKILL_ID})
def test_import_with_skill_id_overwrites(monkeypatch: pytest.MonkeyPatch) -> None:
seen = _record(monkeypatch, "overwrite_skill", _skill())
_stub(monkeypatch, "import_skill", lambda **_: pytest.fail("must not create"))
response, status = _import(SKILL_MD, skill_id=SKILL_ID, draft_hash="1761200000")
assert status == 200
[sent] = seen
assert sent["skill_id"] == SKILL_ID
assert cast(SkillOverwritePayload, sent["payload"]).expected_updated_at == 1761200000
assert response.hints[0].op == "publish.skill"
@pytest.mark.parametrize(
("fields", "loc"),
[
({"skill_id": SKILL_ID, "draft_hash": "abc"}, "draft_hash"),
({"skill_id": SKILL_ID, "draft_hash": "١٢٣"}, "draft_hash"),
({"skill_id": "not-a-uuid"}, "skill_id"),
({"draft_hash": "1761200000"}, "draft_hash"),
],
ids=["letters-draft-hash", "non-ascii-digits-draft-hash", "non-uuid-skill-id", "draft-hash-without-skill-id"],
)
def test_import_payload_rejects_a_bad_field(fields: Record, loc: str) -> None:
with pytest.raises(ValidationError) as exc:
SkillImportPayload.model_validate({"files": _folder(SKILL_MD), **fields})
assert [error["loc"] for error in exc.value.errors()] == [(loc,)]
@pytest.mark.parametrize(
("method", "details", "hints"),
[
(
"import_skill",
{SkillErrorDetail.SKILL_ID: EXISTING_ID},
[("import.skill", {"workspace_id": WORKSPACE_ID, "skill_id": EXISTING_ID, "files": None})],
),
(
"import_skill",
{SkillErrorDetail.NAME: "expense-sop"},
[("get.skill", {"workspace_id": WORKSPACE_ID, "keyword": "expense-sop"})],
),
("overwrite_skill", {}, []),
],
ids=["pre-check-overwrites-the-existing-skill", "commit-race-searches-by-name", "nothing-to-point-at"],
)
def test_import_name_conflict_hints_at_the_clashing_skill(
monkeypatch: pytest.MonkeyPatch, method: str, details: Record, hints: list[tuple[str, Record]]
) -> None:
_stub(monkeypatch, method, _reject(SkillServiceErrorCode.SKILL_NAME_CONFLICT, **details))
with pytest.raises(SkillNameConflict) as exc:
_HANDLER_CALLS[method]()
assert exc.value.error_code == OpenApiErrorCode.SKILL_NAME_CONFLICT
assert exc.value.hints is not None
assert [(hint.op, hint.input) for hint in exc.value.hints] == hints
@pytest.mark.parametrize(
("limit", "files", "message"),
[
(
{"UPLOAD_SKILL_FILE_SIZE_LIMIT": 1},
{"SKILL.md": b"x" * (2 * BYTES_PER_MIB)},
"The skill folder is over 1 MiB",
),
(
{"SKILL_PACKAGE_MAX_ENTRIES": 2},
{"SKILL.md": b"x", "a.txt": b"x", "b.txt": b"x"},
"The skill folder has more than 2 files",
),
],
ids=["bytes", "files"],
)
def test_import_folder_over_a_limit_is_413_naming_the_limit(
monkeypatch: pytest.MonkeyPatch,
config_overrides: Callable[..., None],
limit: Record,
files: Mapping[str, bytes],
message: str,
) -> None:
config_overrides(**limit)
_stub(monkeypatch, "import_skill", lambda **_: pytest.fail("must not reach the service"))
with pytest.raises(SkillPackageTooLarge) as exc:
_import(files)
assert (exc.value.code, exc.value.description) == (413, message)
def test_import_part_cap_counts_files_and_fields(
app: Flask, sqlite_session: Session, monkeypatch: pytest.MonkeyPatch
) -> None:
assert _skill_upload_parts() == dify_config.SKILL_PACKAGE_MAX_ENTRIES + 2
monkeypatch.setattr(dify_config, "SKILL_PACKAGE_MAX_ENTRIES", 2)
assert _skill_upload_parts() == 4
@accepts(body=SkillImportPayload)
def handler(*, body: SkillImportPayload) -> int:
return len(body.files)
def post(*paths: str) -> FlaskRequestContext:
data: dict[str, object] = {
"skill_id": f'"{SKILL_ID}"',
"draft_hash": '"1761200000"',
**{f"files[{path}]": (io.BytesIO(b"x"), path) for path in paths},
}
return app.test_request_context("/x", method="POST", data=data, content_type="multipart/form-data")
with post("SKILL.md", "a.txt"):
_UPLOAD_CAP.run(account_subject(), make_ctx(sqlite_session), sqlite_session)
assert handler() == 2
with post("SKILL.md", "a.txt", "b.txt"):
_UPLOAD_CAP.run(account_subject(), make_ctx(sqlite_session), sqlite_session)
with pytest.raises(RequestOverRouteLimits, match="at most 4 parts"):
handler()
def test_import_bad_path_names_the_path() -> None:
with pytest.raises(SkillPackageInvalid) as exc:
_import({"SKILL.md": b"x", "a/../b": b"x"})
assert exc.value.details is not None
assert any(detail.loc == ["a/../b"] for detail in exc.value.details)
assert "a/../b" in str(exc.value.description)
def test_import_only_junk_is_rejected() -> None:
with pytest.raises(SkillPackageInvalid):
_import({".DS_Store": b"x"})
@pytest.mark.parametrize(
("data", "error_type", "match"),
[
(
{"files[SKILL.md]": [(io.BytesIO(b"a"), "SKILL.md"), (io.BytesIO(b"b"), "SKILL.md")]},
InvalidFilePart,
"SKILL.md",
),
({"files[SKILL.md]": (io.BytesIO(b"x"), "SKILL.md"), "force": "true"}, UnprocessableEntity, None),
],
ids=["same-path-twice", "unknown-field"],
)
def test_import_body_is_rejected_before_parsing(
app: Flask, data: Record, error_type: type[Exception], match: str | None
) -> None:
@accepts(body=SkillImportPayload)
def handler(**_kwargs: object) -> None:
pytest.fail("must not parse")
with (
app.test_request_context("/x", method="POST", data=data, content_type="multipart/form-data"),
pytest.raises(error_type, match=match),
):
handler()
@pytest.mark.parametrize(
"view",
[
SkillListApi.get,
SkillDescribeApi.get,
SkillVersionListApi.get,
SkillExportApi.get,
SkillPublishApi.post,
SkillImportApi.post,
],
)
def test_skills_off_is_503_before_membership_and_permission_checks(
sqlite_session: Session, config_overrides: Callable[..., None], view: object
) -> None:
"""The caller is not a member of the workspace, so any earlier check would refuse it first."""
config_overrides(ENABLE_SKILL=False, RBAC_ENABLED=True)
subject = account_subject()
with pytest.raises(SkillsDisabled):
Pipeline().run(
subject=subject,
auth=subject.auth,
spec=cast(Endpoint[object], view).__spec__,
ctx=make_ctx(sqlite_session, subject=subject, workspace_id=TENANT_ID),
session=sqlite_session,
call=never_reached,
)
@@ -5,15 +5,16 @@ from __future__ import annotations
import io
import json
import zipfile
from collections.abc import Generator
from collections.abc import Callable, Generator, Mapping
from types import SimpleNamespace
from typing import cast, override
from unittest.mock import patch
from uuid import uuid4
import pytest
from sqlalchemy import Table, delete, func, select
from sqlalchemy import Table, delete, func, select, update
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from core.credit_usage import CreditUsageCreatedBy
from core.db.session_factory import session_factory
@@ -61,12 +62,15 @@ from services.skill_management_service import (
SkillDraftFileOperationPayload,
SkillDraftTreeItemPayload,
SkillDraftTreePayload,
SkillErrorDetail,
SkillImportPayload,
SkillManagementService,
SkillManagementServiceError,
SkillMetadataPayload,
SkillOverwritePayload,
SkillPublishPayload,
SkillRestorePayload,
SkillServiceErrorCode,
SkillVersionUpdatePayload,
normalize_skill_file_path,
validate_skill_description,
@@ -80,6 +84,10 @@ USER = "33333333-3333-3333-3333-333333333333"
class _FakeToolFileManager(ToolFileManager):
def __init__(self) -> None:
super().__init__()
self.stored: dict[str, bytes] = {}
@override
def create_file_by_raw(
self,
@@ -105,6 +113,7 @@ class _FakeToolFileManager(ToolFileManager):
with session_factory.create_session() as session:
session.add(tool_file)
session.commit()
self.stored[tool_file.file_key] = file_binary
return tool_file
@@ -2941,7 +2950,7 @@ def test_import_skill_package_rejects_archive_larger_than_upload_skill_limit(mon
payload=SkillImportPayload(content=b"not-empty", filename="expense-sop.zip"),
)
assert exc_info.value.code == "archive_too_large"
assert (exc_info.value.code, exc_info.value.message) == ("archive_too_large", "skill archive is over 0 MiB")
def test_import_skill_package_rejects_excessive_expansion_before_reading_members(
@@ -2958,13 +2967,9 @@ def test_import_skill_package_rejects_excessive_expansion_before_reading_members
patch.object(zipfile.ZipFile, "read", side_effect=AssertionError("member read before size validation")),
pytest.raises(SkillManagementServiceError) as exc_info,
):
service._draft_payload_from_zip(
tenant_id=TENANT,
user_id=USER,
archive_bytes=package.getvalue(),
)
service._read_skill_archive(archive_bytes=package.getvalue())
assert exc_info.value.code == "skill_too_large"
assert (exc_info.value.code, exc_info.value.message) == ("skill_too_large", "skill is over 1 MiB uncompressed")
def test_publish_and_export_include_binary_tool_files() -> None:
@@ -3202,7 +3207,7 @@ def test_build_assistant_attachment_context_includes_extractable_pdf_text() -> N
assert "Binary attachment available" not in context
def _zip_payload(files: dict[str, str]) -> bytes:
def _zip_payload(files: Mapping[str, str | bytes]) -> bytes:
buffer = io.BytesIO()
with zipfile.ZipFile(buffer, "w") as archive:
for path, content in files.items():
@@ -3740,5 +3745,399 @@ def test_archive_limits_accept_high_compression_ratio_within_size_budget(monkeyp
with zipfile.ZipFile(io.BytesIO(output.getvalue())) as archive:
assert SkillManagementService._validate_archive_limits(archive)[0].file_size == 1024 * 1024
apply_config_overrides(monkeypatch, SKILL_PACKAGE_MAX_UNCOMPRESSED_BYTES=1024)
with pytest.raises(SkillManagementServiceError, match="uncompressed size limit"):
with pytest.raises(SkillManagementServiceError, match="^skill is over 1024 bytes uncompressed$"):
SkillManagementService._validate_archive_limits(archive)
def test_archive_limits_name_the_file_count_limit(monkeypatch: pytest.MonkeyPatch) -> None:
apply_config_overrides(monkeypatch, SKILL_PACKAGE_MAX_ENTRIES=2)
content = _zip_payload({"SKILL.md": _skill_md(), "a.md": "a", "b.md": "b"})
with zipfile.ZipFile(io.BytesIO(content)) as archive:
with pytest.raises(SkillManagementServiceError) as exc_info:
SkillManagementService._validate_archive_limits(archive)
assert (exc_info.value.code, exc_info.value.message) == ("too_many_files", "skill has more than 2 files")
OTHER_TENANT = "99999999-9999-9999-9999-999999999999"
_LOGO = b"\x89PNG\r\n\x1a\n\x00logo"
_CHART = b"\x89PNG\r\n\x1a\n\x00chart"
_SAME_SIZE_AS_LOGO = b"\x89PNG\r\n\x1a\n\x00fake"
_NAME_DETAILS = {"path": "SKILL.md", "field": "name", "line": 2}
_DESCRIPTION_DETAILS = {**_NAME_DETAILS, "field": "description"}
class _ForcedFailureError(Exception):
pass
@pytest.fixture
def tool_files(monkeypatch: pytest.MonkeyPatch) -> _FakeToolFileManager:
manager = _FakeToolFileManager()
def delete_object(key: str) -> None:
if key not in manager.stored:
raise FileNotFoundError(key)
del manager.stored[key]
monkeypatch.setattr("services.skill_management_service.storage.delete", delete_object)
monkeypatch.setattr("services.skill_management_service.storage.load_once", manager.stored.__getitem__)
return manager
@pytest.fixture
def service(tool_files: _FakeToolFileManager) -> SkillManagementService:
return SkillManagementService(tool_file_manager=tool_files)
def _before[**P, R](hook: Callable[[], object], call: Callable[P, R]) -> Callable[P, R]:
def wrapped(*args: P.args, **kwargs: P.kwargs) -> R:
hook()
return call(*args, **kwargs)
return wrapped
def _service_error(call: Callable[[], object]) -> SkillManagementServiceError:
with pytest.raises(SkillManagementServiceError) as exc_info:
call()
return exc_info.value
def _new_skill(service: SkillManagementService, name: str = "expense-sop") -> str:
return service.create_skill(tenant_id=TENANT, user_id=USER, payload=SkillCreatePayload(name=name))["id"]
def _package(binaries: Mapping[str, bytes], *, name: str = "expense-sop", body: str = "# Expenses") -> bytes:
return _zip_payload({"SKILL.md": _skill_md(name=name, description="Expenses", body=body), **binaries})
def _import_package(service: SkillManagementService, content: bytes) -> str:
payload = SkillImportPayload(content=content, filename="expense-sop.zip")
return service.import_skill(tenant_id=TENANT, user_id=USER, payload=payload)["id"]
def _overwrite_package(
service: SkillManagementService, skill_id: str, content: bytes, *, expected_updated_at: int | None = None
) -> Mapping[str, object]:
payload = SkillOverwritePayload(content=content, expected_updated_at=expected_updated_at)
return service.overwrite_skill(tenant_id=TENANT, user_id=USER, skill_id=skill_id, payload=payload)
def _tool_file_ids() -> set[str]:
with session_factory.create_session() as session:
return set(session.scalars(select(ToolFile.id)))
def _tool_file_key(tool_file_id: str) -> str:
with session_factory.create_session() as session:
key = session.scalar(select(ToolFile.file_key).where(ToolFile.id == tool_file_id))
assert key is not None
return key
def _draft_paths(skill_id: str) -> list[str]:
with session_factory.create_session() as session:
return sorted(session.scalars(select(SkillDraftFile.path).where(SkillDraftFile.skill_id == skill_id)))
def _draft_tool_file_ids(skill_id: str) -> dict[str, str]:
with session_factory.create_session() as session:
files = session.scalars(select(SkillDraftFile).where(SkillDraftFile.skill_id == skill_id))
return {file.path: file.tool_file_id for file in files if file.tool_file_id is not None}
def _skill_with_logo(service: SkillManagementService) -> tuple[str, str]:
skill_id = _new_skill(service)
_overwrite_package(service, skill_id, _package({"assets/logo.png": _LOGO}))
return skill_id, _draft_tool_file_ids(skill_id)["assets/logo.png"]
def test_export_version_archive_serves_only_the_skills_own_version(
service: SkillManagementService, tool_files: _FakeToolFileManager
) -> None:
skill_id = _import_package(service, _package({}))
other_id = _import_package(service, _package({}, name="other-sop"))
publish = SkillPublishPayload()
version = service.publish_skill(tenant_id=TENANT, user_id=USER, skill_id=skill_id, payload=publish)
other_version = service.publish_skill(tenant_id=TENANT, user_id=USER, skill_id=other_id, payload=publish)
skill_zip, _other_zip = tool_files.stored.values()
archive = service.export_version_archive(tenant_id=TENANT, skill_id=skill_id, version_id=version["id"])
other_skills_version = _service_error(
lambda: service.export_version_archive(tenant_id=TENANT, skill_id=skill_id, version_id=other_version["id"])
)
other_workspace = _service_error(
lambda: service.export_version_archive(tenant_id=OTHER_TENANT, skill_id=skill_id, version_id=version["id"])
)
assert archive.payload == skill_zip
assert (archive.filename, archive.mime_type) == ("expense-sop-v1.zip", "application/zip")
assert other_skills_version.code == SkillServiceErrorCode.SKILL_VERSION_NOT_FOUND
assert other_skills_version.status_code == 404
assert other_workspace.code == SkillServiceErrorCode.SKILL_NOT_FOUND
@pytest.mark.parametrize(
("extra", "paths"),
[
({".DS_Store": "junk", "__MACOSX/._SKILL.md": "junk", "scripts/Thumbs.db": "junk"}, ["SKILL.md"]),
({"x.DS_Store": "kept", ".ds_store": "kept"}, [".ds_store", "SKILL.md", "x.DS_Store"]),
],
ids=["os-junk-dropped", "lookalike-names-kept"],
)
def test_import_drops_os_junk_only(service: SkillManagementService, extra: dict[str, str], paths: list[str]) -> None:
skill_id = _import_package(service, _zip_payload({"SKILL.md": _skill_md(name="expense-sop"), **extra}))
assert [item["path"] for item in service.get_skill(tenant_id=TENANT, skill_id=skill_id)["files"]] == paths
def test_import_refused_for_a_taken_name_or_a_full_workspace_stores_no_files(
service: SkillManagementService, tool_files: _FakeToolFileManager, monkeypatch: pytest.MonkeyPatch
) -> None:
existing_id = _new_skill(service)
package = _package({"assets/logo.png": _LOGO})
at_pre_check = _service_error(lambda: _import_package(service, package))
monkeypatch.setattr(service, "_reject_taken_skill_name", lambda _session, **_kwargs: None)
at_commit = _service_error(lambda: _import_package(service, package))
monkeypatch.setattr("services.skill_management_service._MAX_SKILLS_PER_WORKSPACE", 1)
workspace_full = _service_error(lambda: _import_package(service, _package({"x.png": _LOGO}, name="other-sop")))
for error in (at_pre_check, at_commit):
assert (error.code, error.message) == (SkillServiceErrorCode.SKILL_NAME_CONFLICT, "skill name already exists")
assert at_pre_check.details == {SkillErrorDetail.SKILL_ID: existing_id}
assert at_commit.details == {SkillErrorDetail.NAME: "expense-sop"}
assert workspace_full.code == "skill_limit_exceeded"
assert (_tool_file_ids(), tool_files.stored) == (set(), {})
def test_import_failure_after_upload_deletes_its_files_and_keeps_its_error_if_cleanup_fails(
service: SkillManagementService, tool_files: _FakeToolFileManager, monkeypatch: pytest.MonkeyPatch
) -> None:
def fail(**_kwargs: object) -> None:
assert tool_files.stored, "binaries are stored before the forced failure"
raise _ForcedFailureError
def broken_delete(key: str) -> None:
raise OSError(key)
monkeypatch.setattr(service, "_build_draft_rows_from_tree", fail)
with pytest.raises(_ForcedFailureError):
_import_package(service, _package({"assets/logo.png": _LOGO, "assets/chart.png": _CHART}))
assert (_tool_file_ids(), tool_files.stored) == (set(), {})
with session_factory.create_session() as session:
assert session.scalar(select(func.count()).select_from(Skill)) == 0
monkeypatch.setattr("services.skill_management_service.storage.delete", broken_delete)
with pytest.raises(_ForcedFailureError):
_import_package(service, _package({"assets/logo.png": _LOGO}))
def test_import_uploads_while_no_transaction_is_open(
tool_files: _FakeToolFileManager, monkeypatch: pytest.MonkeyPatch
) -> None:
in_transaction_at_upload: list[bool] = []
with session_factory.create_session() as session:
record = _before(
lambda: in_transaction_at_upload.append(session.in_transaction()), tool_files.create_file_by_raw
)
monkeypatch.setattr(tool_files, "create_file_by_raw", record)
service = SkillManagementService(tool_file_manager=tool_files, session=session)
_import_package(service, _package({"assets/logo.png": _LOGO}))
assert in_transaction_at_upload == [False]
def test_overwrite_replaces_draft_and_syncs_metadata(service: SkillManagementService) -> None:
skill_id = _import_package(service, _package({"old.md": b"old"}))
before = service.update_metadata(
tenant_id=TENANT, user_id=USER, skill_id=skill_id, payload=SkillMetadataPayload(icon="🔥", tags=["finance"])
)
skill_md = "---\nname: expense-sop\ndescription: New description\nmetadata:\n display-name: New Display\n---\n"
result = _overwrite_package(service, skill_id, _zip_payload({"SKILL.md": skill_md, "new.md": "new"}))
assert (result["name"], result["description"]) == ("expense-sop", "New description")
assert (result["display_name"], result["icon"]) == ("New Display", "🔥")
assert result["tags"] == before["tags"]
assert result["tags"]
assert _draft_paths(skill_id) == ["SKILL.md", "new.md"]
def test_overwrite_without_a_display_name_takes_the_one_derived_from_the_name(service: SkillManagementService) -> None:
payload = SkillCreatePayload(name="expense-sop", display_name="Console Title")
created = service.create_skill(tenant_id=TENANT, user_id=USER, payload=payload)
result = _overwrite_package(service, created["id"], _package({}))
assert created["display_name"] == "Console Title"
expected = SkillManagementService._display_name_from_frontmatter(metadata={}, name="expense-sop")
assert result["display_name"] == expected == "Expense Sop"
assert service.get_skill(tenant_id=TENANT, skill_id=created["id"])["display_name"] == expected
def _import_into(service: SkillManagementService, _skill_id: str, content: bytes) -> None:
_import_package(service, content)
_NO_NAME = "---\ndescription: Expenses\n---\n"
_BAD_NAME = "---\nname: Expense SOP\ndescription: Expenses\n---\n"
_NO_DESCRIPTION = "---\nname: expense-sop\n---\n"
@pytest.mark.parametrize(
("send", "skill_md", "code", "details"),
[
(_import_into, _NO_NAME, SkillServiceErrorCode.MISSING_SKILL_NAME, _NAME_DETAILS),
(_import_into, _BAD_NAME, SkillServiceErrorCode.INVALID_SKILL_NAME, _NAME_DETAILS),
(_overwrite_package, _NO_NAME, SkillServiceErrorCode.MISSING_SKILL_NAME, _NAME_DETAILS),
(_overwrite_package, _BAD_NAME, SkillServiceErrorCode.INVALID_SKILL_NAME, _NAME_DETAILS),
(_overwrite_package, _NO_DESCRIPTION, SkillServiceErrorCode.MISSING_SKILL_DESCRIPTION, _DESCRIPTION_DETAILS),
(
_overwrite_package,
_skill_md("other-sop"),
SkillServiceErrorCode.SKILL_NAME_MISMATCH,
{"expected": "expense-sop", "got": "other-sop"},
),
],
ids=["import-no-name", "import-bad-name", "overwrite-no-name", "overwrite-bad-name", "no-description", "mismatch"],
)
def test_a_rejected_package_leaves_the_draft_and_storage_unchanged(
service: SkillManagementService,
tool_files: _FakeToolFileManager,
send: Callable[[SkillManagementService, str, bytes], object],
skill_md: str,
code: str,
details: dict[str, object],
) -> None:
skill_id = _new_skill(service)
paths_before = _draft_paths(skill_id)
error = _service_error(lambda: send(service, skill_id, _zip_payload({"SKILL.md": skill_md, "x.png": _LOGO})))
assert (error.code, error.status_code, error.details) == (code, 400, details)
assert _draft_paths(skill_id) == paths_before
assert service.get_skill(tenant_id=TENANT, skill_id=skill_id)["name"] == "expense-sop"
assert (_tool_file_ids(), tool_files.stored) == (set(), {})
def test_overwrite_refuses_a_stale_draft_hash_an_unknown_skill_and_an_oversized_archive(
service: SkillManagementService, monkeypatch: pytest.MonkeyPatch
) -> None:
skill_id = _new_skill(service)
stale = _service_error(lambda: _overwrite_package(service, skill_id, b"never-parsed", expected_updated_at=1))
unknown = _service_error(lambda: _overwrite_package(service, str(uuid4()), _package({})))
apply_config_overrides(monkeypatch, UPLOAD_SKILL_FILE_SIZE_LIMIT=0)
oversized = _service_error(lambda: _overwrite_package(service, skill_id, b"not-empty"))
assert (stale.code, stale.status_code) == (SkillServiceErrorCode.SKILL_CONFLICT, 409)
assert (unknown.code, unknown.status_code) == (SkillServiceErrorCode.SKILL_NOT_FOUND, 404)
assert oversized.code == SkillServiceErrorCode.ARCHIVE_TOO_LARGE
assert oversized.message == "skill archive is over 0 MiB"
assert _tool_file_ids() == set()
def test_overwrite_and_restore_reuse_a_matching_binary_at_any_path_loading_it_once(
service: SkillManagementService, tool_files: _FakeToolFileManager, monkeypatch: pytest.MonkeyPatch
) -> None:
skill_id, logo_id = _skill_with_logo(service)
version = service.publish_skill(tenant_id=TENANT, user_id=USER, skill_id=skill_id, payload=SkillPublishPayload())
ids_before = _tool_file_ids()
loaded: list[str] = []
def load_once(key: str) -> bytes:
loaded.append(key)
return tool_files.stored[key]
monkeypatch.setattr("services.skill_management_service.storage.load_once", load_once)
_overwrite_package(service, skill_id, _package({"assets/logo.png": _LOGO, "images/logo.png": _LOGO}, body="# New"))
assert _draft_tool_file_ids(skill_id) == {"assets/logo.png": logo_id, "images/logo.png": logo_id}
assert loaded == [_tool_file_key(logo_id)]
assert any(
"# New" in (file["content"] or "") for file in service.get_skill(tenant_id=TENANT, skill_id=skill_id)["files"]
)
restore = SkillRestorePayload(version_id=version["id"])
restored = service.restore_version(tenant_id=TENANT, user_id=USER, skill_id=skill_id, payload=restore)
assert _draft_tool_file_ids(skill_id) == {"assets/logo.png": logo_id}
assert any("# Expenses" in (file["content"] or "") for file in restored["files"])
assert _tool_file_ids() == ids_before
def test_overwrite_stores_a_new_file_for_changed_bytes_or_another_skills_binary(
service: SkillManagementService,
) -> None:
skill_id, logo_id = _skill_with_logo(service)
other_id = _new_skill(service, "other-sop")
_overwrite_package(service, skill_id, _package({"assets/logo.png": _CHART}))
_overwrite_package(service, other_id, _package({"assets/logo.png": _LOGO}, name="other-sop"))
chart_id = _draft_tool_file_ids(skill_id)["assets/logo.png"]
other_logo_id = _draft_tool_file_ids(other_id)["assets/logo.png"]
assert len({logo_id, chart_id, other_logo_id}) == 3
assert _tool_file_ids() == {logo_id, chart_id, other_logo_id}
def test_overwrite_failure_after_upload_keeps_reused_files(
service: SkillManagementService, tool_files: _FakeToolFileManager, monkeypatch: pytest.MonkeyPatch
) -> None:
skill_id, logo_id = _skill_with_logo(service)
stored_before = dict(tool_files.stored)
def fail(_session: Session, **_kwargs: object) -> None:
assert len(tool_files.stored) > len(stored_before), "the new binary is stored before the forced failure"
raise _ForcedFailureError
monkeypatch.setattr(service, "_replace_draft_rows", fail)
with pytest.raises(_ForcedFailureError):
_overwrite_package(service, skill_id, _package({"assets/logo.png": _LOGO, "assets/chart.png": _CHART}))
assert (_tool_file_ids(), tool_files.stored) == ({logo_id}, stored_before)
assert _draft_tool_file_ids(skill_id) == {"assets/logo.png": logo_id}
def _stored_tool_file(manager: _FakeToolFileManager, payload: bytes, *, tenant_id: str = TENANT) -> ToolFile:
return manager.create_file_by_raw(
user_id=USER, tenant_id=tenant_id, conversation_id=None, file_binary=payload, mimetype="image/png"
)
def _unreadable_logo(manager: _FakeToolFileManager) -> str:
tool_file = _stored_tool_file(manager, _LOGO)
del manager.stored[tool_file.file_key]
return tool_file.id
@pytest.mark.parametrize(
"claimed_file",
[
pytest.param(lambda manager: _stored_tool_file(manager, _SAME_SIZE_AS_LOGO).id, id="bytes-differ"),
pytest.param(lambda _manager: str(uuid4()), id="file-missing"),
pytest.param(lambda manager: _stored_tool_file(manager, _LOGO, tenant_id=OTHER_TENANT).id, id="other-tenant"),
pytest.param(_unreadable_logo, id="object-unreadable"),
],
)
def test_overwrite_reuses_only_a_file_whose_stored_bytes_match_the_draft_claim(
service: SkillManagementService,
tool_files: _FakeToolFileManager,
claimed_file: Callable[[_FakeToolFileManager], str],
) -> None:
skill_id, logo_id = _skill_with_logo(service)
claimed_id = claimed_file(tool_files)
# The logo row keeps claiming the logo's hash and size, but now names the claimed file.
with session_factory.create_session() as session, session.begin():
session.execute(
update(SkillDraftFile).where(SkillDraftFile.tool_file_id == logo_id).values(tool_file_id=claimed_id)
)
ids_before, stored_before = _tool_file_ids(), dict(tool_files.stored)
_overwrite_package(service, skill_id, _package({"assets/logo.png": _LOGO}))
new_logo_id = _draft_tool_file_ids(skill_id)["assets/logo.png"]
assert new_logo_id not in {claimed_id, logo_id}
assert tool_files.stored[_tool_file_key(new_logo_id)] == _LOGO
assert ids_before <= _tool_file_ids()
assert stored_before.items() <= tool_files.stored.items()
+100
View File
@@ -1835,6 +1835,106 @@
"method": "POST",
"path": "/openapi/v1/workspaces/{workspace_id}/tool-providers/{provider}/credentials",
"summary": "Save an API-key credential for a tool provider; the provider checks it first"
},
"get.skill": {
"bind": {
"keyword": "query",
"limit": "query",
"page": "query",
"tag": "query",
"workspace_id": "path"
},
"deprecated": false,
"input": {
"type": "object",
"properties": { "keyword": {}, "limit": {}, "page": {}, "tag": {}, "workspace_id": {} },
"required": ["workspace_id"]
},
"internal": false,
"kind": "list",
"method": "GET",
"path": "/openapi/v1/workspaces/{workspace_id}/skills",
"summary": "Skills in the workspace, newest edit first"
},
"describe.skill": {
"bind": { "skill_id": "path", "workspace_id": "path" },
"deprecated": false,
"input": {
"type": "object",
"properties": { "skill_id": {}, "workspace_id": {} },
"required": ["skill_id", "workspace_id"]
},
"internal": false,
"kind": "object",
"method": "GET",
"path": "/openapi/v1/workspaces/{workspace_id}/skills/{skill_id}",
"summary": "One skill with its draft hash and latest published version"
},
"get.skill.version": {
"bind": { "limit": "query", "page": "query", "skill_id": "path", "workspace_id": "path" },
"deprecated": false,
"input": {
"type": "object",
"properties": { "limit": {}, "page": {}, "skill_id": {}, "workspace_id": {} },
"required": ["skill_id", "workspace_id"]
},
"internal": false,
"kind": "list",
"method": "GET",
"path": "/openapi/v1/workspaces/{workspace_id}/skills/{skill_id}/versions",
"summary": "Published versions of a skill, newest first"
},
"export.skill": {
"bind": { "skill_id": "path", "version_id": "query", "workspace_id": "path" },
"deprecated": false,
"input": {
"type": "object",
"properties": { "skill_id": {}, "version_id": {}, "workspace_id": {} },
"required": ["skill_id", "workspace_id"]
},
"internal": false,
"kind": "file",
"method": "GET",
"path": "/openapi/v1/workspaces/{workspace_id}/skills/{skill_id}/archive",
"summary": "Download a skill as a zip: its draft, or one published version"
},
"import.skill": {
"bind": { "draft_hash": "body", "files": "file", "skill_id": "body", "workspace_id": "path" },
"deprecated": false,
"input": {
"type": "object",
"properties": { "draft_hash": {}, "files": {}, "skill_id": {}, "workspace_id": {} },
"required": ["workspace_id", "files"]
},
"internal": false,
"kind": "object",
"method": "POST",
"path": "/openapi/v1/workspaces/{workspace_id}/skills/imports",
"summary": "Create a skill from a folder, or overwrite a skill's draft with one"
},
"publish.skill": {
"bind": {
"publish_note": "body",
"skill_id": "path",
"version_name": "body",
"workspace_id": "path"
},
"deprecated": false,
"input": {
"type": "object",
"properties": {
"publish_note": {},
"skill_id": {},
"version_name": {},
"workspace_id": {}
},
"required": ["skill_id", "workspace_id"]
},
"internal": false,
"kind": "object",
"method": "POST",
"path": "/openapi/v1/workspaces/{workspace_id}/skills/{skill_id}:publish",
"summary": "Publish the draft as a new version"
}
}
}
+157 -10
View File
@@ -128,6 +128,17 @@ import {
zGetWorkspacesByWorkspaceIdPluginTasksByTaskIdPath,
zGetWorkspacesByWorkspaceIdPluginTasksByTaskIdResponse,
zGetWorkspacesByWorkspaceIdResponse,
zGetWorkspacesByWorkspaceIdSkillsBySkillIdArchivePath,
zGetWorkspacesByWorkspaceIdSkillsBySkillIdArchiveQuery,
zGetWorkspacesByWorkspaceIdSkillsBySkillIdArchiveResponse,
zGetWorkspacesByWorkspaceIdSkillsBySkillIdPath,
zGetWorkspacesByWorkspaceIdSkillsBySkillIdResponse,
zGetWorkspacesByWorkspaceIdSkillsBySkillIdVersionsPath,
zGetWorkspacesByWorkspaceIdSkillsBySkillIdVersionsQuery,
zGetWorkspacesByWorkspaceIdSkillsBySkillIdVersionsResponse,
zGetWorkspacesByWorkspaceIdSkillsPath,
zGetWorkspacesByWorkspaceIdSkillsQuery,
zGetWorkspacesByWorkspaceIdSkillsResponse,
zGetWorkspacesByWorkspaceIdToolProvidersByProviderPath,
zGetWorkspacesByWorkspaceIdToolProvidersByProviderResponse,
zGetWorkspacesByWorkspaceIdToolsPath,
@@ -269,6 +280,12 @@ import {
zPostWorkspacesByWorkspaceIdPluginsInstallBody,
zPostWorkspacesByWorkspaceIdPluginsInstallPath,
zPostWorkspacesByWorkspaceIdPluginsInstallResponse,
zPostWorkspacesByWorkspaceIdSkillsBySkillIdPublishBody,
zPostWorkspacesByWorkspaceIdSkillsBySkillIdPublishPath,
zPostWorkspacesByWorkspaceIdSkillsBySkillIdPublishResponse,
zPostWorkspacesByWorkspaceIdSkillsImportsBody,
zPostWorkspacesByWorkspaceIdSkillsImportsPath,
zPostWorkspacesByWorkspaceIdSkillsImportsResponse,
zPostWorkspacesByWorkspaceIdToolProvidersByProviderCredentialsBody,
zPostWorkspacesByWorkspaceIdToolProvidersByProviderCredentialsPath,
zPostWorkspacesByWorkspaceIdToolProvidersByProviderCredentialsResponse,
@@ -2310,6 +2327,135 @@ export const plugins2 = {
install,
}
/**
* Import never publishes
*
* ``draft_hash`` is the draft's ``updated_at`` in whole seconds,
* so an edit made in the same second as the export is not detected.
*/
export const post31 = oc
.route({
description:
"``draft_hash`` is the draft's ``updated_at`` in whole seconds,\nso an edit made in the same second as the export is not detected.",
inputStructure: 'detailed',
method: 'POST',
operationId: 'postWorkspacesByWorkspaceIdSkillsImports',
path: '/workspaces/{workspace_id}/skills/imports',
summary: 'Import never publishes',
tags: ['openapi'],
})
.input(
z.object({
body: zPostWorkspacesByWorkspaceIdSkillsImportsBody,
params: zPostWorkspacesByWorkspaceIdSkillsImportsPath,
}),
)
.output(zPostWorkspacesByWorkspaceIdSkillsImportsResponse)
export const imports2 = {
post: post31,
}
export const get52 = oc
.route({
inputStructure: 'detailed',
method: 'GET',
operationId: 'getWorkspacesByWorkspaceIdSkillsBySkillIdArchive',
path: '/workspaces/{workspace_id}/skills/{skill_id}/archive',
tags: ['openapi'],
})
.input(
z.object({
params: zGetWorkspacesByWorkspaceIdSkillsBySkillIdArchivePath,
query: zGetWorkspacesByWorkspaceIdSkillsBySkillIdArchiveQuery.optional(),
}),
)
.output(zGetWorkspacesByWorkspaceIdSkillsBySkillIdArchiveResponse)
export const archive = {
get: get52,
}
export const get53 = oc
.route({
inputStructure: 'detailed',
method: 'GET',
operationId: 'getWorkspacesByWorkspaceIdSkillsBySkillIdVersions',
path: '/workspaces/{workspace_id}/skills/{skill_id}/versions',
tags: ['openapi'],
})
.input(
z.object({
params: zGetWorkspacesByWorkspaceIdSkillsBySkillIdVersionsPath,
query: zGetWorkspacesByWorkspaceIdSkillsBySkillIdVersionsQuery.optional(),
}),
)
.output(zGetWorkspacesByWorkspaceIdSkillsBySkillIdVersionsResponse)
export const versions2 = {
get: get53,
}
export const post32 = oc
.route({
inputStructure: 'detailed',
method: 'POST',
operationId: 'postWorkspacesByWorkspaceIdSkillsBySkillIdPublish',
path: '/workspaces/{workspace_id}/skills/{skill_id}:publish',
tags: ['openapi'],
})
.input(
z.object({
body: zPostWorkspacesByWorkspaceIdSkillsBySkillIdPublishBody,
params: zPostWorkspacesByWorkspaceIdSkillsBySkillIdPublishPath,
}),
)
.output(zPostWorkspacesByWorkspaceIdSkillsBySkillIdPublishResponse)
export const publish2 = {
post: post32,
}
export const get54 = oc
.route({
inputStructure: 'detailed',
method: 'GET',
operationId: 'getWorkspacesByWorkspaceIdSkillsBySkillId',
path: '/workspaces/{workspace_id}/skills/{skill_id}',
tags: ['openapi'],
})
.input(z.object({ params: zGetWorkspacesByWorkspaceIdSkillsBySkillIdPath }))
.output(zGetWorkspacesByWorkspaceIdSkillsBySkillIdResponse)
export const bySkillId = {
get: get54,
archive,
versions: versions2,
publish: publish2,
}
export const get55 = oc
.route({
inputStructure: 'detailed',
method: 'GET',
operationId: 'getWorkspacesByWorkspaceIdSkills',
path: '/workspaces/{workspace_id}/skills',
tags: ['openapi'],
})
.input(
z.object({
params: zGetWorkspacesByWorkspaceIdSkillsPath,
query: zGetWorkspacesByWorkspaceIdSkillsQuery.optional(),
}),
)
.output(zGetWorkspacesByWorkspaceIdSkillsResponse)
export const skills = {
get: get55,
imports: imports2,
bySkillId,
}
export const patch18 = oc
.route({
inputStructure: 'detailed',
@@ -2330,7 +2476,7 @@ export const byCredentialId3 = {
patch: patch18,
}
export const post31 = oc
export const post33 = oc
.route({
inputStructure: 'detailed',
method: 'POST',
@@ -2348,11 +2494,11 @@ export const post31 = oc
.output(zPostWorkspacesByWorkspaceIdToolProvidersByProviderCredentialsResponse)
export const credentials3 = {
post: post31,
post: post33,
byCredentialId: byCredentialId3,
}
export const get52 = oc
export const get56 = oc
.route({
inputStructure: 'detailed',
method: 'GET',
@@ -2364,7 +2510,7 @@ export const get52 = oc
.output(zGetWorkspacesByWorkspaceIdToolProvidersByProviderResponse)
export const byProvider2 = {
get: get52,
get: get56,
credentials: credentials3,
}
@@ -2372,7 +2518,7 @@ export const toolProviders = {
byProvider: byProvider2,
}
export const get53 = oc
export const get57 = oc
.route({
inputStructure: 'detailed',
method: 'GET',
@@ -2389,10 +2535,10 @@ export const get53 = oc
.output(zGetWorkspacesByWorkspaceIdToolsResponse)
export const tools = {
get: get53,
get: get57,
}
export const get54 = oc
export const get58 = oc
.route({
inputStructure: 'detailed',
method: 'GET',
@@ -2404,7 +2550,7 @@ export const get54 = oc
.output(zGetWorkspacesByWorkspaceIdResponse)
export const byWorkspaceId = {
get: get54,
get: get58,
apps: apps2,
knowledgeBases,
knowledgeFs,
@@ -2414,11 +2560,12 @@ export const byWorkspaceId = {
models: models2,
pluginTasks,
plugins: plugins2,
skills,
toolProviders,
tools,
}
export const get55 = oc
export const get59 = oc
.route({
inputStructure: 'detailed',
method: 'GET',
@@ -2430,7 +2577,7 @@ export const get55 = oc
.output(zGetWorkspacesResponse)
export const workspaces = {
get: get55,
get: get59,
byWorkspaceId,
}
+266
View File
@@ -257,6 +257,8 @@ export type AppSettingsInfo = {
name: string
}
export type BinaryFileResponse = Blob | File
export type ChatAppInfo = {
description?: string | null
icon?: string | null
@@ -1097,6 +1099,13 @@ export type OpenApiErrorCode =
| 'secret_mask_not_secret'
| 'secret_mask_unknown_id'
| 'service_api_disabled'
| 'skill_name_conflict'
| 'skill_name_mismatch'
| 'skill_not_found'
| 'skill_package_invalid'
| 'skill_request_rejected'
| 'skill_version_not_found'
| 'skills_disabled'
| 'too_many_files'
| 'too_many_requests'
| 'trigger_workflow_service_mode_unavailable'
@@ -1351,6 +1360,105 @@ export type SimpleResultResponse = {
result: string
}
export type SkillDescribeResponse = {
description: string
display_name: string
draft_hash: string
hints?: Array<Hint>
icon: string | null
id: string
latest_published_version_id: string | null
latest_version: SkillVersionRow | null
name: string
tags: Array<string>
updated_at: number
}
export type SkillExportQuery = {
version_id?: string | null
}
export type SkillImportPayload = {
draft_hash?: string | null
files: {
[key: string]: Blob | File | Array<Blob | File>
}
skill_id?: string | null
}
export type SkillImportResponse = {
draft_hash: string
file_count: number
hints?: Array<Hint>
id: string
name: string
}
export type SkillListQuery = {
keyword?: string | null
limit?: number
page?: number
tag?: Array<string> | null
}
export type SkillListResponse = {
data: Array<SkillRow>
has_more: boolean
hints?: Array<Hint>
limit: number
page: number
total: number
}
export type SkillPublishPayload = {
publish_note?: string
version_name?: string | null
}
export type SkillPublishResponse = {
created_at: number
created_by: string | null
hints?: Array<Hint>
id: string
publish_note: string
version_name: string | null
version_number: number
}
export type SkillRow = {
description: string
display_name: string
icon: string | null
id: string
latest_published_version_id: string | null
name: string
tags: Array<string>
updated_at: number
}
export type SkillVersionListQuery = {
limit?: number
page?: number
}
export type SkillVersionListResponse = {
data: Array<SkillVersionRow>
has_more: boolean
hints?: Array<Hint>
limit: number
page: number
total: number
}
export type SkillVersionRow = {
created_at: number
created_by: string | null
id: string
publish_note: string
version_name: string | null
version_number: number
}
export type SubjectType = 'account' | 'external_sso' | 'resource_access'
export type SupportedAppType = 'advanced-chat' | 'agent-chat' | 'chat' | 'completion' | 'workflow'
@@ -4414,6 +4522,164 @@ export type PostWorkspacesByWorkspaceIdPluginsInstallResponses = {
export type PostWorkspacesByWorkspaceIdPluginsInstallResponse =
PostWorkspacesByWorkspaceIdPluginsInstallResponses[keyof PostWorkspacesByWorkspaceIdPluginsInstallResponses]
export type GetWorkspacesByWorkspaceIdSkillsData = {
body?: never
path: {
workspace_id: string
}
query?: {
keyword?: string
limit?: number
page?: number
tag?: Array<string>
}
url: '/workspaces/{workspace_id}/skills'
}
export type GetWorkspacesByWorkspaceIdSkillsErrors = {
422: ErrorBody
default: ErrorBody
}
export type GetWorkspacesByWorkspaceIdSkillsError =
GetWorkspacesByWorkspaceIdSkillsErrors[keyof GetWorkspacesByWorkspaceIdSkillsErrors]
export type GetWorkspacesByWorkspaceIdSkillsResponses = {
200: SkillListResponse
}
export type GetWorkspacesByWorkspaceIdSkillsResponse =
GetWorkspacesByWorkspaceIdSkillsResponses[keyof GetWorkspacesByWorkspaceIdSkillsResponses]
export type PostWorkspacesByWorkspaceIdSkillsImportsData = {
body: SkillImportPayload
path: {
workspace_id: string
}
query?: never
url: '/workspaces/{workspace_id}/skills/imports'
}
export type PostWorkspacesByWorkspaceIdSkillsImportsErrors = {
422: ErrorBody
default: ErrorBody
}
export type PostWorkspacesByWorkspaceIdSkillsImportsError =
PostWorkspacesByWorkspaceIdSkillsImportsErrors[keyof PostWorkspacesByWorkspaceIdSkillsImportsErrors]
export type PostWorkspacesByWorkspaceIdSkillsImportsResponses = {
200: SkillImportResponse
201: SkillImportResponse
}
export type PostWorkspacesByWorkspaceIdSkillsImportsResponse =
PostWorkspacesByWorkspaceIdSkillsImportsResponses[keyof PostWorkspacesByWorkspaceIdSkillsImportsResponses]
export type GetWorkspacesByWorkspaceIdSkillsBySkillIdData = {
body?: never
path: {
skill_id: string
workspace_id: string
}
query?: never
url: '/workspaces/{workspace_id}/skills/{skill_id}'
}
export type GetWorkspacesByWorkspaceIdSkillsBySkillIdErrors = {
default: ErrorBody
}
export type GetWorkspacesByWorkspaceIdSkillsBySkillIdError =
GetWorkspacesByWorkspaceIdSkillsBySkillIdErrors[keyof GetWorkspacesByWorkspaceIdSkillsBySkillIdErrors]
export type GetWorkspacesByWorkspaceIdSkillsBySkillIdResponses = {
200: SkillDescribeResponse
}
export type GetWorkspacesByWorkspaceIdSkillsBySkillIdResponse =
GetWorkspacesByWorkspaceIdSkillsBySkillIdResponses[keyof GetWorkspacesByWorkspaceIdSkillsBySkillIdResponses]
export type GetWorkspacesByWorkspaceIdSkillsBySkillIdArchiveData = {
body?: never
path: {
skill_id: string
workspace_id: string
}
query?: {
version_id?: string
}
url: '/workspaces/{workspace_id}/skills/{skill_id}/archive'
}
export type GetWorkspacesByWorkspaceIdSkillsBySkillIdArchiveErrors = {
422: ErrorBody
default: ErrorBody
}
export type GetWorkspacesByWorkspaceIdSkillsBySkillIdArchiveError =
GetWorkspacesByWorkspaceIdSkillsBySkillIdArchiveErrors[keyof GetWorkspacesByWorkspaceIdSkillsBySkillIdArchiveErrors]
export type GetWorkspacesByWorkspaceIdSkillsBySkillIdArchiveResponses = {
200: BinaryFileResponse
}
export type GetWorkspacesByWorkspaceIdSkillsBySkillIdArchiveResponse =
GetWorkspacesByWorkspaceIdSkillsBySkillIdArchiveResponses[keyof GetWorkspacesByWorkspaceIdSkillsBySkillIdArchiveResponses]
export type GetWorkspacesByWorkspaceIdSkillsBySkillIdVersionsData = {
body?: never
path: {
skill_id: string
workspace_id: string
}
query?: {
limit?: number
page?: number
}
url: '/workspaces/{workspace_id}/skills/{skill_id}/versions'
}
export type GetWorkspacesByWorkspaceIdSkillsBySkillIdVersionsErrors = {
422: ErrorBody
default: ErrorBody
}
export type GetWorkspacesByWorkspaceIdSkillsBySkillIdVersionsError =
GetWorkspacesByWorkspaceIdSkillsBySkillIdVersionsErrors[keyof GetWorkspacesByWorkspaceIdSkillsBySkillIdVersionsErrors]
export type GetWorkspacesByWorkspaceIdSkillsBySkillIdVersionsResponses = {
200: SkillVersionListResponse
}
export type GetWorkspacesByWorkspaceIdSkillsBySkillIdVersionsResponse =
GetWorkspacesByWorkspaceIdSkillsBySkillIdVersionsResponses[keyof GetWorkspacesByWorkspaceIdSkillsBySkillIdVersionsResponses]
export type PostWorkspacesByWorkspaceIdSkillsBySkillIdPublishData = {
body: SkillPublishPayload
path: {
skill_id: string
workspace_id: string
}
query?: never
url: '/workspaces/{workspace_id}/skills/{skill_id}:publish'
}
export type PostWorkspacesByWorkspaceIdSkillsBySkillIdPublishErrors = {
422: ErrorBody
default: ErrorBody
}
export type PostWorkspacesByWorkspaceIdSkillsBySkillIdPublishError =
PostWorkspacesByWorkspaceIdSkillsBySkillIdPublishErrors[keyof PostWorkspacesByWorkspaceIdSkillsBySkillIdPublishErrors]
export type PostWorkspacesByWorkspaceIdSkillsBySkillIdPublishResponses = {
200: SkillPublishResponse
}
export type PostWorkspacesByWorkspaceIdSkillsBySkillIdPublishResponse =
PostWorkspacesByWorkspaceIdSkillsBySkillIdPublishResponses[keyof PostWorkspacesByWorkspaceIdSkillsBySkillIdPublishResponses]
export type GetWorkspacesByWorkspaceIdToolProvidersByProviderData = {
body?: never
path: {
+234
View File
@@ -285,6 +285,13 @@ export const zAppSettingsInfo = z.object({
name: z.string(),
})
/**
* BinaryFileResponse
*/
export const zBinaryFileResponse = z.custom<Blob | File>(
(value) => value instanceof Blob || value instanceof File,
)
/**
* ChatAppInfo
*/
@@ -1467,6 +1474,13 @@ export const zOpenApiErrorCode = z.enum([
'secret_mask_not_secret',
'secret_mask_unknown_id',
'service_api_disabled',
'skill_name_conflict',
'skill_name_mismatch',
'skill_not_found',
'skill_package_invalid',
'skill_request_rejected',
'skill_version_not_found',
'skills_disabled',
'too_many_files',
'too_many_requests',
'trigger_workflow_service_mode_unavailable',
@@ -1842,6 +1856,148 @@ export const zSimpleResultResponse = z.object({
result: z.string(),
})
/**
* SkillExportQuery
*/
export const zSkillExportQuery = z.object({
version_id: z.uuid().nullish(),
})
/**
* SkillImportPayload
*/
export const zSkillImportPayload = z.object({
draft_hash: z
.string()
.regex(/^[0-9]+$/)
.nullish(),
files: z.record(
z.string(),
z.union([
z.custom<Blob | File>((value) => value instanceof Blob || value instanceof File),
z.array(z.custom<Blob | File>((value) => value instanceof Blob || value instanceof File)),
]),
),
skill_id: z.uuid().nullish(),
})
/**
* SkillImportResponse
*/
export const zSkillImportResponse = z.object({
draft_hash: z.string(),
file_count: z.int(),
hints: z.array(zHint).optional(),
id: z.string(),
name: z.string(),
})
/**
* SkillListQuery
*/
export const zSkillListQuery = z.object({
keyword: z.string().max(200).nullish(),
limit: z.int().gte(1).lte(100).optional().default(20),
page: z.int().gte(1).optional().default(1),
tag: z.array(z.string()).nullish(),
})
/**
* SkillPublishPayload
*/
export const zSkillPublishPayload = z.object({
publish_note: z.string().max(1024).optional().default(''),
version_name: z.string().max(128).nullish(),
})
/**
* SkillPublishResponse
*/
export const zSkillPublishResponse = z.object({
created_at: z.int(),
created_by: z.string().nullable(),
hints: z.array(zHint).optional(),
id: z.string(),
publish_note: z.string(),
version_name: z.string().nullable(),
version_number: z.int(),
})
/**
* SkillRow
*/
export const zSkillRow = z.object({
description: z.string(),
display_name: z.string(),
icon: z.string().nullable(),
id: z.string(),
latest_published_version_id: z.string().nullable(),
name: z.string(),
tags: z.array(z.string()),
updated_at: z.int(),
})
/**
* SkillListResponse
*/
export const zSkillListResponse = z.object({
data: z.array(zSkillRow),
has_more: z.boolean(),
hints: z.array(zHint).optional(),
limit: z.int(),
page: z.int(),
total: z.int(),
})
/**
* SkillVersionListQuery
*/
export const zSkillVersionListQuery = z.object({
limit: z.int().gte(1).lte(100).optional().default(20),
page: z.int().gte(1).optional().default(1),
})
/**
* SkillVersionRow
*/
export const zSkillVersionRow = z.object({
created_at: z.int(),
created_by: z.string().nullable(),
id: z.string(),
publish_note: z.string(),
version_name: z.string().nullable(),
version_number: z.int(),
})
/**
* SkillDescribeResponse
*/
export const zSkillDescribeResponse = z.object({
description: z.string(),
display_name: z.string(),
draft_hash: z.string(),
hints: z.array(zHint).optional(),
icon: z.string().nullable(),
id: z.string(),
latest_published_version_id: z.string().nullable(),
latest_version: zSkillVersionRow.nullable(),
name: z.string(),
tags: z.array(z.string()),
updated_at: z.int(),
})
/**
* SkillVersionListResponse
*/
export const zSkillVersionListResponse = z.object({
data: z.array(zSkillVersionRow),
has_more: z.boolean(),
hints: z.array(zHint).optional(),
limit: z.int(),
page: z.int(),
total: z.int(),
})
/**
* SubjectType
*/
@@ -3669,6 +3825,84 @@ export const zPostWorkspacesByWorkspaceIdPluginsInstallPath = z.object({
*/
export const zPostWorkspacesByWorkspaceIdPluginsInstallResponse = zPluginTaskStartResponse
export const zGetWorkspacesByWorkspaceIdSkillsPath = z.object({
workspace_id: z.string(),
})
export const zGetWorkspacesByWorkspaceIdSkillsQuery = z.object({
keyword: z.string().max(200).optional(),
limit: z.int().gte(1).lte(100).optional().default(20),
page: z.int().gte(1).optional().default(1),
tag: z.array(z.string()).optional(),
})
/**
* Skills
*/
export const zGetWorkspacesByWorkspaceIdSkillsResponse = zSkillListResponse
export const zPostWorkspacesByWorkspaceIdSkillsImportsBody = zSkillImportPayload
export const zPostWorkspacesByWorkspaceIdSkillsImportsPath = z.object({
workspace_id: z.string(),
})
/**
* Overwritten
*/
export const zPostWorkspacesByWorkspaceIdSkillsImportsResponse = zSkillImportResponse
export const zGetWorkspacesByWorkspaceIdSkillsBySkillIdPath = z.object({
skill_id: z.string(),
workspace_id: z.string(),
})
/**
* Skill
*/
export const zGetWorkspacesByWorkspaceIdSkillsBySkillIdResponse = zSkillDescribeResponse
export const zGetWorkspacesByWorkspaceIdSkillsBySkillIdArchivePath = z.object({
skill_id: z.string(),
workspace_id: z.string(),
})
export const zGetWorkspacesByWorkspaceIdSkillsBySkillIdArchiveQuery = z.object({
version_id: z.uuid().optional(),
})
/**
* Zip archive
*/
export const zGetWorkspacesByWorkspaceIdSkillsBySkillIdArchiveResponse = zBinaryFileResponse
export const zGetWorkspacesByWorkspaceIdSkillsBySkillIdVersionsPath = z.object({
skill_id: z.string(),
workspace_id: z.string(),
})
export const zGetWorkspacesByWorkspaceIdSkillsBySkillIdVersionsQuery = z.object({
limit: z.int().gte(1).lte(100).optional().default(20),
page: z.int().gte(1).optional().default(1),
})
/**
* Versions
*/
export const zGetWorkspacesByWorkspaceIdSkillsBySkillIdVersionsResponse = zSkillVersionListResponse
export const zPostWorkspacesByWorkspaceIdSkillsBySkillIdPublishBody = zSkillPublishPayload
export const zPostWorkspacesByWorkspaceIdSkillsBySkillIdPublishPath = z.object({
skill_id: z.string(),
workspace_id: z.string(),
})
/**
* Published
*/
export const zPostWorkspacesByWorkspaceIdSkillsBySkillIdPublishResponse = zSkillPublishResponse
export const zGetWorkspacesByWorkspaceIdToolProvidersByProviderPath = z.object({
provider: z.string(),
workspace_id: z.string(),
@@ -16,6 +16,7 @@ This inventory records non-JSON runtime responses and their schema intent. Legac
| console | POST | `/datasets/{dataset_id}/documents/download-zip` | `application/zip` attachment | `BinaryFileResponse` |
| service | POST | `/datasets/{dataset_id}/documents/download-zip` | `application/zip` attachment | `BinaryFileResponse` |
| service | GET | `/files/{file_id}/preview` | Original file MIME type, optionally attachment | `BinaryFileResponse` |
| openapi | GET | `/workspaces/{workspace_id}/skills/{skill_id}/archive` | `application/zip` attachment | `BinaryFileResponse` |
| console | GET | `/workspaces/current/plugin/icon` | Plugin asset MIME type | `string (binary)` |
| console | GET | `/workspaces/current/plugin/asset` | `application/octet-stream` | `string (binary)` |
| console | GET | `/workspaces/current/skills/{skill_id}/files/content` | Skill file MIME type, optionally attachment | `string (binary)` |
+1
View File
@@ -32,3 +32,4 @@ Not logged in, or the server unreachable, gives the local commands plus one line
- Read [`references/operations.md`](references/operations.md) before running any operation: flags, inputs, files, results, hints, errors and exit codes.
- Read [`references/build-workflow.md`](references/build-workflow.md) when asked to create, change, test or publish a Workflow or Chatflow app.
- Read [`references/plugins.md`](references/plugins.md) to find tools, models and knowledge bases before building, when a plugin, model, tool or credential is missing, or when the human asks to install or remove a plugin.
- Read [`references/workspace-skills.md`](references/workspace-skills.md) to list, read, create, change, roll back or publish a workspace skill.
@@ -0,0 +1,50 @@
# Workspace skills
A workspace skill is a folder with a `SKILL.md` at its root, plus any files it uses. Skills have a draft and published versions. Import writes the draft. Agents use only what is published.
## Find a skill
1. `difyctl get skill` lists the skills in the workspace.
2. `difyctl describe skill --skill-id <skill_id>` shows one skill. Keep its `draft_hash`. `latest_version` is null while the skill has never been published.
3. `difyctl get skill version --skill-id <skill_id>` lists the published versions.
## Create a skill
1. Put the skill in a folder with `SKILL.md` at its root. `name` and `description` in its frontmatter are required.
2. `difyctl import skill --files <dir>`
3. `difyctl publish skill --skill-id <skill_id>`. Import never publishes. Use the `skill_id` from the import result.
A name already taken gives 409 `skill_name_conflict`. Its hint is an import with `--skill-id` set to the skill that has the name. Use it only if the human wants to replace that skill. If another import took the name a moment earlier, the server does not know that skill's id, so the hint is `difyctl get skill --keyword <name>` instead. Take the id from that list.
## Change a skill
1. `difyctl describe skill --skill-id <skill_id>` and keep `draft_hash`.
2. `difyctl export skill --skill-id <skill_id> --output <name>.zip` saves the draft as a zip.
3. Unzip into a new, empty folder: `unzip -q <name>.zip -d <dir>`, or `python3 -m zipfile -e <name>.zip <dir>`, or on Windows `tar -xf <name>.zip -C <dir>`.
4. Edit the files in the folder.
5. `difyctl import skill --files <dir> --skill-id <skill_id> --draft-hash <draft_hash>`
6. `difyctl publish skill --skill-id <skill_id>`
Leaving out `--draft-hash` on an overwrite skips the conflict check, so the import replaces whatever the draft holds now.
The folder replaces the whole draft. A file you leave out of the folder is deleted from the draft.
If step 5 gives 409, someone changed or published the skill since you took the hash. Run steps 1 to 4 again and redo your edits on the new export.
## Read what is live
Export the published version, not the draft: `difyctl export skill --skill-id <skill_id> --version-id <version_id>`. Take `<version_id>` from `latest_version.id` in the `describe skill` result.
## Roll back
1. Pick the version with `difyctl get skill version --skill-id <skill_id>`.
2. Export it with `--version-id`, unzip it, and import the folder with `--skill-id` and the current `--draft-hash`.
3. Publish.
## Rules
- `name` in `SKILL.md` must equal the name of the skill you overwrite. A different name gives 400.
- Import takes a folder, never a zip.
- `.DS_Store`, `Thumbs.db`, `desktop.ini` and `__MACOSX` entries are dropped.
- `draft_hash` has one-second resolution. Two edits in the same second are not told apart.
- A folder over the server's file count or size limit gives 413 and the message names the limit. Don't assume the numbers; trim the folder to fit what the message says.