feat: implement skills marketplace API with CRUD operations and gateway integration
This commit is contained in:
committed by
Abhimanyu Saharan
parent
db510a8612
commit
e7b5df0bce
313
backend/app/api/skills_marketplace.py
Normal file
313
backend/app/api/skills_marketplace.py
Normal file
@@ -0,0 +1,313 @@
|
||||
"""Skills marketplace API for catalog management and gateway install actions."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
from urllib.parse import unquote, urlparse
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlmodel import col
|
||||
|
||||
from app.api.deps import require_org_admin
|
||||
from app.core.time import utcnow
|
||||
from app.db.session import get_session
|
||||
from app.models.gateway_installed_skills import GatewayInstalledSkill
|
||||
from app.models.gateways import Gateway
|
||||
from app.models.marketplace_skills import MarketplaceSkill
|
||||
from app.schemas.common import OkResponse
|
||||
from app.schemas.skills_marketplace import (
|
||||
MarketplaceSkillActionResponse,
|
||||
MarketplaceSkillCardRead,
|
||||
MarketplaceSkillCreate,
|
||||
MarketplaceSkillRead,
|
||||
)
|
||||
from app.services.openclaw.gateway_dispatch import GatewayDispatchService
|
||||
from app.services.openclaw.gateway_resolver import gateway_client_config, require_gateway_workspace_root
|
||||
from app.services.openclaw.gateway_rpc import OpenClawGatewayError
|
||||
from app.services.openclaw.shared import GatewayAgentIdentity
|
||||
from app.services.organizations import OrganizationContext
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
router = APIRouter(prefix="/skills", tags=["skills"])
|
||||
SESSION_DEP = Depends(get_session)
|
||||
ORG_ADMIN_DEP = Depends(require_org_admin)
|
||||
GATEWAY_ID_QUERY = Query(...)
|
||||
|
||||
|
||||
def _skills_install_dir(workspace_root: str) -> str:
|
||||
normalized = workspace_root.rstrip("/\\")
|
||||
if not normalized:
|
||||
return "skills"
|
||||
return f"{normalized}/skills"
|
||||
|
||||
|
||||
def _infer_skill_name(source_url: str) -> str:
|
||||
parsed = urlparse(source_url)
|
||||
path = parsed.path.rstrip("/")
|
||||
candidate = path.rsplit("/", maxsplit=1)[-1] if path else parsed.netloc
|
||||
candidate = unquote(candidate).removesuffix(".git").replace("-", " ").replace("_", " ")
|
||||
if candidate.strip():
|
||||
return candidate.strip()
|
||||
return "Skill"
|
||||
|
||||
|
||||
def _install_instruction(*, skill: MarketplaceSkill, gateway: Gateway) -> str:
|
||||
install_dir = _skills_install_dir(gateway.workspace_root)
|
||||
return (
|
||||
"MISSION CONTROL SKILL INSTALL REQUEST\n"
|
||||
f"Skill name: {skill.name}\n"
|
||||
f"Skill source URL: {skill.source_url}\n"
|
||||
f"Install destination: {install_dir}\n\n"
|
||||
"Actions:\n"
|
||||
"1. Ensure the install destination exists.\n"
|
||||
"2. Install or update the skill from the source URL into the destination.\n"
|
||||
"3. Verify the skill is discoverable by the runtime.\n"
|
||||
"4. Reply with success or failure details."
|
||||
)
|
||||
|
||||
|
||||
def _uninstall_instruction(*, skill: MarketplaceSkill, gateway: Gateway) -> str:
|
||||
install_dir = _skills_install_dir(gateway.workspace_root)
|
||||
return (
|
||||
"MISSION CONTROL SKILL UNINSTALL REQUEST\n"
|
||||
f"Skill name: {skill.name}\n"
|
||||
f"Skill source URL: {skill.source_url}\n"
|
||||
f"Install destination: {install_dir}\n\n"
|
||||
"Actions:\n"
|
||||
"1. Remove the skill assets previously installed from this source URL.\n"
|
||||
"2. Ensure the skill is no longer discoverable by the runtime.\n"
|
||||
"3. Reply with success or failure details."
|
||||
)
|
||||
|
||||
|
||||
def _as_card(
|
||||
*,
|
||||
skill: MarketplaceSkill,
|
||||
installation: GatewayInstalledSkill | None,
|
||||
) -> MarketplaceSkillCardRead:
|
||||
return MarketplaceSkillCardRead(
|
||||
id=skill.id,
|
||||
organization_id=skill.organization_id,
|
||||
name=skill.name,
|
||||
description=skill.description,
|
||||
source_url=skill.source_url,
|
||||
created_at=skill.created_at,
|
||||
updated_at=skill.updated_at,
|
||||
installed=installation is not None,
|
||||
installed_at=installation.created_at if installation is not None else None,
|
||||
)
|
||||
|
||||
|
||||
async def _require_gateway_for_org(
|
||||
*,
|
||||
gateway_id: UUID,
|
||||
session: AsyncSession,
|
||||
ctx: OrganizationContext,
|
||||
) -> Gateway:
|
||||
gateway = await Gateway.objects.by_id(gateway_id).first(session)
|
||||
if gateway is None or gateway.organization_id != ctx.organization.id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Gateway not found",
|
||||
)
|
||||
return gateway
|
||||
|
||||
|
||||
async def _require_marketplace_skill_for_org(
|
||||
*,
|
||||
skill_id: UUID,
|
||||
session: AsyncSession,
|
||||
ctx: OrganizationContext,
|
||||
) -> MarketplaceSkill:
|
||||
skill = await MarketplaceSkill.objects.by_id(skill_id).first(session)
|
||||
if skill is None or skill.organization_id != ctx.organization.id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Marketplace skill not found",
|
||||
)
|
||||
return skill
|
||||
|
||||
|
||||
async def _dispatch_gateway_instruction(
|
||||
*,
|
||||
session: AsyncSession,
|
||||
gateway: Gateway,
|
||||
message: str,
|
||||
) -> None:
|
||||
dispatch = GatewayDispatchService(session)
|
||||
config = gateway_client_config(gateway)
|
||||
session_key = GatewayAgentIdentity.session_key(gateway)
|
||||
await dispatch.send_agent_message(
|
||||
session_key=session_key,
|
||||
config=config,
|
||||
agent_name="Gateway Agent",
|
||||
message=message,
|
||||
deliver=True,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/marketplace", response_model=list[MarketplaceSkillCardRead])
|
||||
async def list_marketplace_skills(
|
||||
gateway_id: UUID = GATEWAY_ID_QUERY,
|
||||
session: AsyncSession = SESSION_DEP,
|
||||
ctx: OrganizationContext = ORG_ADMIN_DEP,
|
||||
) -> list[MarketplaceSkillCardRead]:
|
||||
"""List marketplace cards for an org and annotate install state for a gateway."""
|
||||
gateway = await _require_gateway_for_org(gateway_id=gateway_id, session=session, ctx=ctx)
|
||||
skills = (
|
||||
await MarketplaceSkill.objects.filter_by(organization_id=ctx.organization.id)
|
||||
.order_by(col(MarketplaceSkill.created_at).desc())
|
||||
.all(session)
|
||||
)
|
||||
installations = await GatewayInstalledSkill.objects.filter_by(gateway_id=gateway.id).all(session)
|
||||
installed_by_skill_id = {record.skill_id: record for record in installations}
|
||||
return [
|
||||
_as_card(skill=skill, installation=installed_by_skill_id.get(skill.id))
|
||||
for skill in skills
|
||||
]
|
||||
|
||||
|
||||
@router.post("/marketplace", response_model=MarketplaceSkillRead)
|
||||
async def create_marketplace_skill(
|
||||
payload: MarketplaceSkillCreate,
|
||||
session: AsyncSession = SESSION_DEP,
|
||||
ctx: OrganizationContext = ORG_ADMIN_DEP,
|
||||
) -> MarketplaceSkill:
|
||||
"""Register a skill source URL in the organization's marketplace catalog."""
|
||||
source_url = str(payload.source_url).strip()
|
||||
existing = await MarketplaceSkill.objects.filter_by(
|
||||
organization_id=ctx.organization.id,
|
||||
source_url=source_url,
|
||||
).first(session)
|
||||
if existing is not None:
|
||||
changed = False
|
||||
if payload.name and existing.name != payload.name:
|
||||
existing.name = payload.name
|
||||
changed = True
|
||||
if payload.description is not None and existing.description != payload.description:
|
||||
existing.description = payload.description
|
||||
changed = True
|
||||
if changed:
|
||||
existing.updated_at = utcnow()
|
||||
session.add(existing)
|
||||
await session.commit()
|
||||
await session.refresh(existing)
|
||||
return existing
|
||||
|
||||
skill = MarketplaceSkill(
|
||||
organization_id=ctx.organization.id,
|
||||
source_url=source_url,
|
||||
name=payload.name or _infer_skill_name(source_url),
|
||||
description=payload.description,
|
||||
)
|
||||
session.add(skill)
|
||||
await session.commit()
|
||||
await session.refresh(skill)
|
||||
return skill
|
||||
|
||||
|
||||
@router.delete("/marketplace/{skill_id}", response_model=OkResponse)
|
||||
async def delete_marketplace_skill(
|
||||
skill_id: UUID,
|
||||
session: AsyncSession = SESSION_DEP,
|
||||
ctx: OrganizationContext = ORG_ADMIN_DEP,
|
||||
) -> OkResponse:
|
||||
"""Delete a marketplace catalog entry and any install records that reference it."""
|
||||
skill = await _require_marketplace_skill_for_org(skill_id=skill_id, session=session, ctx=ctx)
|
||||
installations = await GatewayInstalledSkill.objects.filter_by(skill_id=skill.id).all(session)
|
||||
for installation in installations:
|
||||
await session.delete(installation)
|
||||
await session.delete(skill)
|
||||
await session.commit()
|
||||
return OkResponse()
|
||||
|
||||
|
||||
@router.post(
|
||||
"/marketplace/{skill_id}/install",
|
||||
response_model=MarketplaceSkillActionResponse,
|
||||
)
|
||||
async def install_marketplace_skill(
|
||||
skill_id: UUID,
|
||||
gateway_id: UUID = GATEWAY_ID_QUERY,
|
||||
session: AsyncSession = SESSION_DEP,
|
||||
ctx: OrganizationContext = ORG_ADMIN_DEP,
|
||||
) -> MarketplaceSkillActionResponse:
|
||||
"""Install a marketplace skill by dispatching instructions to the gateway agent."""
|
||||
gateway = await _require_gateway_for_org(gateway_id=gateway_id, session=session, ctx=ctx)
|
||||
require_gateway_workspace_root(gateway)
|
||||
skill = await _require_marketplace_skill_for_org(skill_id=skill_id, session=session, ctx=ctx)
|
||||
try:
|
||||
await _dispatch_gateway_instruction(
|
||||
session=session,
|
||||
gateway=gateway,
|
||||
message=_install_instruction(skill=skill, gateway=gateway),
|
||||
)
|
||||
except OpenClawGatewayError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail=str(exc),
|
||||
) from exc
|
||||
|
||||
installation = await GatewayInstalledSkill.objects.filter_by(
|
||||
gateway_id=gateway.id,
|
||||
skill_id=skill.id,
|
||||
).first(session)
|
||||
if installation is None:
|
||||
session.add(
|
||||
GatewayInstalledSkill(
|
||||
gateway_id=gateway.id,
|
||||
skill_id=skill.id,
|
||||
),
|
||||
)
|
||||
else:
|
||||
installation.updated_at = utcnow()
|
||||
session.add(installation)
|
||||
await session.commit()
|
||||
return MarketplaceSkillActionResponse(
|
||||
skill_id=skill.id,
|
||||
gateway_id=gateway.id,
|
||||
installed=True,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/marketplace/{skill_id}/uninstall",
|
||||
response_model=MarketplaceSkillActionResponse,
|
||||
)
|
||||
async def uninstall_marketplace_skill(
|
||||
skill_id: UUID,
|
||||
gateway_id: UUID = GATEWAY_ID_QUERY,
|
||||
session: AsyncSession = SESSION_DEP,
|
||||
ctx: OrganizationContext = ORG_ADMIN_DEP,
|
||||
) -> MarketplaceSkillActionResponse:
|
||||
"""Uninstall a marketplace skill by dispatching instructions to the gateway agent."""
|
||||
gateway = await _require_gateway_for_org(gateway_id=gateway_id, session=session, ctx=ctx)
|
||||
require_gateway_workspace_root(gateway)
|
||||
skill = await _require_marketplace_skill_for_org(skill_id=skill_id, session=session, ctx=ctx)
|
||||
try:
|
||||
await _dispatch_gateway_instruction(
|
||||
session=session,
|
||||
gateway=gateway,
|
||||
message=_uninstall_instruction(skill=skill, gateway=gateway),
|
||||
)
|
||||
except OpenClawGatewayError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail=str(exc),
|
||||
) from exc
|
||||
|
||||
installation = await GatewayInstalledSkill.objects.filter_by(
|
||||
gateway_id=gateway.id,
|
||||
skill_id=skill.id,
|
||||
).first(session)
|
||||
if installation is not None:
|
||||
await session.delete(installation)
|
||||
await session.commit()
|
||||
return MarketplaceSkillActionResponse(
|
||||
skill_id=skill.id,
|
||||
gateway_id=gateway.id,
|
||||
installed=False,
|
||||
)
|
||||
@@ -25,6 +25,7 @@ from app.api.gateways import router as gateways_router
|
||||
from app.api.metrics import router as metrics_router
|
||||
from app.api.organizations import router as organizations_router
|
||||
from app.api.souls_directory import router as souls_directory_router
|
||||
from app.api.skills_marketplace import router as skills_marketplace_router
|
||||
from app.api.tags import router as tags_router
|
||||
from app.api.task_custom_fields import router as task_custom_fields_router
|
||||
from app.api.tasks import router as tasks_router
|
||||
@@ -138,6 +139,7 @@ api_v1.include_router(gateways_router)
|
||||
api_v1.include_router(metrics_router)
|
||||
api_v1.include_router(organizations_router)
|
||||
api_v1.include_router(souls_directory_router)
|
||||
api_v1.include_router(skills_marketplace_router)
|
||||
api_v1.include_router(board_groups_router)
|
||||
api_v1.include_router(board_group_memory_router)
|
||||
api_v1.include_router(boards_router)
|
||||
|
||||
@@ -12,6 +12,8 @@ from app.models.board_webhook_payloads import BoardWebhookPayload
|
||||
from app.models.board_webhooks import BoardWebhook
|
||||
from app.models.boards import Board
|
||||
from app.models.gateways import Gateway
|
||||
from app.models.gateway_installed_skills import GatewayInstalledSkill
|
||||
from app.models.marketplace_skills import MarketplaceSkill
|
||||
from app.models.organization_board_access import OrganizationBoardAccess
|
||||
from app.models.organization_invite_board_access import OrganizationInviteBoardAccess
|
||||
from app.models.organization_invites import OrganizationInvite
|
||||
@@ -42,6 +44,8 @@ __all__ = [
|
||||
"BoardGroup",
|
||||
"Board",
|
||||
"Gateway",
|
||||
"GatewayInstalledSkill",
|
||||
"MarketplaceSkill",
|
||||
"Organization",
|
||||
"BoardTaskCustomField",
|
||||
"TaskCustomFieldDefinition",
|
||||
|
||||
33
backend/app/models/gateway_installed_skills.py
Normal file
33
backend/app/models/gateway_installed_skills.py
Normal file
@@ -0,0 +1,33 @@
|
||||
"""Gateway-to-skill installation state records."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from sqlalchemy import UniqueConstraint
|
||||
from sqlmodel import Field
|
||||
|
||||
from app.core.time import utcnow
|
||||
from app.models.base import QueryModel
|
||||
|
||||
RUNTIME_ANNOTATION_TYPES = (datetime,)
|
||||
|
||||
|
||||
class GatewayInstalledSkill(QueryModel, table=True):
|
||||
"""Marks that a marketplace skill is installed for a specific gateway."""
|
||||
|
||||
__tablename__ = "gateway_installed_skills" # pyright: ignore[reportAssignmentType]
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"gateway_id",
|
||||
"skill_id",
|
||||
name="uq_gateway_installed_skills_gateway_id_skill_id",
|
||||
),
|
||||
)
|
||||
|
||||
id: UUID = Field(default_factory=uuid4, primary_key=True)
|
||||
gateway_id: UUID = Field(foreign_key="gateways.id", index=True)
|
||||
skill_id: UUID = Field(foreign_key="marketplace_skills.id", index=True)
|
||||
created_at: datetime = Field(default_factory=utcnow)
|
||||
updated_at: datetime = Field(default_factory=utcnow)
|
||||
35
backend/app/models/marketplace_skills.py
Normal file
35
backend/app/models/marketplace_skills.py
Normal file
@@ -0,0 +1,35 @@
|
||||
"""Organization-scoped skill catalog entries for the skills marketplace."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from sqlalchemy import UniqueConstraint
|
||||
from sqlmodel import Field
|
||||
|
||||
from app.core.time import utcnow
|
||||
from app.models.tenancy import TenantScoped
|
||||
|
||||
RUNTIME_ANNOTATION_TYPES = (datetime,)
|
||||
|
||||
|
||||
class MarketplaceSkill(TenantScoped, table=True):
|
||||
"""A marketplace skill entry that can be installed onto one or more gateways."""
|
||||
|
||||
__tablename__ = "marketplace_skills" # pyright: ignore[reportAssignmentType]
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"organization_id",
|
||||
"source_url",
|
||||
name="uq_marketplace_skills_org_source_url",
|
||||
),
|
||||
)
|
||||
|
||||
id: UUID = Field(default_factory=uuid4, primary_key=True)
|
||||
organization_id: UUID = Field(foreign_key="organizations.id", index=True)
|
||||
name: str
|
||||
description: str | None = Field(default=None)
|
||||
source_url: str
|
||||
created_at: datetime = Field(default_factory=utcnow)
|
||||
updated_at: datetime = Field(default_factory=utcnow)
|
||||
@@ -33,6 +33,12 @@ from app.schemas.organizations import (
|
||||
OrganizationMemberUpdate,
|
||||
OrganizationRead,
|
||||
)
|
||||
from app.schemas.skills_marketplace import (
|
||||
MarketplaceSkillActionResponse,
|
||||
MarketplaceSkillCardRead,
|
||||
MarketplaceSkillCreate,
|
||||
MarketplaceSkillRead,
|
||||
)
|
||||
from app.schemas.souls_directory import (
|
||||
SoulsDirectoryMarkdownResponse,
|
||||
SoulsDirectorySearchResponse,
|
||||
@@ -83,6 +89,10 @@ __all__ = [
|
||||
"SoulsDirectoryMarkdownResponse",
|
||||
"SoulsDirectorySearchResponse",
|
||||
"SoulsDirectorySoulRef",
|
||||
"MarketplaceSkillActionResponse",
|
||||
"MarketplaceSkillCardRead",
|
||||
"MarketplaceSkillCreate",
|
||||
"MarketplaceSkillRead",
|
||||
"TagCreate",
|
||||
"TagRead",
|
||||
"TagRef",
|
||||
|
||||
49
backend/app/schemas/skills_marketplace.py
Normal file
49
backend/app/schemas/skills_marketplace.py
Normal file
@@ -0,0 +1,49 @@
|
||||
"""Schemas for skills marketplace listing and install/uninstall actions."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import AnyHttpUrl
|
||||
from sqlmodel import SQLModel
|
||||
|
||||
from app.schemas.common import NonEmptyStr
|
||||
|
||||
RUNTIME_ANNOTATION_TYPES = (datetime, UUID, NonEmptyStr)
|
||||
|
||||
|
||||
class MarketplaceSkillCreate(SQLModel):
|
||||
"""Payload used to register a skill URL in the organization marketplace."""
|
||||
|
||||
source_url: AnyHttpUrl
|
||||
name: NonEmptyStr | None = None
|
||||
description: str | None = None
|
||||
|
||||
|
||||
class MarketplaceSkillRead(SQLModel):
|
||||
"""Serialized marketplace skill catalog record."""
|
||||
|
||||
id: UUID
|
||||
organization_id: UUID
|
||||
name: str
|
||||
description: str | None = None
|
||||
source_url: str
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class MarketplaceSkillCardRead(MarketplaceSkillRead):
|
||||
"""Marketplace card payload with gateway-specific install state."""
|
||||
|
||||
installed: bool
|
||||
installed_at: datetime | None = None
|
||||
|
||||
|
||||
class MarketplaceSkillActionResponse(SQLModel):
|
||||
"""Install/uninstall action response payload."""
|
||||
|
||||
ok: bool = True
|
||||
skill_id: UUID
|
||||
gateway_id: UUID
|
||||
installed: bool
|
||||
Reference in New Issue
Block a user