mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-13 08:52:02 +00:00
feat(skills): let authors edit and withdraw pending submissions (#51312)
This commit is contained in:
parent
5a511b6fcc
commit
d076cee903
19 changed files with 1468 additions and 147 deletions
|
|
@ -11,7 +11,7 @@ from litellm.models.plugin_upload import (
|
|||
)
|
||||
from litellm.types.proxy.skill_scanning import SkillScan
|
||||
|
||||
SubmissionStatus: TypeAlias = Literal["pending_review", "approved", "rejected"]
|
||||
SubmissionStatus: TypeAlias = Literal["pending_review", "approved", "rejected", "withdrawn"]
|
||||
SubmissionOperation: TypeAlias = Literal["create", "update"]
|
||||
|
||||
|
||||
|
|
@ -38,6 +38,7 @@ class SkillSubmission:
|
|||
reviewed_by: str | None
|
||||
reviewed_at: datetime | None
|
||||
rejection_reason: str | None
|
||||
revision: int = 0
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
|
|
@ -99,6 +100,14 @@ class SkillSubmissionStore(Protocol):
|
|||
|
||||
async def get_archive(self, submission_id: str, submitted_by: str | None) -> bytes | None: ...
|
||||
|
||||
async def edit(
|
||||
self, submission_id: str, owner: str, revision: int, upload: PluginPublication
|
||||
) -> SkillSubmission | SubmissionMissing | SubmissionConflict | SubmissionLimitExceeded: ...
|
||||
|
||||
async def withdraw(
|
||||
self, submission_id: str, owner: str, revision: int
|
||||
) -> SkillSubmission | SubmissionMissing | SubmissionConflict: ...
|
||||
|
||||
async def review(
|
||||
self,
|
||||
submission_id: str,
|
||||
|
|
@ -107,4 +116,5 @@ class SkillSubmissionStore(Protocol):
|
|||
reason: str | None,
|
||||
*,
|
||||
publication_base_url: str | None = None,
|
||||
expected_revision: int | None = None,
|
||||
) -> SubmissionReviewResult: ...
|
||||
|
|
|
|||
|
|
@ -59670,6 +59670,38 @@
|
|||
"skill_hub": {
|
||||
"components": {
|
||||
"schemas": {
|
||||
"Body_edit_submission_archive_skill_hub_submissions__submission_id__upload_put": {
|
||||
"properties": {
|
||||
"expected_revision": {
|
||||
"minimum": 0.0,
|
||||
"title": "Expected Revision",
|
||||
"type": "integer"
|
||||
},
|
||||
"file": {
|
||||
"contentMediaType": "application/octet-stream",
|
||||
"description": "Replacement Agent Skills ZIP with the same declared skill name",
|
||||
"title": "File",
|
||||
"type": "string"
|
||||
},
|
||||
"version": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Version"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"file",
|
||||
"expected_revision"
|
||||
],
|
||||
"title": "Body_edit_submission_archive_skill_hub_submissions__submission_id__upload_put",
|
||||
"type": "object"
|
||||
},
|
||||
"Body_replace_skill_archive_skill_hub_skills__name__upload_put": {
|
||||
"properties": {
|
||||
"expected_updated_at": {
|
||||
|
|
@ -60227,6 +60259,18 @@
|
|||
"SkillHubRejection": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"expected_revision": {
|
||||
"anyOf": [
|
||||
{
|
||||
"minimum": 0.0,
|
||||
"type": "integer"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Expected Revision"
|
||||
},
|
||||
"reason": {
|
||||
"anyOf": [
|
||||
{
|
||||
|
|
@ -60536,6 +60580,12 @@
|
|||
],
|
||||
"title": "Base Updated At"
|
||||
},
|
||||
"can_edit": {
|
||||
"default": false,
|
||||
"description": "Whether the caller may edit or withdraw this pending submission",
|
||||
"title": "Can Edit",
|
||||
"type": "boolean"
|
||||
},
|
||||
"created_at": {
|
||||
"format": "date-time",
|
||||
"title": "Created At",
|
||||
|
|
@ -60594,6 +60644,13 @@
|
|||
],
|
||||
"title": "Reviewed By"
|
||||
},
|
||||
"revision": {
|
||||
"default": 0,
|
||||
"description": "Proposal revision used to prevent stale edits and reviews",
|
||||
"minimum": 0.0,
|
||||
"title": "Revision",
|
||||
"type": "integer"
|
||||
},
|
||||
"skill_name": {
|
||||
"title": "Skill Name",
|
||||
"type": "string"
|
||||
|
|
@ -60602,7 +60659,8 @@
|
|||
"enum": [
|
||||
"pending_review",
|
||||
"approved",
|
||||
"rejected"
|
||||
"rejected",
|
||||
"withdrawn"
|
||||
],
|
||||
"title": "Status",
|
||||
"type": "string"
|
||||
|
|
@ -60653,6 +60711,95 @@
|
|||
"title": "SkillHubSubmissionDetail",
|
||||
"type": "object"
|
||||
},
|
||||
"SkillHubSubmissionEdit": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"description": {
|
||||
"anyOf": [
|
||||
{
|
||||
"maxLength": 1024,
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Description"
|
||||
},
|
||||
"domain": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Domain"
|
||||
},
|
||||
"expected_revision": {
|
||||
"description": "Revision read from the submission detail",
|
||||
"minimum": 0.0,
|
||||
"title": "Expected Revision",
|
||||
"type": "integer"
|
||||
},
|
||||
"expected_updated_at": {
|
||||
"anyOf": [
|
||||
{
|
||||
"format": "date-time",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Published updated_at read by the editor. A stale value returns 409",
|
||||
"title": "Expected Updated At"
|
||||
},
|
||||
"keywords": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Keywords"
|
||||
},
|
||||
"namespace": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Namespace"
|
||||
},
|
||||
"version": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Explicit higher SemVer, or omit/null for an automatic patch bump",
|
||||
"title": "Version"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"expected_revision"
|
||||
],
|
||||
"title": "SkillHubSubmissionEdit",
|
||||
"type": "object"
|
||||
},
|
||||
"SkillHubSubmissionList": {
|
||||
"properties": {
|
||||
"count": {
|
||||
|
|
@ -60679,6 +60826,22 @@
|
|||
"title": "SkillHubSubmissionList",
|
||||
"type": "object"
|
||||
},
|
||||
"SkillHubSubmissionRevision": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"expected_revision": {
|
||||
"description": "Revision read from the submission detail",
|
||||
"minimum": 0.0,
|
||||
"title": "Expected Revision",
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"expected_revision"
|
||||
],
|
||||
"title": "SkillHubSubmissionRevision",
|
||||
"type": "object"
|
||||
},
|
||||
"SkillHubVersion": {
|
||||
"properties": {
|
||||
"content_kind": {
|
||||
|
|
@ -62118,7 +62281,8 @@
|
|||
"enum": [
|
||||
"pending_review",
|
||||
"approved",
|
||||
"rejected"
|
||||
"rejected",
|
||||
"withdrawn"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
|
|
@ -62188,7 +62352,7 @@
|
|||
},
|
||||
"/skill-hub/submissions/{submission_id}": {
|
||||
"get": {
|
||||
"description": "Read an immutable proposal and its currently visible published skill.",
|
||||
"description": "Read a proposal revision and its currently visible published skill.",
|
||||
"operationId": "get_submission_skill_hub_submissions__submission_id__get",
|
||||
"parameters": [
|
||||
{
|
||||
|
|
@ -62232,6 +62396,62 @@
|
|||
"tags": [
|
||||
"skill_hub"
|
||||
]
|
||||
},
|
||||
"put": {
|
||||
"description": "Replace a pending proposal's metadata, preserving files. Only its original submitting user may edit.",
|
||||
"operationId": "edit_submission_skill_hub_submissions__submission_id__put",
|
||||
"parameters": [
|
||||
{
|
||||
"in": "path",
|
||||
"name": "submission_id",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"title": "Submission Id",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/SkillHubSubmissionEdit"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/SkillHubSubmission"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Successful Response"
|
||||
},
|
||||
"422": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/HTTPValidationError"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Validation Error"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"APIKeyHeader": []
|
||||
}
|
||||
],
|
||||
"summary": "Edit Submission",
|
||||
"tags": [
|
||||
"skill_hub"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/skill-hub/submissions/{submission_id}/approve": {
|
||||
|
|
@ -62249,6 +62469,23 @@
|
|||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/SkillHubSubmissionRevision"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Body"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
|
|
@ -62506,6 +62743,122 @@
|
|||
"skill_hub"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/skill-hub/submissions/{submission_id}/upload": {
|
||||
"put": {
|
||||
"description": "Replace a pending proposal's files using the upload limits. Only its original submitting user may edit.",
|
||||
"operationId": "edit_submission_archive_skill_hub_submissions__submission_id__upload_put",
|
||||
"parameters": [
|
||||
{
|
||||
"in": "path",
|
||||
"name": "submission_id",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"title": "Submission Id",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"multipart/form-data": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Body_edit_submission_archive_skill_hub_submissions__submission_id__upload_put"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/SkillHubSubmission"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Successful Response"
|
||||
},
|
||||
"422": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/HTTPValidationError"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Validation Error"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"APIKeyHeader": []
|
||||
}
|
||||
],
|
||||
"summary": "Edit Submission Archive",
|
||||
"tags": [
|
||||
"skill_hub"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/skill-hub/submissions/{submission_id}/withdraw": {
|
||||
"put": {
|
||||
"description": "Withdraw a pending proposal without publishing it. Only its original submitting user may withdraw.",
|
||||
"operationId": "withdraw_submission_skill_hub_submissions__submission_id__withdraw_put",
|
||||
"parameters": [
|
||||
{
|
||||
"in": "path",
|
||||
"name": "submission_id",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"title": "Submission Id",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/SkillHubSubmissionRevision"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/SkillHubSubmission"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Successful Response"
|
||||
},
|
||||
"422": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/HTTPValidationError"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Validation Error"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"APIKeyHeader": []
|
||||
}
|
||||
],
|
||||
"summary": "Withdraw Submission",
|
||||
"tags": [
|
||||
"skill_hub"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -470,6 +470,8 @@ class LiteLLMRoutes(enum.Enum):
|
|||
"/skill-hub/submissions",
|
||||
"/skill-hub/submissions/{submission_id}",
|
||||
"/skill-hub/submissions/{submission_id}/archive",
|
||||
"/skill-hub/submissions/{submission_id}/upload",
|
||||
"/skill-hub/submissions/{submission_id}/withdraw",
|
||||
"/skill-hub/submissions/{submission_id}/approve",
|
||||
"/skill-hub/submissions/{submission_id}/reject",
|
||||
"/skill-hub/submissions/{submission_id}/scan",
|
||||
|
|
|
|||
|
|
@ -58,6 +58,15 @@ def submission_owner(caller: UserAPIKeyAuth) -> str | None | SkillMutationError:
|
|||
return caller.user_id
|
||||
|
||||
|
||||
def submission_editor(caller: UserAPIKeyAuth) -> str | SkillMutationError:
|
||||
error: Final = writer_error(caller)
|
||||
if error is not None:
|
||||
return error
|
||||
if not caller.user_id or caller.user_id == DB_UNAVAILABLE_FALLBACK_USER_ID:
|
||||
return SkillMutationError(403, "Editing submissions requires the original submitting user's identity.")
|
||||
return caller.user_id
|
||||
|
||||
|
||||
async def archive_writer(caller: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)]) -> UserAPIKeyAuth:
|
||||
from litellm.proxy.proxy_server import proxy_logging_obj
|
||||
|
||||
|
|
@ -149,13 +158,19 @@ async def review_skill(
|
|||
approve: bool,
|
||||
reason: str | None,
|
||||
publication_base_url: str | None = None,
|
||||
expected_revision: int | None = None,
|
||||
) -> SubmissionReviewed | SkillMutationError:
|
||||
if not is_proxy_admin(caller):
|
||||
return SkillMutationError(403, "Only proxy admins may review skill submissions.")
|
||||
if submissions is None:
|
||||
return SkillMutationError(503, "Skills Hub requires a connected database")
|
||||
result: Final = await submissions.review(
|
||||
submission_id, caller.user_id or "proxy-admin", approve, reason, publication_base_url=publication_base_url
|
||||
submission_id,
|
||||
caller.user_id or "proxy-admin",
|
||||
approve,
|
||||
reason,
|
||||
publication_base_url=publication_base_url,
|
||||
expected_revision=expected_revision,
|
||||
)
|
||||
match result:
|
||||
case SubmissionMissing():
|
||||
|
|
|
|||
|
|
@ -1,27 +1,49 @@
|
|||
import asyncio
|
||||
from dataclasses import replace
|
||||
from typing import Annotated, Final
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
|
||||
from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, Request, Response, UploadFile
|
||||
|
||||
from litellm.models.plugin_upload import PluginPublication
|
||||
from litellm.models.skill_submission import SkillSubmission, SkillSubmissionStore, SubmissionStatus
|
||||
from litellm.models.plugin_upload import PluginPublication, PluginPublicationError, PluginUploadStore
|
||||
from litellm.models.skill_submission import (
|
||||
SkillSubmission,
|
||||
SkillSubmissionStore,
|
||||
SubmissionConflict,
|
||||
SubmissionLimitExceeded,
|
||||
SubmissionMissing,
|
||||
SubmissionStatus,
|
||||
)
|
||||
from litellm.proxy._types import UserAPIKeyAuth
|
||||
from litellm.proxy.anthropic_endpoints.claude_code_endpoints.claude_code_skill_access import resolve_skill_visibility
|
||||
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
|
||||
from litellm.proxy.deps.repositories import PluginUploadRepositoryDep, SkillSubmissionRepositoryDep
|
||||
from litellm.proxy.discovery_endpoints.agent_skills_archive import build_agent_skill_archive
|
||||
from litellm.proxy.discovery_endpoints.skill_hub_endpoints import (
|
||||
prepare_skill_publication,
|
||||
skill_metadata_response,
|
||||
skill_proposal_response,
|
||||
skill_publication_response,
|
||||
)
|
||||
from litellm.proxy.discovery_endpoints.skill_hub_mutations import SkillMutationError, review_skill, submission_owner
|
||||
from litellm.proxy.discovery_endpoints.skill_hub_mutations import (
|
||||
SkillMutationError,
|
||||
archive_writer,
|
||||
publication_error,
|
||||
review_skill,
|
||||
submission_editor,
|
||||
submission_owner,
|
||||
write_target,
|
||||
)
|
||||
from litellm.proxy.discovery_endpoints.skill_import import MAX_IMPORT_BYTES
|
||||
from litellm.repositories.plugin_publication import resolve_plugin_version
|
||||
from litellm.types.proxy.skill_hub import (
|
||||
SkillHubMetadata,
|
||||
SkillHubRejection,
|
||||
SkillHubReviewResponse,
|
||||
SkillHubSubmission,
|
||||
SkillHubSubmissionDetail,
|
||||
SkillHubSubmissionEdit,
|
||||
SkillHubSubmissionList,
|
||||
SkillHubSubmissionRevision,
|
||||
)
|
||||
|
||||
router: Final = APIRouter(prefix="/skill-hub/submissions", tags=["Skills Hub"])
|
||||
|
|
@ -40,7 +62,7 @@ def _owner(caller: UserAPIKeyAuth) -> str | None:
|
|||
return owner
|
||||
|
||||
|
||||
def _response(request: Request, submission: SkillSubmission) -> SkillHubSubmission:
|
||||
def _response(request: Request, submission: SkillSubmission, caller: UserAPIKeyAuth) -> SkillHubSubmission:
|
||||
return SkillHubSubmission(
|
||||
id=submission.id,
|
||||
skill_name=submission.plugin_name,
|
||||
|
|
@ -55,6 +77,8 @@ def _response(request: Request, submission: SkillSubmission) -> SkillHubSubmissi
|
|||
reviewed_by=submission.reviewed_by,
|
||||
reviewed_at=submission.reviewed_at,
|
||||
rejection_reason=submission.rejection_reason,
|
||||
revision=submission.revision,
|
||||
can_edit=submission.status == "pending_review" and submission_editor(caller) == submission.submitted_by,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -71,7 +95,7 @@ async def list_submissions(
|
|||
owner: Final = _owner(caller)
|
||||
page: Final = await _store(submissions).list(owner, status, limit, offset)
|
||||
return SkillHubSubmissionList(
|
||||
submissions=tuple(_response(request, row) for row in page.submissions),
|
||||
submissions=tuple(_response(request, row, caller) for row in page.submissions),
|
||||
count=len(page.submissions),
|
||||
has_more=page.has_more,
|
||||
)
|
||||
|
|
@ -85,7 +109,7 @@ async def get_submission(
|
|||
uploads: PluginUploadRepositoryDep,
|
||||
caller: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)],
|
||||
) -> SkillHubSubmissionDetail:
|
||||
"""Read an immutable proposal and its currently visible published skill."""
|
||||
"""Read a proposal revision and its currently visible published skill."""
|
||||
owner: Final = _owner(caller)
|
||||
submission: Final = await _store(submissions).get(submission_id, owner)
|
||||
if submission is None:
|
||||
|
|
@ -95,7 +119,7 @@ async def get_submission(
|
|||
current: Final = await uploads.get_metadata(submission.plugin_name)
|
||||
visible: Final = current is not None and (owner is None or (await resolve_skill_visibility(caller)).allows(current))
|
||||
return SkillHubSubmissionDetail(
|
||||
submission=_response(request, submission),
|
||||
submission=_response(request, submission, caller),
|
||||
current_skill=skill_metadata_response(request, current) if current is not None and visible else None,
|
||||
)
|
||||
|
||||
|
|
@ -143,11 +167,12 @@ async def _review(
|
|||
caller: UserAPIKeyAuth,
|
||||
approve: bool,
|
||||
reason: str | None,
|
||||
expected_revision: int | None,
|
||||
) -> SkillHubReviewResponse:
|
||||
from litellm.proxy.utils import get_custom_url
|
||||
|
||||
result: Final = await review_skill(
|
||||
submission_id, submissions, caller, approve, reason, get_custom_url(str(request.base_url))
|
||||
submission_id, submissions, caller, approve, reason, get_custom_url(str(request.base_url)), expected_revision
|
||||
)
|
||||
if isinstance(result, SkillMutationError):
|
||||
raise HTTPException(result.status, result.message)
|
||||
|
|
@ -164,7 +189,7 @@ async def _review(
|
|||
)
|
||||
return SkillHubReviewResponse(
|
||||
action="approved" if approve else "rejected",
|
||||
submission=_response(request, result.submission),
|
||||
submission=_response(request, result.submission, caller),
|
||||
skill=None if result.plugin is None else skill_publication_response(request, publication, result.plugin),
|
||||
)
|
||||
|
||||
|
|
@ -175,9 +200,12 @@ async def approve_submission(
|
|||
submission_id: str,
|
||||
submissions: SkillSubmissionRepositoryDep,
|
||||
caller: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)],
|
||||
body: SkillHubSubmissionRevision | None = None,
|
||||
) -> SkillHubReviewResponse:
|
||||
"""Publish a pending proposal and record its approval atomically. Only proxy admins may review."""
|
||||
return await _review(request, submission_id, submissions, caller, True, None)
|
||||
return await _review(
|
||||
request, submission_id, submissions, caller, True, None, None if body is None else body.expected_revision
|
||||
)
|
||||
|
||||
|
||||
@router.put("/{submission_id}/reject", response_model=SkillHubReviewResponse)
|
||||
|
|
@ -189,4 +217,133 @@ async def reject_submission(
|
|||
body: SkillHubRejection | None = None,
|
||||
) -> SkillHubReviewResponse:
|
||||
"""Reject a pending proposal without changing the published skill. Only proxy admins may review."""
|
||||
return await _review(request, submission_id, submissions, caller, False, None if body is None else body.reason)
|
||||
return await _review(
|
||||
request,
|
||||
submission_id,
|
||||
submissions,
|
||||
caller,
|
||||
False,
|
||||
None if body is None else body.reason,
|
||||
None if body is None else body.expected_revision,
|
||||
)
|
||||
|
||||
|
||||
def _editor(caller: UserAPIKeyAuth) -> str:
|
||||
owner: Final = submission_editor(caller)
|
||||
if isinstance(owner, SkillMutationError):
|
||||
raise HTTPException(owner.status, owner.message)
|
||||
return owner
|
||||
|
||||
|
||||
def _changed(
|
||||
result: SkillSubmission | SubmissionMissing | SubmissionConflict | SubmissionLimitExceeded,
|
||||
) -> SkillSubmission:
|
||||
if isinstance(result, SubmissionMissing):
|
||||
raise HTTPException(404, "Skill submission not found")
|
||||
if isinstance(result, SubmissionConflict):
|
||||
raise HTTPException(409, result.message)
|
||||
if isinstance(result, SubmissionLimitExceeded):
|
||||
raise HTTPException(429, result.message)
|
||||
return result
|
||||
|
||||
|
||||
async def _editable(
|
||||
submission_id: str,
|
||||
owner: str,
|
||||
store: SkillSubmissionStore,
|
||||
revision: int,
|
||||
version: str | None,
|
||||
uploads: PluginUploadStore | None,
|
||||
caller: UserAPIKeyAuth,
|
||||
) -> SkillSubmission:
|
||||
submission: Final = await store.get(submission_id, owner)
|
||||
if submission is None:
|
||||
raise HTTPException(404, "Skill submission not found")
|
||||
if submission.status != "pending_review" or submission.revision != revision:
|
||||
raise HTTPException(409, "This submission changed or is no longer pending. Reload it before trying again.")
|
||||
existing: Final = await write_target(
|
||||
submission.plugin_name, submission.operation == "update", uploads, caller, submission.base_updated_at
|
||||
)
|
||||
if isinstance(existing, SkillMutationError):
|
||||
raise HTTPException(existing.status, existing.message)
|
||||
validation: Final = resolve_plugin_version(
|
||||
version, None if existing is None else existing.version, updating=existing is not None
|
||||
)
|
||||
if isinstance(validation, PluginPublicationError):
|
||||
error: Final = publication_error(validation)
|
||||
raise HTTPException(error.status, error.message)
|
||||
return submission
|
||||
|
||||
|
||||
@router.put("/{submission_id}", response_model=SkillHubSubmission)
|
||||
async def edit_submission(
|
||||
request: Request,
|
||||
submission_id: str,
|
||||
body: SkillHubSubmissionEdit,
|
||||
submissions: SkillSubmissionRepositoryDep,
|
||||
uploads: PluginUploadRepositoryDep,
|
||||
caller: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)],
|
||||
) -> SkillHubSubmission:
|
||||
"""Replace a pending proposal's metadata, preserving files. Only its original submitting user may edit."""
|
||||
owner: Final = _editor(caller)
|
||||
store: Final = _store(submissions)
|
||||
submission: Final = await _editable(
|
||||
submission_id, owner, store, body.expected_revision, body.version, uploads, caller
|
||||
)
|
||||
publication: Final = PluginPublication(
|
||||
name=submission.plugin_name,
|
||||
version=body.version,
|
||||
description=body.description,
|
||||
manifest={
|
||||
**{
|
||||
key: value
|
||||
for key, value in submission.proposal.manifest.items()
|
||||
if key not in SkillHubMetadata.model_fields
|
||||
},
|
||||
**body.model_dump(exclude={"expected_revision"}, exclude_none=True),
|
||||
},
|
||||
created_by=owner,
|
||||
)
|
||||
result: Final = await store.edit(submission_id, owner, body.expected_revision, publication)
|
||||
return _response(request, _changed(result), caller)
|
||||
|
||||
|
||||
@router.put("/{submission_id}/upload", response_model=SkillHubSubmission)
|
||||
async def edit_submission_archive(
|
||||
request: Request,
|
||||
submission_id: str,
|
||||
submissions: SkillSubmissionRepositoryDep,
|
||||
uploads: PluginUploadRepositoryDep,
|
||||
caller: Annotated[UserAPIKeyAuth, Depends(archive_writer)],
|
||||
file: Annotated[UploadFile, File(description="Replacement Agent Skills ZIP with the same declared skill name")],
|
||||
expected_revision: Annotated[int, Form(ge=0)],
|
||||
version: Annotated[str | None, Form()] = None,
|
||||
) -> SkillHubSubmission:
|
||||
"""Replace a pending proposal's files using the upload limits. Only its original submitting user may edit."""
|
||||
owner: Final = _editor(caller)
|
||||
store: Final = _store(submissions)
|
||||
submission: Final = await _editable(submission_id, owner, store, expected_revision, version, uploads, caller)
|
||||
content: Final = await file.read(MAX_IMPORT_BYTES + 1)
|
||||
if len(content) > MAX_IMPORT_BYTES:
|
||||
raise HTTPException(413, "Skill ZIP must be 10 MiB or smaller")
|
||||
archive: Final = await asyncio.to_thread(build_agent_skill_archive, content)
|
||||
if archive is None or archive.declared_name != submission.plugin_name:
|
||||
raise HTTPException(422, "Replacement SKILL.md must declare the existing skill name and a description")
|
||||
prepared: Final = await prepare_skill_publication(request, archive, version, None, owner)
|
||||
publication: Final = replace(prepared, manifest={**submission.proposal.manifest, **prepared.manifest})
|
||||
result: Final = await store.edit(submission_id, owner, expected_revision, publication)
|
||||
return _response(request, _changed(result), caller)
|
||||
|
||||
|
||||
@router.put("/{submission_id}/withdraw", response_model=SkillHubSubmission)
|
||||
async def withdraw_submission(
|
||||
request: Request,
|
||||
submission_id: str,
|
||||
body: SkillHubSubmissionRevision,
|
||||
submissions: SkillSubmissionRepositoryDep,
|
||||
caller: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)],
|
||||
) -> SkillHubSubmission:
|
||||
"""Withdraw a pending proposal without publishing it. Only its original submitting user may withdraw."""
|
||||
owner: Final = _editor(caller)
|
||||
result: Final = await _store(submissions).withdraw(submission_id, owner, body.expected_revision)
|
||||
return _response(request, _changed(result), caller)
|
||||
|
|
|
|||
|
|
@ -2993,6 +2993,7 @@ _ROUTE_MAX_REQUEST_SIZE_MB: Final[Mapping[tuple[str, str], float]] = MappingProx
|
|||
("POST", "/claude-code/plugins/upload"): 11,
|
||||
("POST", "/skill-hub/skills/upload"): 11,
|
||||
("PUT", "/skill-hub/skills/{name}/upload"): 11,
|
||||
("PUT", "/skill-hub/submissions/{submission_id}/upload"): 11,
|
||||
}
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ from datetime import datetime, timezone
|
|||
from typing import TYPE_CHECKING, Final, Protocol, cast # noqa: TID251 # Prisma's generated delegates are dynamic
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, TypeAdapter, ValidationError
|
||||
from pydantic import BaseModel, ConfigDict, Field, TypeAdapter, ValidationError
|
||||
|
||||
from litellm.models.plugin_upload import (
|
||||
PluginPublication,
|
||||
|
|
@ -45,6 +45,7 @@ class _StoredProposal(BaseModel):
|
|||
|
||||
manifest: Mapping[str, object]
|
||||
version: str | None
|
||||
revision: int = Field(default=0, ge=0)
|
||||
|
||||
def proposal(self, name: str) -> SkillProposal:
|
||||
return SkillProposal(
|
||||
|
|
@ -92,6 +93,7 @@ class _SubmissionRow(BaseModel):
|
|||
reviewed_by=self.reviewed_by,
|
||||
reviewed_at=self.reviewed_at,
|
||||
rejection_reason=self.rejection_reason,
|
||||
revision=stored.revision,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -283,6 +285,71 @@ class SkillSubmissionRepository:
|
|||
)
|
||||
return None if row is None or row.archive_content is None else row.archive_content.decode()
|
||||
|
||||
async def edit(
|
||||
self, submission_id: str, owner: str, revision: int, upload: PluginPublication
|
||||
) -> SkillSubmission | SubmissionMissing | SubmissionConflict | SubmissionLimitExceeded:
|
||||
from prisma import Base64
|
||||
|
||||
async with self._database.tx() as transaction:
|
||||
await transaction.query_raw(
|
||||
"SELECT 1 AS locked FROM pg_advisory_xact_lock(hashtext('litellm.skill_submissions'), hashtext($1))",
|
||||
owner,
|
||||
)
|
||||
row: Final = await _owned_pending(transaction, submission_id, owner, revision)
|
||||
if isinstance(row, (SubmissionMissing, SubmissionConflict)):
|
||||
return row
|
||||
if upload.name != row.plugin_name:
|
||||
return SubmissionConflict("A submission's skill name cannot change.")
|
||||
proposal_json: Final = _StoredProposal(
|
||||
manifest=upload.manifest, version=upload.version, revision=revision + 1
|
||||
).model_dump_json()
|
||||
archive: Final = row.archive_content if upload.content is None else Base64.encode(upload.content)
|
||||
usage: Final = _USAGE.validate_python(
|
||||
await transaction.query_raw(
|
||||
"""SELECT COUNT(*) AS count,
|
||||
COALESCE(SUM(octet_length(proposal_json)
|
||||
+ COALESCE(octet_length(archive_content), 0)), 0) AS storage_bytes
|
||||
FROM "LiteLLM_SkillSubmission" WHERE submitted_by = $1 AND status = 'pending_review'""",
|
||||
owner,
|
||||
)
|
||||
)[0]
|
||||
previous_bytes: Final = len(row.proposal_json.encode()) + (
|
||||
0 if row.archive_content is None else len(row.archive_content.decode())
|
||||
)
|
||||
incoming_bytes: Final = len(proposal_json.encode()) + (0 if archive is None else len(archive.decode()))
|
||||
if usage.storage_bytes - previous_bytes + incoming_bytes > _MAX_PENDING_BYTES:
|
||||
return SubmissionLimitExceeded(
|
||||
"Pending skill submissions cannot exceed 50 MiB of stored metadata and archives per user."
|
||||
)
|
||||
updated: Final = await transaction.litellm_skillsubmission.update(
|
||||
where={"id": submission_id},
|
||||
data={"proposal_json": proposal_json, "archive_content": archive, "scan_json": None},
|
||||
)
|
||||
return SubmissionMissing() if updated is None else _submission(updated)
|
||||
|
||||
async def withdraw(
|
||||
self, submission_id: str, owner: str, revision: int
|
||||
) -> SkillSubmission | SubmissionMissing | SubmissionConflict:
|
||||
async with self._database.tx() as transaction:
|
||||
row: Final = await _owned_pending(transaction, submission_id, owner, revision)
|
||||
if isinstance(row, (SubmissionMissing, SubmissionConflict)):
|
||||
return row
|
||||
receipt: Final = _StoredProposal(
|
||||
manifest={"name": row.plugin_name, "source": {}},
|
||||
version=None,
|
||||
revision=revision,
|
||||
)
|
||||
updated: Final = await transaction.litellm_skillsubmission.update(
|
||||
where={"id": submission_id},
|
||||
data={
|
||||
"status": "withdrawn",
|
||||
"proposal_json": receipt.model_dump_json(),
|
||||
"archive_content": None,
|
||||
"scan_json": None,
|
||||
},
|
||||
)
|
||||
return SubmissionMissing() if updated is None else _submission(updated)
|
||||
|
||||
async def review(
|
||||
self,
|
||||
submission_id: str,
|
||||
|
|
@ -291,6 +358,7 @@ class SkillSubmissionRepository:
|
|||
reason: str | None,
|
||||
*,
|
||||
publication_base_url: str | None = None,
|
||||
expected_revision: int | None = None,
|
||||
) -> SubmissionReviewResult:
|
||||
from prisma.errors import UniqueViolationError
|
||||
|
||||
|
|
@ -304,7 +372,9 @@ class SkillSubmissionRepository:
|
|||
return SubmissionMissing()
|
||||
submission: Final = _submission(row)
|
||||
if submission.status != "pending_review":
|
||||
return SubmissionConflict("This submission has already been reviewed.")
|
||||
return SubmissionConflict("This submission is no longer pending review.")
|
||||
if submission.revision != (expected_revision if expected_revision is not None else 0):
|
||||
return SubmissionConflict("This submission changed. Reload it before reviewing.")
|
||||
content: Final = None if row.archive_content is None else row.archive_content.decode()
|
||||
if approve and content is not None and not publication_base_url:
|
||||
return SubmissionConflict("Uploaded skill approval requires a trusted publication URL.")
|
||||
|
|
@ -344,6 +414,20 @@ class SkillSubmissionRepository:
|
|||
return SubmissionConflict("A skill with this name is already published. Submit a new update proposal.")
|
||||
|
||||
|
||||
async def _owned_pending(
|
||||
transaction: SubmissionTransaction, submission_id: str, owner: str, revision: int
|
||||
) -> "prisma_models.LiteLLM_SkillSubmission | SubmissionMissing | SubmissionConflict":
|
||||
await transaction.query_raw('SELECT id FROM "LiteLLM_SkillSubmission" WHERE id = $1 FOR UPDATE', submission_id)
|
||||
row: Final = await transaction.litellm_skillsubmission.find_unique(where={"id": submission_id})
|
||||
if row is None or not owner or row.submitted_by != owner:
|
||||
return SubmissionMissing()
|
||||
if row.status != "pending_review":
|
||||
return SubmissionConflict("Only pending submissions can be edited or withdrawn.")
|
||||
if _StoredProposal.model_validate_json(row.proposal_json).revision != revision:
|
||||
return SubmissionConflict("This submission changed. Reload it before trying again.")
|
||||
return row
|
||||
|
||||
|
||||
async def _publish_submission(
|
||||
transaction: PluginPublicationTransaction,
|
||||
row: "prisma_models.LiteLLM_SkillSubmission",
|
||||
|
|
|
|||
|
|
@ -156,6 +156,8 @@ class SkillHubSubmission(LiteLLMBaseModel):
|
|||
reviewed_by: str | None
|
||||
reviewed_at: datetime | None
|
||||
rejection_reason: str | None
|
||||
revision: int = Field(0, ge=0, description="Proposal revision used to prevent stale edits and reviews")
|
||||
can_edit: bool = Field(False, description="Whether the caller may edit or withdraw this pending submission")
|
||||
|
||||
|
||||
class SkillHubSubmissionList(LiteLLMBaseModel):
|
||||
|
|
@ -173,6 +175,16 @@ class SkillHubSubmissionDetail(LiteLLMBaseModel):
|
|||
current_skill: SkillHubSkill | None
|
||||
|
||||
|
||||
class SkillHubSubmissionRevision(LiteLLMBaseModel):
|
||||
model_config = ConfigDict(frozen=True, extra="forbid")
|
||||
|
||||
expected_revision: int = Field(ge=0, description="Revision read from the submission detail")
|
||||
|
||||
|
||||
class SkillHubSubmissionEdit(SkillHubMetadata):
|
||||
expected_revision: int = Field(ge=0, description="Revision read from the submission detail")
|
||||
|
||||
|
||||
class SkillHubReviewResponse(LiteLLMBaseModel):
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
|
|
@ -186,3 +198,4 @@ class SkillHubRejection(LiteLLMBaseModel):
|
|||
model_config = ConfigDict(frozen=True, extra="forbid")
|
||||
|
||||
reason: str | None = Field(None, max_length=2000)
|
||||
expected_revision: int | None = Field(None, ge=0)
|
||||
|
|
|
|||
|
|
@ -31,6 +31,8 @@ const published: SkillHubSkill = {
|
|||
|
||||
const pending: SkillSubmission = {
|
||||
id: "submission-1",
|
||||
revision: 0,
|
||||
can_edit: false,
|
||||
skill_name: published.name,
|
||||
operation: "update",
|
||||
status: "pending_review",
|
||||
|
|
@ -70,8 +72,9 @@ let reviewFailure: string | null;
|
|||
let directPublication: boolean;
|
||||
|
||||
const respondToWrite = (path: string, body: RequestInit["body"]) => {
|
||||
if (reviewFailure && path.startsWith("/skill-hub/submissions/"))
|
||||
return Response.json({ detail: { error: reviewFailure } }, { status: 409 });
|
||||
if (path.endsWith("/approve") || path.endsWith("/reject")) {
|
||||
if (reviewFailure) return Response.json({ detail: { error: reviewFailure } }, { status: 409 });
|
||||
const approved = path.endsWith("/approve");
|
||||
const reason = typeof body === "string" ? JSON.parse(body).reason : null;
|
||||
submissions = submissions.map((submission) =>
|
||||
|
|
@ -88,6 +91,19 @@ const respondToWrite = (path: string, body: RequestInit["body"]) => {
|
|||
};
|
||||
return Response.json(result);
|
||||
}
|
||||
if (path === `/skill-hub/submissions/${pending.id}`) {
|
||||
const metadata = typeof body === "string" ? JSON.parse(body) : {};
|
||||
submissions = submissions.map((item) => ({
|
||||
...item,
|
||||
revision: (item.revision ?? 0) + 1,
|
||||
proposal: { ...item.proposal, ...metadata },
|
||||
}));
|
||||
return Response.json(submissions[0]);
|
||||
}
|
||||
if (path.endsWith("/withdraw")) {
|
||||
submissions = submissions.map((item) => ({ ...item, status: "withdrawn", can_edit: false }));
|
||||
return Response.json(submissions[0]);
|
||||
}
|
||||
if (directPublication) {
|
||||
catalog = [{ ...published, version: "3.0.0", description: "Admin-published instructions" }];
|
||||
return Response.json(catalog[0]);
|
||||
|
|
@ -573,7 +589,7 @@ it("saves an admin rejection reason for the author and keeps the published metad
|
|||
expect(writes[0]).toMatchObject({
|
||||
path: `/skill-hub/submissions/${pending.id}/reject`,
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ reason: "Please include setup instructions" }),
|
||||
body: JSON.stringify({ expected_revision: 0, reason: "Please include setup instructions" }),
|
||||
});
|
||||
fireEvent.click(screen.getByRole("tab", { name: "Published skills" }));
|
||||
await waitFor(() =>
|
||||
|
|
@ -792,3 +808,84 @@ it("applies tag and scan filters across the catalog and resets pagination when s
|
|||
expect(catalogRequests.at(-1)?.searchParams.get("tag")).toBe("engineering");
|
||||
expect(catalogRequests.at(-1)?.searchParams.get("security_scan_passed")).toBe("true");
|
||||
});
|
||||
|
||||
it("lets an owner edit the viewed proposal and withdraw it with its current revision", async () => {
|
||||
submissions = [{ ...pending, can_edit: true, revision: 3, proposal: { ...pending.proposal, version: "3.0.0" } }];
|
||||
renderWithProviders(<ClaudeCodePluginsPanel accessToken="member-token" userRole="Internal User" />, {
|
||||
searchParams: "tab=submissions",
|
||||
});
|
||||
fireEvent.click(await screen.findByRole("button", { name: "View details" }));
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Edit submission" }));
|
||||
expect(screen.getByLabelText("Version override (optional)")).toHaveValue("3.0.0");
|
||||
expect(
|
||||
screen.getByText(
|
||||
`Current version ${published.version}. Leave blank for the next patch version, or enter a higher version`,
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
fireEvent.change(await screen.findByLabelText("Description (Optional)"), {
|
||||
target: { value: "Owner revised instructions" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save submission" }));
|
||||
expect(await screen.findByRole("cell", { name: "Owner revised instructions" })).toBeInTheDocument();
|
||||
expect(JSON.parse(String(writes[0].body))).toMatchObject({
|
||||
expected_revision: 3,
|
||||
description: "Owner revised instructions",
|
||||
});
|
||||
expect(catalog).toEqual([published]);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Withdraw submission" }));
|
||||
expect(writes).toHaveLength(1);
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Confirm withdrawal" }));
|
||||
expect(await screen.findByText("Withdrawn")).toBeInTheDocument();
|
||||
expect(writes[1]).toMatchObject({
|
||||
path: `/skill-hub/submissions/${pending.id}/withdraw`,
|
||||
body: JSON.stringify({ expected_revision: 4 }),
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "View details" }));
|
||||
expect(screen.queryByRole("button", { name: "Edit submission" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Withdraw submission" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps admin review separate from ownership and sends the displayed revision", async () => {
|
||||
submissions = [{ ...pending, revision: 7, can_edit: false }];
|
||||
renderSubmissions();
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Review" }));
|
||||
expect(screen.queryByRole("button", { name: "Edit submission" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Withdraw submission" })).not.toBeInTheDocument();
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Approve and publish" }));
|
||||
expect(await screen.findByText("Approved")).toBeInTheDocument();
|
||||
expect(JSON.parse(String(writes[0].body))).toEqual({ expected_revision: 7 });
|
||||
});
|
||||
|
||||
it.each(["metadata", "upload"])(
|
||||
"reloads a conflicted %s edit only when the author discards the draft",
|
||||
async (mode) => {
|
||||
submissions = [{ ...pending, can_edit: true }];
|
||||
renderWithProviders(<ClaudeCodePluginsPanel accessToken="member-token" userRole="Internal User" />, {
|
||||
searchParams: "tab=submissions",
|
||||
});
|
||||
fireEvent.click(await screen.findByRole("button", { name: "View details" }));
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Edit submission" }));
|
||||
fireEvent.change(screen.getByLabelText("Description (Optional)"), { target: { value: "Unsaved draft" } });
|
||||
if (mode === "upload") {
|
||||
fireEvent.click(screen.getByRole("radio", { name: "Upload ZIP" }));
|
||||
fireEvent.change(screen.getByLabelText("Skill ZIP file"), {
|
||||
target: { files: [new File(["replacement"], "skill.zip", { type: "application/zip" })] },
|
||||
});
|
||||
}
|
||||
submissions = [
|
||||
{ ...pending, can_edit: true, revision: 1, proposal: { ...pending.proposal, description: "Latest edit" } },
|
||||
];
|
||||
reviewFailure = "This submission changed. Reload it before trying again.";
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save submission" }));
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent(reviewFailure);
|
||||
expect(screen.getByRole("dialog", { name: "Edit submission" })).toBeInTheDocument();
|
||||
reviewFailure = null;
|
||||
fireEvent.click(screen.getByRole("button", { name: "Discard draft and reload submission" }));
|
||||
expect(await screen.findByRole("cell", { name: "Latest edit" })).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Edit submission" }));
|
||||
expect(screen.getByLabelText("Description (Optional)")).toHaveValue("Latest edit");
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save submission" }));
|
||||
await waitFor(() => expect(writes).toHaveLength(2));
|
||||
expect(JSON.parse(String(writes[1].body))).toMatchObject({ expected_revision: 1, description: "Latest edit" });
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -245,6 +245,7 @@ const SkillsPanelContent: React.FC<ClaudeCodePluginsPanelProps> = ({
|
|||
accessToken={accessToken}
|
||||
queryScope={queryScope}
|
||||
canReview={isAdmin}
|
||||
canWrite={canWrite}
|
||||
adminView={adminView}
|
||||
onChanged={refreshSkills}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import {
|
|||
getSkillSubmission,
|
||||
listSkillSubmissions,
|
||||
reviewSkillSubmission,
|
||||
withdrawSkillSubmission,
|
||||
} from "@/components/networking";
|
||||
import type { SkillHubSkill, SkillSubmission } from "@/components/claude_code_plugins/types";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
|
@ -16,6 +17,16 @@ import { Textarea } from "@/components/ui/textarea";
|
|||
import { useUrlTableState, type UrlTableStateOptions } from "@/components/shared/DataTable/useUrlTableState";
|
||||
import SkillScanPanel from "./SkillScanPanel";
|
||||
import { toast } from "@/lib/toast";
|
||||
import AddPluginForm from "./add_plugin_form";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
|
||||
const METADATA_FIELDS = ["name", "version", "description", "source", "keywords", "domain", "namespace"] as const;
|
||||
|
||||
|
|
@ -28,6 +39,7 @@ const STATUS_CLASSES = {
|
|||
pending_review: "border-warning/30 text-warning",
|
||||
approved: "border-success/30 text-success",
|
||||
rejected: "border-destructive/30 text-destructive",
|
||||
withdrawn: "border-muted text-muted-foreground",
|
||||
};
|
||||
|
||||
const SUBMISSION_TABLE_STATE: UrlTableStateOptions<never> = {
|
||||
|
|
@ -82,51 +94,63 @@ function SubmissionSummary({ submission }: { submission: SkillSubmission }) {
|
|||
function SubmissionComparison({
|
||||
submission,
|
||||
current,
|
||||
onDownload,
|
||||
downloading,
|
||||
}: {
|
||||
submission: SkillSubmission;
|
||||
current?: SkillHubSkill | null;
|
||||
onDownload: () => Promise<void>;
|
||||
downloading: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full table-fixed border-collapse text-sm">
|
||||
<thead>
|
||||
<tr className="border-b">
|
||||
<th className="w-1/5 p-2 text-left">{t("skills.submissions.field")}</th>
|
||||
<th className="p-2 text-left">{t("skills.submissions.current")}</th>
|
||||
<th className="p-2 text-left">{t("skills.submissions.proposed")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{METADATA_FIELDS.map((field) => (
|
||||
<tr key={field} className="border-b align-top">
|
||||
<th className="p-2 text-left font-medium">{t(`skills.submissions.fields.${field}`)}</th>
|
||||
<>
|
||||
{!current && <p className="text-sm text-muted-foreground">{t("skills.submissions.noCurrent")}</p>}
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full table-fixed border-collapse text-sm">
|
||||
<thead>
|
||||
<tr className="border-b">
|
||||
<th className="w-1/5 p-2 text-left">{t("skills.submissions.field")}</th>
|
||||
<th className="p-2 text-left">{t("skills.submissions.current")}</th>
|
||||
<th className="p-2 text-left">{t("skills.submissions.proposed")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{METADATA_FIELDS.map((field) => (
|
||||
<tr key={field} className="border-b align-top">
|
||||
<th className="p-2 text-left font-medium">{t(`skills.submissions.fields.${field}`)}</th>
|
||||
<td className="p-2">
|
||||
<pre className="whitespace-pre-wrap break-words font-mono text-xs">
|
||||
{displayValue(current?.[field]) || t("skills.submissions.notSet")}
|
||||
</pre>
|
||||
</td>
|
||||
<td className="p-2">
|
||||
<pre className="whitespace-pre-wrap break-words font-mono text-xs">
|
||||
{field === "version" && submission.operation === "update" && !submission.proposal.version
|
||||
? t("skills.submissions.nextPatchVersion")
|
||||
: displayValue(submission.proposal[field]) || t("skills.submissions.notSet")}
|
||||
</pre>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
<tr className="border-b align-top">
|
||||
<th className="p-2 text-left font-medium">{t("skills.submissions.fields.agent_skill")}</th>
|
||||
<td className="p-2 text-muted-foreground">{t("skills.submissions.descriptorUnavailable")}</td>
|
||||
<td className="p-2">
|
||||
<pre className="whitespace-pre-wrap break-words font-mono text-xs">
|
||||
{displayValue(current?.[field]) || t("skills.submissions.notSet")}
|
||||
</pre>
|
||||
</td>
|
||||
<td className="p-2">
|
||||
<pre className="whitespace-pre-wrap break-words font-mono text-xs">
|
||||
{field === "version" && submission.operation === "update" && !submission.proposal.version
|
||||
? t("skills.submissions.nextPatchVersion")
|
||||
: displayValue(submission.proposal[field]) || t("skills.submissions.notSet")}
|
||||
{displayValue(submission.proposal.agent_skill) || t("skills.submissions.notSet")}
|
||||
</pre>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
<tr className="border-b align-top">
|
||||
<th className="p-2 text-left font-medium">{t("skills.submissions.fields.agent_skill")}</th>
|
||||
<td className="p-2 text-muted-foreground">{t("skills.submissions.descriptorUnavailable")}</td>
|
||||
<td className="p-2">
|
||||
<pre className="whitespace-pre-wrap break-words font-mono text-xs">
|
||||
{displayValue(submission.proposal.agent_skill) || t("skills.submissions.notSet")}
|
||||
</pre>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{submission.has_archive && submission.proposal.agent_skill && (
|
||||
<Button variant="outline" onClick={onDownload} disabled={downloading}>
|
||||
{t(downloading ? "skills.submissions.downloading" : "skills.submissions.download")}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -135,33 +159,46 @@ interface SubmissionReviewProps {
|
|||
accessToken: string;
|
||||
queryScope: QueryKey;
|
||||
canReview: boolean;
|
||||
canWrite: boolean;
|
||||
onClose: () => void;
|
||||
onChanged: () => Promise<void>;
|
||||
}
|
||||
|
||||
function SubmissionReview({ id, accessToken, queryScope, canReview, onClose, onChanged }: SubmissionReviewProps) {
|
||||
function SubmissionReview({
|
||||
id,
|
||||
accessToken,
|
||||
queryScope,
|
||||
canReview,
|
||||
canWrite,
|
||||
onClose,
|
||||
onChanged,
|
||||
}: SubmissionReviewProps) {
|
||||
const { t } = useTranslation();
|
||||
const [reason, setReason] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [downloading, setDownloading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const detail = useQuery({
|
||||
const [editing, setEditing] = useState<SkillSubmission | null>(null);
|
||||
const [withdrawing, setWithdrawing] = useState<SkillSubmission | null>(null);
|
||||
const detailOptions = {
|
||||
queryKey: [...queryScope, "submission", id],
|
||||
queryFn: () => getSkillSubmission(accessToken, id),
|
||||
});
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
};
|
||||
const detail = useQuery(detailOptions);
|
||||
const { submission, current_skill: current } = detail.data ?? {};
|
||||
const canDecide = canReview && submission?.status === "pending_review";
|
||||
const actionsDisabled = busy || detail.isFetching || detail.isError;
|
||||
const review = async (action: "approve" | "reject") => {
|
||||
if (!canDecide || busy) return;
|
||||
if (!canDecide || !submission || busy) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await reviewSkillSubmission(
|
||||
accessToken,
|
||||
id,
|
||||
action,
|
||||
action === "reject" ? { reason: reason.trim() || null } : undefined,
|
||||
);
|
||||
await reviewSkillSubmission(accessToken, id, action, {
|
||||
expected_revision: submission.revision ?? 0,
|
||||
...(action === "reject" ? { reason: reason.trim() || null } : {}),
|
||||
});
|
||||
onClose();
|
||||
await onChanged();
|
||||
toast.success(t(action === "approve" ? "skills.submissions.approved" : "skills.submissions.rejected"));
|
||||
|
|
@ -172,6 +209,24 @@ function SubmissionReview({ id, accessToken, queryScope, canReview, onClose, onC
|
|||
setBusy(false);
|
||||
}
|
||||
};
|
||||
const withdraw = async () => {
|
||||
if (!withdrawing || busy) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await withdrawSkillSubmission(accessToken, withdrawing);
|
||||
setWithdrawing(null);
|
||||
onClose();
|
||||
await onChanged();
|
||||
toast.success(t("skills.submissions.withdrawn"));
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t("skills.submissions.actionFailed"));
|
||||
setWithdrawing(null);
|
||||
await onChanged();
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
const download = async () => {
|
||||
if (!submission || downloading) return;
|
||||
setDownloading(true);
|
||||
|
|
@ -185,73 +240,111 @@ function SubmissionReview({ id, accessToken, queryScope, canReview, onClose, onC
|
|||
}
|
||||
};
|
||||
return (
|
||||
<Dialog open onOpenChange={(open) => !open && !busy && onClose()}>
|
||||
<DialogContent className="top-8 max-h-[calc(100dvh-4rem)] translate-y-0 overflow-y-auto sm:max-w-[960px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("skills.submissions.reviewTitle", { name: submission?.skill_name ?? "" })}</DialogTitle>
|
||||
</DialogHeader>
|
||||
{detail.isPending && <p>{t("skills.submissions.loading")}</p>}
|
||||
{detail.isError && (
|
||||
<div role="alert" className="text-destructive">
|
||||
{t("skills.submissions.loadFailed")}
|
||||
<Button variant="link" onClick={() => void detail.refetch()}>
|
||||
{t("skills.submissions.retry")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{submission && (
|
||||
<>
|
||||
<SubmissionSummary submission={submission} />
|
||||
{!current && <p className="text-sm text-muted-foreground">{t("skills.submissions.noCurrent")}</p>}
|
||||
<SubmissionComparison submission={submission} current={current} />
|
||||
{submission.has_archive && submission.proposal.agent_skill && (
|
||||
<Button variant="outline" onClick={download} disabled={downloading}>
|
||||
{t(downloading ? "skills.submissions.downloading" : "skills.submissions.download")}
|
||||
<>
|
||||
<Dialog open={!editing && !withdrawing} onOpenChange={(open) => !open && !busy && onClose()}>
|
||||
<DialogContent className="top-8 max-h-[calc(100dvh-4rem)] translate-y-0 overflow-y-auto sm:max-w-[960px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("skills.submissions.reviewTitle", { name: submission?.skill_name ?? "" })}</DialogTitle>
|
||||
</DialogHeader>
|
||||
{detail.isPending && <p>{t("skills.submissions.loading")}</p>}
|
||||
{detail.isError && (
|
||||
<div role="alert" className="text-destructive">
|
||||
{t("skills.submissions.loadFailed")}
|
||||
<Button variant="link" onClick={() => void detail.refetch()}>
|
||||
{t("skills.submissions.retry")}
|
||||
</Button>
|
||||
)}
|
||||
{canReview && (
|
||||
<SkillScanPanel
|
||||
id={id}
|
||||
accessToken={accessToken}
|
||||
queryScope={queryScope}
|
||||
pending={submission.status === "pending_review"}
|
||||
</div>
|
||||
)}
|
||||
{submission && (
|
||||
<>
|
||||
<SubmissionSummary submission={submission} />
|
||||
<SubmissionComparison
|
||||
submission={submission}
|
||||
current={current}
|
||||
onDownload={download}
|
||||
downloading={downloading}
|
||||
/>
|
||||
)}
|
||||
{canDecide && (
|
||||
<>
|
||||
<label htmlFor="skill-rejection-reason" className="text-sm font-medium">
|
||||
{t("skills.submissions.reasonLabel")}
|
||||
</label>
|
||||
<Textarea
|
||||
id="skill-rejection-reason"
|
||||
value={reason}
|
||||
maxLength={2000}
|
||||
onChange={(event) => setReason(event.target.value)}
|
||||
disabled={busy}
|
||||
/>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => void review("reject")}
|
||||
disabled={busy || detail.isFetching || detail.isError}
|
||||
>
|
||||
{t("skills.submissions.reject")}
|
||||
{canWrite && submission.can_edit && (
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" disabled={actionsDisabled} onClick={() => setEditing(submission)}>
|
||||
{t("skills.submissions.edit")}
|
||||
</Button>
|
||||
<Button onClick={() => void review("approve")} disabled={busy || detail.isFetching || detail.isError}>
|
||||
{t("skills.submissions.approve")}
|
||||
<Button variant="destructive" disabled={actionsDisabled} onClick={() => setWithdrawing(submission)}>
|
||||
{t("skills.submissions.withdraw")}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{error && (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
{canReview && (
|
||||
<SkillScanPanel
|
||||
id={id}
|
||||
accessToken={accessToken}
|
||||
queryScope={queryScope}
|
||||
pending={submission.status === "pending_review"}
|
||||
/>
|
||||
)}
|
||||
{canDecide && (
|
||||
<>
|
||||
<label htmlFor="skill-rejection-reason" className="text-sm font-medium">
|
||||
{t("skills.submissions.reasonLabel")}
|
||||
</label>
|
||||
<Textarea
|
||||
id="skill-rejection-reason"
|
||||
value={reason}
|
||||
maxLength={2000}
|
||||
onChange={(event) => setReason(event.target.value)}
|
||||
disabled={busy}
|
||||
/>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="destructive" onClick={() => void review("reject")} disabled={actionsDisabled}>
|
||||
{t("skills.submissions.reject")}
|
||||
</Button>
|
||||
<Button onClick={() => void review("approve")} disabled={actionsDisabled}>
|
||||
{t("skills.submissions.approve")}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{error && (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
{editing && (
|
||||
<AddPluginForm
|
||||
key={`${editing.id}-${editing.revision}`}
|
||||
visible
|
||||
accessToken={accessToken}
|
||||
skill={{ ...editing.proposal, id: editing.id, visibility: "private", security_scan_passed: false }}
|
||||
submission={{
|
||||
value: editing,
|
||||
publishedVersion: current?.version ?? null,
|
||||
onChanged: () => {
|
||||
void onChanged();
|
||||
},
|
||||
}}
|
||||
onClose={() => setEditing(null)}
|
||||
requiresApproval
|
||||
/>
|
||||
)}
|
||||
<AlertDialog open={Boolean(withdrawing)} onOpenChange={(open) => !open && !busy && setWithdrawing(null)}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t("skills.submissions.withdraw")}</AlertDialogTitle>
|
||||
<AlertDialogDescription>{t("skills.submissions.withdrawNotice")}</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={busy}>{t("skills.addForm.cancel")}</AlertDialogCancel>
|
||||
<Button variant="destructive" disabled={busy} onClick={() => void withdraw()}>
|
||||
{t("skills.submissions.confirmWithdraw")}
|
||||
</Button>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -259,6 +352,7 @@ interface SkillSubmissionsProps {
|
|||
accessToken: string;
|
||||
queryScope: QueryKey;
|
||||
canReview: boolean;
|
||||
canWrite: boolean;
|
||||
adminView: boolean;
|
||||
onChanged: () => Promise<void>;
|
||||
}
|
||||
|
|
@ -267,6 +361,7 @@ export default function SkillSubmissions({
|
|||
accessToken,
|
||||
queryScope,
|
||||
canReview,
|
||||
canWrite,
|
||||
adminView,
|
||||
onChanged,
|
||||
}: SkillSubmissionsProps) {
|
||||
|
|
@ -358,6 +453,7 @@ export default function SkillSubmissions({
|
|||
accessToken={accessToken}
|
||||
queryScope={queryScope}
|
||||
canReview={canReview}
|
||||
canWrite={canWrite}
|
||||
onClose={() => setSelectedId(null)}
|
||||
onChanged={onChanged}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -3,7 +3,14 @@ import { useTranslation } from "react-i18next";
|
|||
import { z } from "zod";
|
||||
import i18n from "@/i18n/i18n";
|
||||
import { toast } from "@/lib/toast";
|
||||
import { importSkillHubSkill, updateSkillHubSkill, uploadSkillHubSkill } from "@/components/networking";
|
||||
import { ApiError } from "@/lib/http/client";
|
||||
import {
|
||||
editSkillSubmission,
|
||||
uploadSkillSubmission,
|
||||
importSkillHubSkill,
|
||||
updateSkillHubSkill,
|
||||
uploadSkillHubSkill,
|
||||
} from "@/components/networking";
|
||||
import { FormField } from "@/components/shared/form/FormField";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
|
|
@ -13,7 +20,12 @@ import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
|||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { useZodForm } from "@/lib/forms/useZodForm";
|
||||
import { isValidSemanticVersion } from "@/components/claude_code_plugins/helpers";
|
||||
import { isPendingSkill, type SkillHubSkill, type SkillMutationResponse } from "@/components/claude_code_plugins/types";
|
||||
import {
|
||||
isPendingSkill,
|
||||
type SkillHubSkill,
|
||||
type SkillMutationResponse,
|
||||
type SkillSubmission,
|
||||
} from "@/components/claude_code_plugins/types";
|
||||
import { buildSkillImport, buildSkillMetadata, skillFormValues } from "@/components/claude_code_plugins/skillForm";
|
||||
|
||||
const EDIT_SKILL_SOURCES = ["metadata", "upload"] as const;
|
||||
|
|
@ -23,8 +35,9 @@ interface AddPluginFormProps {
|
|||
visible: boolean;
|
||||
onClose: () => void;
|
||||
accessToken: string | null;
|
||||
onSuccess: (response: SkillMutationResponse) => void;
|
||||
onSuccess?: (response: SkillMutationResponse) => void;
|
||||
skill?: SkillHubSkill;
|
||||
submission?: { value: SkillSubmission; publishedVersion: string | null; onChanged: () => void };
|
||||
requiresApproval?: boolean;
|
||||
}
|
||||
|
||||
|
|
@ -65,23 +78,53 @@ const skillSchema = z.object(skillShape).superRefine((values, context) => {
|
|||
}
|
||||
});
|
||||
|
||||
function formLabels(editing: boolean, submission: boolean, requiresApproval: boolean) {
|
||||
if (submission) return { title: "skills.submissions.edit", submit: "skills.submissions.save" } as const;
|
||||
const directSubmit = editing ? "skills.addForm.save" : "skills.addForm.submit";
|
||||
return {
|
||||
title: editing ? "skills.addForm.editTitle" : "skills.addForm.title",
|
||||
submit: requiresApproval ? "skills.addForm.submitReview" : directSubmit,
|
||||
} as const;
|
||||
}
|
||||
|
||||
export default function AddPluginForm({
|
||||
visible,
|
||||
onClose,
|
||||
accessToken,
|
||||
onSuccess,
|
||||
skill,
|
||||
submission,
|
||||
requiresApproval = false,
|
||||
}: AddPluginFormProps) {
|
||||
const { t } = useTranslation();
|
||||
const form = useZodForm(skillSchema, { defaultValues: skillFormValues(skill) });
|
||||
const form = useZodForm(skillSchema, {
|
||||
defaultValues: {
|
||||
...skillFormValues(skill),
|
||||
...(submission ? { version: submission.value.proposal.version ?? "" } : {}),
|
||||
},
|
||||
});
|
||||
const mode = form.watch("mode");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
const conflictedSubmission = error instanceof ApiError && error.status === 409 ? submission : undefined;
|
||||
const isSubmitting = form.formState.isSubmitting;
|
||||
const submit = async (values: z.infer<typeof skillSchema>) => {
|
||||
if (!accessToken) return;
|
||||
setError(null);
|
||||
try {
|
||||
if (submission && skill) {
|
||||
if (values.mode === "upload" && values.archiveFile) {
|
||||
await uploadSkillSubmission(accessToken, submission.value, values.archiveFile, values.version);
|
||||
} else {
|
||||
await editSkillSubmission(accessToken, submission.value.id, {
|
||||
...buildSkillMetadata(values, skill),
|
||||
expected_revision: submission.value.revision ?? 0,
|
||||
});
|
||||
}
|
||||
toast.success(t("skills.submissions.saved"));
|
||||
submission.onChanged();
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
const write = () => {
|
||||
if (values.mode === "upload" && values.archiveFile) {
|
||||
return uploadSkillHubSkill(accessToken, values.archiveFile, values.version, skill);
|
||||
|
|
@ -92,10 +135,10 @@ export default function AddPluginForm({
|
|||
const response = await write();
|
||||
const publishedMessage = skill ? "skills.addForm.toasts.updated" : "skills.addForm.toasts.registered";
|
||||
toast.success(t(isPendingSkill(response) ? "skills.addForm.toasts.submitted" : publishedMessage));
|
||||
onSuccess(response);
|
||||
onSuccess?.(response);
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t("skills.addForm.toasts.registerFailed"));
|
||||
setError(err instanceof Error ? err : new Error(t("skills.addForm.toasts.registerFailed")));
|
||||
}
|
||||
};
|
||||
const changeMode = (value: string) => {
|
||||
|
|
@ -105,15 +148,14 @@ export default function AddPluginForm({
|
|||
form.clearErrors();
|
||||
setError(null);
|
||||
};
|
||||
const directLabel = skill ? "skills.addForm.save" : "skills.addForm.submit";
|
||||
const submitLabel = requiresApproval ? "skills.addForm.submitReview" : directLabel;
|
||||
const labels = formLabels(Boolean(skill), Boolean(submission), requiresApproval);
|
||||
const fields =
|
||||
mode === "import" ? (["url", "skill", "ref", "path"] as const) : (["domain", "namespace", "keywords"] as const);
|
||||
return (
|
||||
<Dialog open={visible} onOpenChange={(open) => !open && !isSubmitting && onClose()}>
|
||||
<DialogContent className="max-h-[90dvh] overflow-y-auto sm:max-w-xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t(skill ? "skills.addForm.editTitle" : "skills.addForm.title")}</DialogTitle>
|
||||
<DialogTitle>{t(labels.title)}</DialogTitle>
|
||||
</DialogHeader>
|
||||
{skill && <p className="text-sm font-medium">{skill.name}</p>}
|
||||
{requiresApproval && <p className="text-sm text-muted-foreground">{t("skills.addForm.approvalNotice")}</p>}
|
||||
|
|
@ -176,7 +218,10 @@ export default function AddPluginForm({
|
|||
label={t(skill ? "skills.addForm.versionOverride" : "skills.addForm.labels.version")}
|
||||
description={
|
||||
skill
|
||||
? t("skills.addForm.versionContext", { version: skill.version ?? t("skills.submissions.notSet") })
|
||||
? t("skills.addForm.versionContext", {
|
||||
version:
|
||||
(submission ? submission.publishedVersion : skill.version) ?? t("skills.submissions.notSet"),
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
|
|
@ -186,15 +231,28 @@ export default function AddPluginForm({
|
|||
</fieldset>
|
||||
{error && (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
{error}
|
||||
{error.message}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex justify-end gap-2">
|
||||
{conflictedSubmission && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
conflictedSubmission.onChanged();
|
||||
onClose();
|
||||
}}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{t("skills.submissions.reload")}
|
||||
</Button>
|
||||
)}
|
||||
<Button type="button" variant="outline" onClick={onClose} disabled={isSubmitting}>
|
||||
{t("skills.addForm.cancel")}
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSubmitting} aria-busy={isSubmitting}>
|
||||
{t(isSubmitting ? "skills.addForm.saving" : submitLabel)}
|
||||
{t(isSubmitting ? "skills.addForm.saving" : labels.submit)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
|
|
|||
|
|
@ -6953,6 +6953,38 @@ export const reviewSkillSubmission = (
|
|||
): Promise<components["schemas"]["SkillHubReviewResponse"]> =>
|
||||
apiClient.put(`/skill-hub/submissions/${encodeURIComponent(id)}/${action}`, { accessToken, body });
|
||||
|
||||
export const editSkillSubmission = (
|
||||
accessToken: string,
|
||||
id: string,
|
||||
body: components["schemas"]["SkillHubSubmissionEdit"],
|
||||
): Promise<components["schemas"]["SkillHubSubmission"]> =>
|
||||
apiClient.put(`/skill-hub/submissions/${encodeURIComponent(id)}`, { accessToken, body });
|
||||
|
||||
export const uploadSkillSubmission = (
|
||||
accessToken: string,
|
||||
submission: components["schemas"]["SkillHubSubmission"],
|
||||
file: File,
|
||||
version: string,
|
||||
): Promise<components["schemas"]["SkillHubSubmission"]> => {
|
||||
const body = new FormData();
|
||||
body.append("file", file);
|
||||
body.append("expected_revision", String(submission.revision ?? 0));
|
||||
if (version.trim()) body.append("version", version.trim());
|
||||
return apiClient.put(`/skill-hub/submissions/${encodeURIComponent(submission.id)}/upload`, {
|
||||
accessToken,
|
||||
rawBody: body,
|
||||
});
|
||||
};
|
||||
|
||||
export const withdrawSkillSubmission = (
|
||||
accessToken: string,
|
||||
submission: components["schemas"]["SkillHubSubmission"],
|
||||
): Promise<components["schemas"]["SkillHubSubmission"]> =>
|
||||
apiClient.put(`/skill-hub/submissions/${encodeURIComponent(submission.id)}/withdraw`, {
|
||||
accessToken,
|
||||
body: { expected_revision: submission.revision ?? 0 },
|
||||
});
|
||||
|
||||
export const getSkillScan = (accessToken: string, id: string): Promise<components["schemas"]["SkillScanResponse"]> =>
|
||||
apiClient.get(`/skill-hub/submissions/${encodeURIComponent(id)}/scan`, { accessToken });
|
||||
|
||||
|
|
|
|||
|
|
@ -1926,7 +1926,8 @@
|
|||
"status": {
|
||||
"pending_review": "Pending review",
|
||||
"approved": "Approved",
|
||||
"rejected": "Rejected"
|
||||
"rejected": "Rejected",
|
||||
"withdrawn": "Withdrawn"
|
||||
},
|
||||
"operation": {
|
||||
"create": "New skill",
|
||||
|
|
@ -1947,7 +1948,15 @@
|
|||
"agent_skill": "Portable skill descriptor"
|
||||
},
|
||||
"nextPatchVersion": "Next patch version on approval",
|
||||
"descriptorUnavailable": "The current portable descriptor is not included in this response"
|
||||
"descriptorUnavailable": "The current portable descriptor is not included in this response",
|
||||
"edit": "Edit submission",
|
||||
"save": "Save submission",
|
||||
"saved": "Submission updated and still awaiting review",
|
||||
"reload": "Discard draft and reload submission",
|
||||
"withdraw": "Withdraw submission",
|
||||
"withdrawNotice": "This removes the unpublished files and proposal details and prevents approval. A withdrawal record remains. You can submit a new proposal later.",
|
||||
"confirmWithdraw": "Confirm withdrawal",
|
||||
"withdrawn": "Submission withdrawn"
|
||||
},
|
||||
"detail": {
|
||||
"subtitle": "Published skill metadata and portable archive",
|
||||
|
|
|
|||
223
litellm/ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
223
litellm/ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -16152,10 +16152,14 @@ export interface paths {
|
|||
};
|
||||
/**
|
||||
* Get Submission
|
||||
* @description Read an immutable proposal and its currently visible published skill.
|
||||
* @description Read a proposal revision and its currently visible published skill.
|
||||
*/
|
||||
get: operations["get_submission_skill_hub_submissions__submission_id__get"];
|
||||
put?: never;
|
||||
/**
|
||||
* Edit Submission
|
||||
* @description Replace a pending proposal's metadata, preserving files. Only its original submitting user may edit.
|
||||
*/
|
||||
put: operations["edit_submission_skill_hub_submissions__submission_id__put"];
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
|
|
@ -16247,6 +16251,46 @@ export interface paths {
|
|||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/skill-hub/submissions/{submission_id}/upload": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
/**
|
||||
* Edit Submission Archive
|
||||
* @description Replace a pending proposal's files using the upload limits. Only its original submitting user may edit.
|
||||
*/
|
||||
put: operations["edit_submission_archive_skill_hub_submissions__submission_id__upload_put"];
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/skill-hub/submissions/{submission_id}/withdraw": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
/**
|
||||
* Withdraw Submission
|
||||
* @description Withdraw a pending proposal without publishing it. Only its original submitting user may withdraw.
|
||||
*/
|
||||
put: operations["withdraw_submission_skill_hub_submissions__submission_id__withdraw_put"];
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/spend/calculate": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
|
@ -28837,6 +28881,18 @@ export interface components {
|
|||
*/
|
||||
target_storage: string;
|
||||
};
|
||||
/** Body_edit_submission_archive_skill_hub_submissions__submission_id__upload_put */
|
||||
Body_edit_submission_archive_skill_hub_submissions__submission_id__upload_put: {
|
||||
/** Expected Revision */
|
||||
expected_revision: number;
|
||||
/**
|
||||
* File
|
||||
* @description Replacement Agent Skills ZIP with the same declared skill name
|
||||
*/
|
||||
file: string;
|
||||
/** Version */
|
||||
version?: string | null;
|
||||
};
|
||||
/** Body_image_edit_api_images_edits_post */
|
||||
Body_image_edit_api_images_edits_post: {
|
||||
/** Image */
|
||||
|
|
@ -47588,6 +47644,8 @@ export interface components {
|
|||
SkillHubPublication: Record<string, never>;
|
||||
/** SkillHubRejection */
|
||||
SkillHubRejection: {
|
||||
/** Expected Revision */
|
||||
expected_revision?: number | null;
|
||||
/** Reason */
|
||||
reason?: string | null;
|
||||
};
|
||||
|
|
@ -47681,6 +47739,12 @@ export interface components {
|
|||
base_skill_id: string | null;
|
||||
/** Base Updated At */
|
||||
base_updated_at: string | null;
|
||||
/**
|
||||
* Can Edit
|
||||
* @description Whether the caller may edit or withdraw this pending submission
|
||||
* @default false
|
||||
*/
|
||||
can_edit: boolean;
|
||||
/**
|
||||
* Created At
|
||||
* Format: date-time
|
||||
|
|
@ -47702,13 +47766,19 @@ export interface components {
|
|||
reviewed_at: string | null;
|
||||
/** Reviewed By */
|
||||
reviewed_by: string | null;
|
||||
/**
|
||||
* Revision
|
||||
* @description Proposal revision used to prevent stale edits and reviews
|
||||
* @default 0
|
||||
*/
|
||||
revision: number;
|
||||
/** Skill Name */
|
||||
skill_name: string;
|
||||
/**
|
||||
* Status
|
||||
* @enum {string}
|
||||
*/
|
||||
status: "pending_review" | "approved" | "rejected";
|
||||
status: "pending_review" | "approved" | "rejected" | "withdrawn";
|
||||
/** Submitted By */
|
||||
submitted_by: string;
|
||||
};
|
||||
|
|
@ -47717,6 +47787,32 @@ export interface components {
|
|||
current_skill: components["schemas"]["SkillHubSkill"] | null;
|
||||
submission: components["schemas"]["SkillHubSubmission"];
|
||||
};
|
||||
/** SkillHubSubmissionEdit */
|
||||
SkillHubSubmissionEdit: {
|
||||
/** Description */
|
||||
description?: string | null;
|
||||
/** Domain */
|
||||
domain?: string | null;
|
||||
/**
|
||||
* Expected Revision
|
||||
* @description Revision read from the submission detail
|
||||
*/
|
||||
expected_revision: number;
|
||||
/**
|
||||
* Expected Updated At
|
||||
* @description Published updated_at read by the editor. A stale value returns 409
|
||||
*/
|
||||
expected_updated_at?: string | null;
|
||||
/** Keywords */
|
||||
keywords?: string[] | null;
|
||||
/** Namespace */
|
||||
namespace?: string | null;
|
||||
/**
|
||||
* Version
|
||||
* @description Explicit higher SemVer, or omit/null for an automatic patch bump
|
||||
*/
|
||||
version?: string | null;
|
||||
};
|
||||
/** SkillHubSubmissionList */
|
||||
SkillHubSubmissionList: {
|
||||
/** Count */
|
||||
|
|
@ -47726,6 +47822,14 @@ export interface components {
|
|||
/** Submissions */
|
||||
submissions: components["schemas"]["SkillHubSubmission"][];
|
||||
};
|
||||
/** SkillHubSubmissionRevision */
|
||||
SkillHubSubmissionRevision: {
|
||||
/**
|
||||
* Expected Revision
|
||||
* @description Revision read from the submission detail
|
||||
*/
|
||||
expected_revision: number;
|
||||
};
|
||||
/** SkillHubVersion */
|
||||
SkillHubVersion: {
|
||||
/**
|
||||
|
|
@ -76029,7 +76133,7 @@ export interface operations {
|
|||
list_submissions_skill_hub_submissions_get: {
|
||||
parameters: {
|
||||
query?: {
|
||||
status?: ("pending_review" | "approved" | "rejected") | null;
|
||||
status?: ("pending_review" | "approved" | "rejected" | "withdrawn") | null;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
};
|
||||
|
|
@ -76090,6 +76194,41 @@ export interface operations {
|
|||
};
|
||||
};
|
||||
};
|
||||
edit_submission_skill_hub_submissions__submission_id__put: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
submission_id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["SkillHubSubmissionEdit"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["SkillHubSubmission"];
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["HTTPValidationError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
approve_submission_skill_hub_submissions__submission_id__approve_put: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
|
@ -76099,7 +76238,11 @@ export interface operations {
|
|||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
requestBody?: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["SkillHubSubmissionRevision"] | null;
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
|
|
@ -76253,6 +76396,76 @@ export interface operations {
|
|||
};
|
||||
};
|
||||
};
|
||||
edit_submission_archive_skill_hub_submissions__submission_id__upload_put: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
submission_id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"multipart/form-data": components["schemas"]["Body_edit_submission_archive_skill_hub_submissions__submission_id__upload_put"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["SkillHubSubmission"];
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["HTTPValidationError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
withdraw_submission_skill_hub_submissions__submission_id__withdraw_put: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
submission_id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["SkillHubSubmissionRevision"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["SkillHubSubmission"];
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["HTTPValidationError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
calculate_spend_spend_calculate_post: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
|
|
|||
|
|
@ -459,3 +459,60 @@ async def test_metadata_scan_requires_the_matching_managed_archive(
|
|||
else:
|
||||
assert isinstance(result, SubmissionConflict)
|
||||
assert await case.submissions.get_scan(submission.id) is None
|
||||
|
||||
|
||||
async def test_owner_edits_preserve_base_and_require_review_of_new_revision(pending_update: PendingUpdate) -> None:
|
||||
case: Final = pending_update
|
||||
before: Final = _published_rows(case.original.name)
|
||||
replacement: Final = _publication(case.original.name, "Edited proposal instructions")
|
||||
edited: Final = await case.submissions.edit(case.submission.id, case.submission.submitted_by, 0, replacement)
|
||||
assert isinstance(edited, SkillSubmission)
|
||||
assert (edited.revision, edited.submitted_by, edited.created_at, edited.base_updated_at) == (
|
||||
1, case.submission.submitted_by, case.submission.created_at, case.submission.base_updated_at,
|
||||
)
|
||||
assert await case.submissions.get_archive(edited.id, edited.submitted_by) == replacement.content
|
||||
assert _published_rows(case.original.name) == before
|
||||
for revision in (None, 0):
|
||||
stale: Final = await case.submissions.review(edited.id, "reviewer", True, None,
|
||||
publication_base_url="https://skills.example.test", expected_revision=revision)
|
||||
assert isinstance(stale, SubmissionConflict)
|
||||
reviewed: Final = await case.submissions.review(edited.id, "reviewer", True, None,
|
||||
publication_base_url="https://skills.example.test", expected_revision=edited.revision)
|
||||
assert isinstance(reviewed, SubmissionReviewed)
|
||||
current: Final = await case.publications.get_metadata(case.original.name)
|
||||
assert current is not None
|
||||
assert current.description == replacement.description
|
||||
assert await case.publications.get_content(current) == replacement.content
|
||||
|
||||
|
||||
@pytest.mark.parametrize("action", ["edit", "withdraw"])
|
||||
async def test_owner_mutation_racing_approval_has_one_winner(pending_update: PendingUpdate, action: str) -> None:
|
||||
case: Final = pending_update
|
||||
before: Final = _published_rows(case.original.name)
|
||||
with psycopg.connect(os.environ["DATABASE_URL"]) as blocker:
|
||||
blocker.execute('SELECT id FROM "LiteLLM_SkillSubmission" WHERE id=%s FOR UPDATE', (case.submission.id,))
|
||||
blocker_pid: Final = str(blocker.info.backend_pid)
|
||||
requests: Final = (
|
||||
asyncio.create_task(case.submissions.edit(case.submission.id, case.submission.submitted_by, 0,
|
||||
_publication(case.original.name, "Concurrent owner edit"))) if action == "edit" else
|
||||
asyncio.create_task(case.submissions.withdraw(case.submission.id, case.submission.submitted_by, 0)),
|
||||
asyncio.create_task(case.submissions.review(case.submission.id, "reviewer", True, None,
|
||||
publication_base_url="https://skills.example.test", expected_revision=0)),
|
||||
)
|
||||
try:
|
||||
await asyncio.to_thread(eventually, lambda: read_rows(
|
||||
"""WITH RECURSIVE blocked AS (
|
||||
SELECT %s::integer AS pid UNION SELECT a.pid FROM pg_stat_activity a
|
||||
JOIN blocked b ON b.pid = ANY(pg_blocking_pids(a.pid))
|
||||
) SELECT pid FROM blocked WHERE pid<>%s::integer""", (blocker_pid, blocker_pid)),
|
||||
lambda rows: len(rows) == 2, seconds=3)
|
||||
finally:
|
||||
blocker.rollback()
|
||||
results: Final = await asyncio.gather(*requests)
|
||||
assert sum(isinstance(result, SubmissionConflict) for result in results) == 1
|
||||
if isinstance(results[0], SkillSubmission):
|
||||
assert results[0].status == ("pending_review" if action == "edit" else "withdrawn")
|
||||
assert _published_rows(case.original.name) == before
|
||||
else:
|
||||
assert isinstance(results[1], SubmissionReviewed)
|
||||
assert (await case.submissions.get(case.submission.id, None)).status == "approved"
|
||||
|
|
|
|||
|
|
@ -704,6 +704,15 @@ async def test_member_updates_preserve_manifest_and_original_editor_base(pending
|
|||
assert detail["submission"]["proposal"]["domain"] == "Tools"
|
||||
assert detail["submission"]["proposal"]["keywords"] == ["notes"]
|
||||
assert detail["current_skill"]["namespace"] == "research"
|
||||
for version in ("1.0.0", original.version):
|
||||
invalid: Final = await hub.client.put(
|
||||
path + ("/upload" if replacement else ""), headers=_headers(MEMBER_KEY),
|
||||
**({"data": {"expected_revision": "0", "version": version},
|
||||
"files": {"file": ("new.zip", _zip(updated_files), "application/zip")}}
|
||||
if replacement else {"json": {"expected_revision": 0, "version": version}}),
|
||||
)
|
||||
assert invalid.status_code == 400, invalid.text
|
||||
assert (await hub.client.get(path, headers=_headers(MEMBER_KEY))).json() == detail
|
||||
assert hub.database.litellm_claudecodepluginversion.create.await_count == before
|
||||
hub.plugins.update.assert_not_awaited()
|
||||
approved: Final = await hub.client.put(path + "/approve", headers=_headers(MASTER_KEY))
|
||||
|
|
@ -1045,3 +1054,115 @@ async def test_restore_checks_the_snapshot_again_under_the_publication_lock(hub:
|
|||
)
|
||||
assert response.status_code == 409, response.text
|
||||
hub.plugins.update.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("key,expected", [(OTHER_KEY, 404), (MASTER_KEY, 404), (VIEWER_KEY, 403), (PLAIN_KEY, 403)])
|
||||
async def test_only_original_submitter_can_edit_or_withdraw(pending_hub: HubClient, key: str, expected: int) -> None:
|
||||
hub: Final = pending_hub
|
||||
created: Final = await _upload(hub, _zip(FILES), MEMBER_KEY)
|
||||
path: Final = f"/skill-hub/submissions/{created.json()['submission_id']}"
|
||||
for suffix in ("", "/upload", "/withdraw"):
|
||||
response: Final = await hub.client.put(
|
||||
path + suffix, headers=_headers(key),
|
||||
**({"data": {"expected_revision": "0"}, "files": {"file": ("edit.zip", _zip(FILES))}}
|
||||
if suffix == "/upload" else {"json": {"expected_revision": 0}}),
|
||||
)
|
||||
assert response.status_code == expected, response.text
|
||||
own: Final = (await hub.client.get(path, headers=_headers(MEMBER_KEY))).json()["submission"]
|
||||
assert (own["revision"], own["status"], own["can_edit"]) == (0, "pending_review", True)
|
||||
admin: Final = (await hub.client.get(path, headers=_headers(MASTER_KEY))).json()["submission"]
|
||||
assert admin["can_edit"] is False
|
||||
hub.plugins.create.assert_not_awaited()
|
||||
|
||||
|
||||
async def test_pending_edits_preserve_owner_and_files_until_fresh_review(pending_hub: HubClient) -> None:
|
||||
hub: Final = pending_hub
|
||||
created: Final = await _upload(hub, _zip(FILES), MEMBER_KEY)
|
||||
path: Final = f"/skill-hub/submissions/{created.json()['submission_id']}"
|
||||
before: Final = (await hub.client.get(path, headers=_headers(MEMBER_KEY))).json()["submission"]
|
||||
edited: Final = await hub.client.put(path, headers=_headers(MEMBER_KEY), json={
|
||||
"expected_revision": 0, "description": "Revised metadata", "version": "3.0.0", "keywords": ["review"],
|
||||
})
|
||||
assert edited.status_code == 200, edited.text
|
||||
result: Final = edited.json()
|
||||
assert (result["revision"], result["submitted_by"], result["created_at"], result["base_updated_at"]) == (
|
||||
1, before["submitted_by"], before["created_at"], before["base_updated_at"],
|
||||
)
|
||||
assert result["proposal"]["description"] == "Revised metadata"
|
||||
_assert_portable(await hub.client.get(path + "/archive", headers=_headers(MEMBER_KEY)))
|
||||
for body in ({}, {"expected_revision": 0}):
|
||||
stale: Final = await hub.client.put(path + "/approve", headers=_headers(MASTER_KEY), json=body or None)
|
||||
assert stale.status_code == 409, stale.text
|
||||
assert (await hub.client.put(path, headers=_headers(MEMBER_KEY), json={"expected_revision": 0})).status_code == 409
|
||||
hub.plugins.create.assert_not_awaited()
|
||||
approved: Final = await hub.client.put(path + "/approve", headers=_headers(MASTER_KEY), json={"expected_revision": 1})
|
||||
assert approved.status_code == 200, approved.text
|
||||
assert approved.json()["skill"]["description"] == "Revised metadata"
|
||||
assert approved.json()["skill"]["version"] == "3.0.0"
|
||||
for suffix in ("", "/withdraw"):
|
||||
assert (await hub.client.put(path + suffix, headers=_headers(MEMBER_KEY), json={"expected_revision": 1})).status_code == 409
|
||||
|
||||
|
||||
async def test_withdrawn_submission_cannot_be_edited_reviewed_or_published(pending_hub: HubClient) -> None:
|
||||
hub: Final = pending_hub
|
||||
created: Final = await _upload(hub, _zip(FILES), MEMBER_KEY)
|
||||
path: Final = f"/skill-hub/submissions/{created.json()['submission_id']}"
|
||||
withdrawn: Final = await hub.client.put(path + "/withdraw", headers=_headers(MEMBER_KEY), json={"expected_revision": 0})
|
||||
assert withdrawn.status_code == 200, withdrawn.text
|
||||
assert (withdrawn.json()["status"], withdrawn.json()["can_edit"], withdrawn.json()["reviewed_by"]) == ("withdrawn", False, None)
|
||||
assert withdrawn.json()["has_archive"] is False
|
||||
assert (await hub.client.get(path + "/archive", headers=_headers(MEMBER_KEY))).status_code == 404
|
||||
for suffix, key in (("", MEMBER_KEY), ("/withdraw", MEMBER_KEY), ("/approve", MASTER_KEY), ("/reject", MASTER_KEY)):
|
||||
response: Final = await hub.client.put(path + suffix, headers=_headers(key), json={"expected_revision": 0})
|
||||
assert response.status_code == 409, response.text
|
||||
listed: Final = await hub.client.get("/skill-hub/submissions?status=withdrawn", headers=_headers(MEMBER_KEY))
|
||||
assert listed.json()["count"] == 1
|
||||
hub.plugins.create.assert_not_awaited()
|
||||
|
||||
|
||||
async def test_pending_archive_edit_revalidates_and_clears_scan(pending_hub: HubClient) -> None:
|
||||
hub: Final = pending_hub
|
||||
created: Final = await _upload(hub, _zip(FILES), MEMBER_KEY)
|
||||
identifier: Final = created.json()["submission_id"]
|
||||
path: Final = f"/skill-hub/submissions/{identifier}"
|
||||
await hub.database.litellm_skillsubmission.update(where={"id": identifier}, data={"scan_json": "old scan"})
|
||||
invalid: Final = await hub.client.put(path + "/upload", headers=_headers(MEMBER_KEY),
|
||||
data={"expected_revision": "0"}, files={"file": ("invalid.zip", b"invalid")})
|
||||
assert invalid.status_code == 422, invalid.text
|
||||
updated_files: Final = {**FILES, "references/guide.md": b"Revised pending instructions.\n"}
|
||||
edited: Final = await hub.client.put(path + "/upload", headers=_headers(MEMBER_KEY),
|
||||
data={"expected_revision": "0", "version": "2.1.0"}, files={"file": ("edit.zip", _zip(updated_files))})
|
||||
assert edited.status_code == 200, edited.text
|
||||
assert edited.json()["revision"] == 1
|
||||
stored: Final = await hub.database.litellm_skillsubmission.find_unique(where={"id": identifier})
|
||||
assert stored.scan_json is None
|
||||
archive: Final = await hub.client.get(path + "/archive", headers=_headers(MEMBER_KEY))
|
||||
with zipfile.ZipFile(io.BytesIO(archive.content)) as content:
|
||||
assert content.read("references/guide.md") == updated_files["references/guide.md"]
|
||||
hub.plugins.create.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("published_version", (None, "legacy"))
|
||||
@pytest.mark.parametrize("replacement", (False, True), ids=("metadata", "archive"))
|
||||
async def test_pending_legacy_update_keeps_explicit_version_requirement(
|
||||
pending_hub: HubClient, published_version: str | None, replacement: bool,
|
||||
) -> None:
|
||||
hub: Final = pending_hub
|
||||
assert (await _upload(hub, _zip(FILES))).status_code == 200
|
||||
original: Final = _stored_upload(hub)
|
||||
hub.plugins.find_unique.return_value = original.model_copy(update={"version": published_version})
|
||||
created: Final = await hub.client.put(
|
||||
f"{HUB}/{NAME}", headers=_headers(MEMBER_KEY), json={"version": "3.0.0"},
|
||||
)
|
||||
assert created.status_code == 200, created.text
|
||||
path: Final = f"/skill-hub/submissions/{created.json()['submission_id']}"
|
||||
before: Final = (await hub.client.get(path, headers=_headers(MEMBER_KEY))).json()
|
||||
edited: Final = await hub.client.put(
|
||||
path + ("/upload" if replacement else ""), headers=_headers(MEMBER_KEY),
|
||||
**({"data": {"expected_revision": "0"}, "files": {"file": ("new.zip", _zip(FILES))}}
|
||||
if replacement else {"json": {"expected_revision": 0}}),
|
||||
)
|
||||
assert edited.status_code == 409, edited.text
|
||||
assert "explicit semantic version" in edited.json()["detail"]
|
||||
assert (await hub.client.get(path, headers=_headers(MEMBER_KEY))).json() == before
|
||||
hub.plugins.update.assert_not_awaited()
|
||||
|
|
|
|||
|
|
@ -255,6 +255,7 @@ def test_operator_request_size_limit_caps_the_decoded_body_when_it_is_lower(
|
|||
("POST", "/claude-code/plugins/upload"),
|
||||
("POST", "/skill-hub/skills/upload"),
|
||||
("PUT", "/skill-hub/skills/example/upload"),
|
||||
("PUT", "/skill-hub/submissions/absent/upload"),
|
||||
])
|
||||
def test_proxy_bounds_skill_uploads_before_authentication_or_body_parsing(
|
||||
coding: str, compress: Callable[[bytes], bytes], method: str, path: str,
|
||||
|
|
|
|||
|
|
@ -215,6 +215,7 @@ def test_an_oversized_messages_request_gets_the_anthropic_request_too_large_erro
|
|||
("POST", "/skill-hub/skills/upload", "/skill-hub/skills/upload"),
|
||||
("POST", "/chat/completions", "/chat/completions"),
|
||||
("PUT", "/skill-hub/skills/example/upload", "/skill-hub/skills/{name}/upload"),
|
||||
("PUT", "/skill-hub/submissions/absent/upload", "/skill-hub/submissions/{submission_id}/upload"),
|
||||
])
|
||||
async def test_chunked_upload_overflow_stops_fastapi_and_auth_multipart_parsing(
|
||||
method: str, path: str, route: str,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue