feat(skills/notifications): 多租户阶段 2 — 路由 + 级联 + 启动迁移
后端工作:
deps.py
- 挂载 skill_store / notification_store 到 app.state
- 启动时 set_notification_store(...) 注入到 harness sender;
关闭时清空,保证测试和 embedded 模式不留状态
- 加 get_skill_store / get_notification_store FastAPI 依赖
routers/skills.py
- 加 _current_user_id / _is_admin_user / _get_owned_skill_or_raise /
_get_editable_skill_or_raise(对齐 routers/agents.py)
- SkillResponse 加 owner_user_id / published / builtin 字段,
_skill_to_response 接受 SkillStore 记录映射
- GET /api/skills 与 GET /api/skills/custom 改 per-user 可见性过滤
- POST /api/skills/install + /install-upload 预解析 archive 名校验
全局 name 不被他人持有;安装后 ensure_legacy 注册 owner=current
- DELETE /api/skills/custom/{name} 走 _get_editable_skill_or_raise,
调 cascade(owner 自删 = delete,admin 跨用户 = admin_takedown),
SkillStore.delete / delete_admin 同步清除 DB 记录
- 新增 PUT /api/skills/custom/{name}/published 切换发布状态;
从 published → unpublished 时触发 cascade(unpublish / admin_takedown)
skills/cascade.py(新)
- 扫所有 .deer-flow/agents/{id}/config.yaml,把指定 skill 从其 skills
数组移除,并通过 deerflow.persistence.notifications.notify 给受
影响 agent 的 owner 发站内信;admin 强制下架时另给 skill owner 发
一条
- 操作类型:unpublish / delete / admin_takedown,对应不同文案
routers/notifications.py(新)+ app.include_router
- GET /api/notifications(unread_only / limit)
- GET /api/notifications/unread_count(红点用)
- POST /api/notifications/{id}/read
- POST /api/notifications/read_all
- DELETE /api/notifications/{id}
app.py lifespan
- _sync_legacy_skills(app):每次启动扫 skills/custom/ 把没在 DB
的 skill 落库为 admin owner + published=True,幂等、非阻塞失败
- 调用顺序:_ensure_admin_user → _sync_legacy_skills → 启动 curator
测试:19 个旧用例继续通过;阶段 4 会写 test_skills_multitenancy.py
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
8162719e36
commit
cf191ef874
7 files changed
+675
-14
No files matched your search
@@ -30,6 +30,7 @@ from app.gateway.routers import (
|
|||||||
mcp,
|
mcp,
|
||||||
memory,
|
memory,
|
||||||
models,
|
models,
|
||||||
|
notifications,
|
||||||
runs,
|
runs,
|
||||||
scheduled_tasks,
|
scheduled_tasks,
|
||||||
skills,
|
skills,
|
||||||
@@ -152,6 +153,68 @@ async def _iter_store_items(store, namespace, *, page_size: int = 500):
|
|||||||
offset += page_size
|
offset += page_size
|
||||||
|
|
||||||
|
|
||||||
|
async def _sync_legacy_skills(app: FastAPI) -> None:
|
||||||
|
"""Idempotently register existing on-disk ``skills/custom/*`` directories
|
||||||
|
in the ``skills`` DB table, owned by the first admin and published=True.
|
||||||
|
|
||||||
|
Without this step, after upgrading to per-user skill ownership the existing
|
||||||
|
custom skill folders would not be visible to anyone (the SkillStore
|
||||||
|
visibility filter relies on a DB row). Running this once on every boot is
|
||||||
|
cheap (folder scan + one ``ensure_legacy`` per name) and idempotent.
|
||||||
|
|
||||||
|
Non-fatal: a failure here MUST NOT block server startup.
|
||||||
|
"""
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from deerflow.persistence.engine import get_session_factory
|
||||||
|
from deerflow.persistence.user.model import UserRow
|
||||||
|
from deerflow.skills.storage import get_or_new_skill_storage
|
||||||
|
from deerflow.skills.types import SkillCategory
|
||||||
|
|
||||||
|
sf = get_session_factory()
|
||||||
|
if sf is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
skill_store = getattr(app.state, "skill_store", None)
|
||||||
|
if skill_store is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
async with sf() as session:
|
||||||
|
stmt = select(UserRow).where(UserRow.system_role == "admin").limit(1)
|
||||||
|
row = (await session.execute(stmt)).scalar_one_or_none()
|
||||||
|
if row is None:
|
||||||
|
# No admin yet — leave ownership null; first admin is responsible for
|
||||||
|
# claiming via the UI once they exist.
|
||||||
|
admin_id: str | None = None
|
||||||
|
else:
|
||||||
|
admin_id = str(row.id)
|
||||||
|
|
||||||
|
config = getattr(app.state, "config", None)
|
||||||
|
storage = get_or_new_skill_storage(app_config=config)
|
||||||
|
try:
|
||||||
|
skills = storage.load_skills(enabled_only=False)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("_sync_legacy_skills: could not list on-disk skills")
|
||||||
|
return
|
||||||
|
|
||||||
|
inserted = 0
|
||||||
|
for skill in skills:
|
||||||
|
if skill.category != SkillCategory.CUSTOM:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
existing = await skill_store.get_any(skill.name)
|
||||||
|
if existing is not None:
|
||||||
|
continue
|
||||||
|
await skill_store.ensure_legacy(
|
||||||
|
{"name": skill.name, "owner_user_id": admin_id, "published": True}
|
||||||
|
)
|
||||||
|
inserted += 1
|
||||||
|
except Exception:
|
||||||
|
logger.warning("_sync_legacy_skills: could not register %s", skill.name, exc_info=True)
|
||||||
|
if inserted:
|
||||||
|
logger.info("Skill ownership migration: registered %d legacy custom skill(s)", inserted)
|
||||||
|
|
||||||
|
|
||||||
async def _migrate_orphaned_threads(store, admin_user_id: str) -> int:
|
async def _migrate_orphaned_threads(store, admin_user_id: str) -> int:
|
||||||
"""Migrate LangGraph store threads with no user_id to the given admin.
|
"""Migrate LangGraph store threads with no user_id to the given admin.
|
||||||
|
|
||||||
@@ -228,6 +291,13 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
|||||||
# Must run AFTER langgraph_runtime so app.state.store is available for thread migration
|
# Must run AFTER langgraph_runtime so app.state.store is available for thread migration
|
||||||
await _ensure_admin_user(app)
|
await _ensure_admin_user(app)
|
||||||
|
|
||||||
|
# Migrate "ownerless" on-disk skills into the new SkillStore table —
|
||||||
|
# makes the multitenancy switch transparent for existing deployments.
|
||||||
|
try:
|
||||||
|
await _sync_legacy_skills(app)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Skill ownership migration failed (non-fatal)")
|
||||||
|
|
||||||
# Kick off skill curator in background if the run interval has elapsed
|
# Kick off skill curator in background if the run interval has elapsed
|
||||||
try:
|
try:
|
||||||
from deerflow.skills.curator import maybe_run_curator
|
from deerflow.skills.curator import maybe_run_curator
|
||||||
@@ -510,6 +580,9 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
|
|||||||
# Feedback API is mounted at /api/threads/{thread_id}/runs/{run_id}/feedback
|
# Feedback API is mounted at /api/threads/{thread_id}/runs/{run_id}/feedback
|
||||||
app.include_router(feedback.router)
|
app.include_router(feedback.router)
|
||||||
|
|
||||||
|
# Notifications API is mounted at /api/notifications
|
||||||
|
app.include_router(notifications.router)
|
||||||
|
|
||||||
# Thread Runs API (LangGraph Platform-compatible runs lifecycle)
|
# Thread Runs API (LangGraph Platform-compatible runs lifecycle)
|
||||||
app.include_router(thread_runs.router)
|
app.include_router(thread_runs.router)
|
||||||
|
|
||||||
|
|||||||
@@ -25,7 +25,9 @@ if TYPE_CHECKING:
|
|||||||
from app.gateway.auth.local_provider import LocalAuthProvider
|
from app.gateway.auth.local_provider import LocalAuthProvider
|
||||||
from app.gateway.auth.repositories.sqlite import SQLiteUserRepository
|
from app.gateway.auth.repositories.sqlite import SQLiteUserRepository
|
||||||
from deerflow.persistence.agents import AgentStore
|
from deerflow.persistence.agents import AgentStore
|
||||||
|
from deerflow.persistence.notifications import NotificationStore
|
||||||
from deerflow.persistence.scheduled_tasks import ScheduledTaskStore
|
from deerflow.persistence.scheduled_tasks import ScheduledTaskStore
|
||||||
|
from deerflow.persistence.skills import SkillStore
|
||||||
from deerflow.persistence.thread_meta.base import ThreadMetaStore
|
from deerflow.persistence.thread_meta.base import ThreadMetaStore
|
||||||
|
|
||||||
|
|
||||||
@@ -94,6 +96,20 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
|
|||||||
|
|
||||||
app.state.agent_store = make_agent_store(sf)
|
app.state.agent_store = make_agent_store(sf)
|
||||||
|
|
||||||
|
from deerflow.persistence.skills import make_skill_store
|
||||||
|
|
||||||
|
app.state.skill_store = make_skill_store(sf)
|
||||||
|
|
||||||
|
from deerflow.persistence.notifications import (
|
||||||
|
make_notification_store,
|
||||||
|
set_notification_store,
|
||||||
|
)
|
||||||
|
|
||||||
|
app.state.notification_store = make_notification_store(sf)
|
||||||
|
# Make the sender helper (harness layer) able to write without going
|
||||||
|
# through FastAPI request context — used by cascade.py.
|
||||||
|
set_notification_store(app.state.notification_store)
|
||||||
|
|
||||||
# Run event store (has its own factory with config-driven backend selection)
|
# Run event store (has its own factory with config-driven backend selection)
|
||||||
run_events_config = getattr(config, "run_events", None)
|
run_events_config = getattr(config, "run_events", None)
|
||||||
app.state.run_event_store = make_run_event_store(run_events_config)
|
app.state.run_event_store = make_run_event_store(run_events_config)
|
||||||
@@ -104,6 +120,9 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
|
|||||||
try:
|
try:
|
||||||
yield
|
yield
|
||||||
finally:
|
finally:
|
||||||
|
from deerflow.persistence.notifications import set_notification_store
|
||||||
|
|
||||||
|
set_notification_store(None)
|
||||||
await close_engine()
|
await close_engine()
|
||||||
|
|
||||||
|
|
||||||
@@ -162,6 +181,22 @@ def get_agent_store(request: Request) -> AgentStore:
|
|||||||
return val
|
return val
|
||||||
|
|
||||||
|
|
||||||
|
def get_skill_store(request: Request) -> SkillStore:
|
||||||
|
"""Return the custom-skill store."""
|
||||||
|
val = getattr(request.app.state, "skill_store", None)
|
||||||
|
if val is None:
|
||||||
|
raise HTTPException(status_code=503, detail="Skill store not available")
|
||||||
|
return val
|
||||||
|
|
||||||
|
|
||||||
|
def get_notification_store(request: Request) -> NotificationStore:
|
||||||
|
"""Return the in-app notification store."""
|
||||||
|
val = getattr(request.app.state, "notification_store", None)
|
||||||
|
if val is None:
|
||||||
|
raise HTTPException(status_code=503, detail="Notification store not available")
|
||||||
|
return val
|
||||||
|
|
||||||
|
|
||||||
def get_run_context(request: Request) -> RunContext:
|
def get_run_context(request: Request) -> RunContext:
|
||||||
"""Build a :class:`RunContext` from ``app.state`` singletons.
|
"""Build a :class:`RunContext` from ``app.state`` singletons.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,152 @@
|
|||||||
|
"""In-app notification inbox API."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from app.gateway.deps import get_notification_store
|
||||||
|
from deerflow.persistence.notifications import NotificationStore
|
||||||
|
from deerflow.runtime.user_context import get_effective_user_id
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api", tags=["notifications"])
|
||||||
|
|
||||||
|
|
||||||
|
def _current_user_id(request: Request) -> str:
|
||||||
|
user = getattr(request.state, "user", None)
|
||||||
|
if user is not None:
|
||||||
|
return str(user.id)
|
||||||
|
return get_effective_user_id()
|
||||||
|
|
||||||
|
|
||||||
|
class NotificationItem(BaseModel):
|
||||||
|
id: str
|
||||||
|
user_id: str
|
||||||
|
type: str
|
||||||
|
title: str
|
||||||
|
body: str
|
||||||
|
payload: dict[str, Any]
|
||||||
|
read: bool
|
||||||
|
created_at: str | None
|
||||||
|
|
||||||
|
|
||||||
|
class NotificationListResponse(BaseModel):
|
||||||
|
items: list[NotificationItem]
|
||||||
|
|
||||||
|
|
||||||
|
class NotificationUnreadCountResponse(BaseModel):
|
||||||
|
unread: int = Field(..., description="Number of unread notifications for the current user")
|
||||||
|
|
||||||
|
|
||||||
|
class MarkReadResponse(BaseModel):
|
||||||
|
success: bool
|
||||||
|
|
||||||
|
|
||||||
|
class MarkAllReadResponse(BaseModel):
|
||||||
|
updated: int
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/notifications",
|
||||||
|
response_model=NotificationListResponse,
|
||||||
|
summary="List Notifications",
|
||||||
|
)
|
||||||
|
async def list_notifications(
|
||||||
|
request: Request,
|
||||||
|
unread_only: bool = Query(default=False),
|
||||||
|
limit: int = Query(default=50, ge=1, le=200),
|
||||||
|
store: NotificationStore = Depends(get_notification_store),
|
||||||
|
) -> NotificationListResponse:
|
||||||
|
try:
|
||||||
|
items = await store.list_for_user(
|
||||||
|
_current_user_id(request),
|
||||||
|
unread_only=unread_only,
|
||||||
|
limit=limit,
|
||||||
|
)
|
||||||
|
return NotificationListResponse(items=[NotificationItem(**item) for item in items])
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed to list notifications: %s", e, exc_info=True)
|
||||||
|
raise HTTPException(status_code=500, detail=f"Failed to list notifications: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/notifications/unread_count",
|
||||||
|
response_model=NotificationUnreadCountResponse,
|
||||||
|
summary="Unread Notification Count",
|
||||||
|
)
|
||||||
|
async def unread_count(
|
||||||
|
request: Request,
|
||||||
|
store: NotificationStore = Depends(get_notification_store),
|
||||||
|
) -> NotificationUnreadCountResponse:
|
||||||
|
try:
|
||||||
|
count = await store.unread_count(_current_user_id(request))
|
||||||
|
return NotificationUnreadCountResponse(unread=count)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed to read unread notification count: %s", e, exc_info=True)
|
||||||
|
raise HTTPException(status_code=500, detail=f"Failed to read unread count: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/notifications/{notification_id}/read",
|
||||||
|
response_model=MarkReadResponse,
|
||||||
|
summary="Mark Notification Read",
|
||||||
|
)
|
||||||
|
async def mark_read(
|
||||||
|
notification_id: str,
|
||||||
|
request: Request,
|
||||||
|
store: NotificationStore = Depends(get_notification_store),
|
||||||
|
) -> MarkReadResponse:
|
||||||
|
try:
|
||||||
|
ok = await store.mark_read(notification_id, _current_user_id(request))
|
||||||
|
if not ok:
|
||||||
|
raise HTTPException(status_code=404, detail="Notification not found")
|
||||||
|
return MarkReadResponse(success=True)
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed to mark notification read: %s", e, exc_info=True)
|
||||||
|
raise HTTPException(status_code=500, detail=f"Failed to mark read: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/notifications/read_all",
|
||||||
|
response_model=MarkAllReadResponse,
|
||||||
|
summary="Mark All Notifications Read",
|
||||||
|
)
|
||||||
|
async def mark_all_read(
|
||||||
|
request: Request,
|
||||||
|
store: NotificationStore = Depends(get_notification_store),
|
||||||
|
) -> MarkAllReadResponse:
|
||||||
|
try:
|
||||||
|
count = await store.mark_all_read(_current_user_id(request))
|
||||||
|
return MarkAllReadResponse(updated=count)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed to mark all notifications read: %s", e, exc_info=True)
|
||||||
|
raise HTTPException(status_code=500, detail=f"Failed to mark all read: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete(
|
||||||
|
"/notifications/{notification_id}",
|
||||||
|
response_model=MarkReadResponse,
|
||||||
|
summary="Delete Notification",
|
||||||
|
)
|
||||||
|
async def delete_notification(
|
||||||
|
notification_id: str,
|
||||||
|
request: Request,
|
||||||
|
store: NotificationStore = Depends(get_notification_store),
|
||||||
|
) -> MarkReadResponse:
|
||||||
|
try:
|
||||||
|
ok = await store.delete(notification_id, _current_user_id(request))
|
||||||
|
if not ok:
|
||||||
|
raise HTTPException(status_code=404, detail="Notification not found")
|
||||||
|
return MarkReadResponse(success=True)
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed to delete notification: %s", e, exc_info=True)
|
||||||
|
raise HTTPException(status_code=500, detail=f"Failed to delete: {e}")
|
||||||
@@ -3,16 +3,20 @@ import logging
|
|||||||
import tempfile
|
import tempfile
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import APIRouter, Body, Depends, File, HTTPException, UploadFile
|
from fastapi import APIRouter, Body, Depends, File, HTTPException, Request, UploadFile
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from app.gateway.deps import get_config
|
from app.gateway.deps import get_agent_store, get_config, get_skill_store
|
||||||
from app.gateway.path_utils import resolve_thread_virtual_path
|
from app.gateway.path_utils import resolve_thread_virtual_path
|
||||||
from deerflow.agents.lead_agent.prompt import refresh_skills_system_prompt_cache_async
|
from deerflow.agents.lead_agent.prompt import refresh_skills_system_prompt_cache_async
|
||||||
from deerflow.config.app_config import AppConfig
|
from deerflow.config.app_config import AppConfig
|
||||||
from deerflow.config.extensions_config import ExtensionsConfig, SkillStateConfig, get_extensions_config, reload_extensions_config
|
from deerflow.config.extensions_config import ExtensionsConfig, SkillStateConfig, get_extensions_config, reload_extensions_config
|
||||||
|
from deerflow.persistence.skills import SkillStore
|
||||||
|
from deerflow.runtime.user_context import get_effective_user_id
|
||||||
from deerflow.skills import Skill
|
from deerflow.skills import Skill
|
||||||
|
from deerflow.skills.cascade import cascade_skill_unpublished_or_deleted
|
||||||
from deerflow.skills.installer import SkillAlreadyExistsError
|
from deerflow.skills.installer import SkillAlreadyExistsError
|
||||||
from deerflow.skills.security_scanner import scan_skill_content
|
from deerflow.skills.security_scanner import scan_skill_content
|
||||||
from deerflow.skills.storage import get_or_new_skill_storage
|
from deerflow.skills.storage import get_or_new_skill_storage
|
||||||
@@ -23,6 +27,56 @@ logger = logging.getLogger(__name__)
|
|||||||
router = APIRouter(prefix="/api", tags=["skills"])
|
router = APIRouter(prefix="/api", tags=["skills"])
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Per-user authz helpers (mirrors routers/agents.py)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _current_user_id(request: Request) -> str:
|
||||||
|
user = getattr(request.state, "user", None)
|
||||||
|
if user is not None:
|
||||||
|
return str(user.id)
|
||||||
|
return get_effective_user_id()
|
||||||
|
|
||||||
|
|
||||||
|
def _is_admin_user(request: Request) -> bool:
|
||||||
|
user = getattr(request.state, "user", None)
|
||||||
|
return getattr(user, "system_role", None) == "admin"
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_owned_skill_or_raise(store: SkillStore, name: str, user_id: str) -> dict[str, Any]:
|
||||||
|
owned = await store.get_owned(name, user_id)
|
||||||
|
if owned is not None:
|
||||||
|
return owned
|
||||||
|
visible = await store.get_visible(name, user_id)
|
||||||
|
if visible is None:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Skill '{name}' not found")
|
||||||
|
raise HTTPException(status_code=403, detail="You can only modify skills that you own")
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_editable_skill_or_raise(
|
||||||
|
store: SkillStore,
|
||||||
|
name: str,
|
||||||
|
user_id: str,
|
||||||
|
*,
|
||||||
|
is_admin: bool,
|
||||||
|
) -> tuple[dict[str, Any], str]:
|
||||||
|
"""Return (record, edit_mode) where edit_mode is 'owner' or 'admin'.
|
||||||
|
|
||||||
|
Admins can edit/delete any skill (including others' published ones, since
|
||||||
|
the spec lets them force-takedown). Non-admins are limited to their own.
|
||||||
|
"""
|
||||||
|
owned = await store.get_owned(name, user_id)
|
||||||
|
if owned is not None:
|
||||||
|
return owned, "owner"
|
||||||
|
record = await store.get_any(name)
|
||||||
|
if record is None:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Skill '{name}' not found")
|
||||||
|
if is_admin:
|
||||||
|
return record, "admin"
|
||||||
|
raise HTTPException(status_code=403, detail="You can only modify skills that you own")
|
||||||
|
|
||||||
|
|
||||||
class SkillResponse(BaseModel):
|
class SkillResponse(BaseModel):
|
||||||
"""Response model for skill information."""
|
"""Response model for skill information."""
|
||||||
|
|
||||||
@@ -38,6 +92,9 @@ class SkillResponse(BaseModel):
|
|||||||
state: str = Field(default="active", description="Lifecycle state: active, stale, or archived")
|
state: str = Field(default="active", description="Lifecycle state: active, stale, or archived")
|
||||||
pinned: bool = Field(default=False, description="Whether this skill is pinned (excluded from curation)")
|
pinned: bool = Field(default=False, description="Whether this skill is pinned (excluded from curation)")
|
||||||
source: str = Field(default="upload", description="Origin: 'agent' for agent-created, 'upload' for user-uploaded")
|
source: str = Field(default="upload", description="Origin: 'agent' for agent-created, 'upload' for user-uploaded")
|
||||||
|
owner_user_id: str | None = Field(default=None, description="Custom skill owner (null = built-in or legacy)")
|
||||||
|
published: bool = Field(default=False, description="Whether the owner has made this skill visible to other users")
|
||||||
|
builtin: bool = Field(default=False, description="True when owner_user_id is null (no human owner)")
|
||||||
|
|
||||||
|
|
||||||
class SkillsListResponse(BaseModel):
|
class SkillsListResponse(BaseModel):
|
||||||
@@ -97,8 +154,14 @@ class SkillRollbackRequest(BaseModel):
|
|||||||
history_index: int = Field(default=-1, description="History entry index to restore from, defaulting to the latest change.")
|
history_index: int = Field(default=-1, description="History entry index to restore from, defaulting to the latest change.")
|
||||||
|
|
||||||
|
|
||||||
def _skill_to_response(skill: Skill) -> SkillResponse:
|
def _skill_to_response(skill: Skill, ownership: dict[str, Any] | None = None) -> SkillResponse:
|
||||||
"""Convert a Skill object to a SkillResponse."""
|
"""Convert a Skill object to a SkillResponse.
|
||||||
|
|
||||||
|
``ownership`` is the skill's record from ``SkillStore`` (or None for public
|
||||||
|
built-in skills which never have a DB row). When present, contributes the
|
||||||
|
``owner_user_id`` / ``published`` / ``builtin`` fields used for per-user
|
||||||
|
visibility and the frontend's owner badges.
|
||||||
|
"""
|
||||||
from deerflow.skills.usage import get_entry, latest_activity_at
|
from deerflow.skills.usage import get_entry, latest_activity_at
|
||||||
|
|
||||||
entry = get_entry(skill.name)
|
entry = get_entry(skill.name)
|
||||||
@@ -109,6 +172,15 @@ def _skill_to_response(skill: Skill) -> SkillResponse:
|
|||||||
last_at = datetime.fromisoformat(last_at_str)
|
last_at = datetime.fromisoformat(last_at_str)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
owner_user_id: str | None = None
|
||||||
|
published = False
|
||||||
|
builtin = skill.category != SkillCategory.CUSTOM
|
||||||
|
if ownership is not None:
|
||||||
|
owner_user_id = ownership.get("owner_user_id")
|
||||||
|
published = bool(ownership.get("published", False))
|
||||||
|
builtin = bool(ownership.get("builtin", owner_user_id is None))
|
||||||
|
|
||||||
return SkillResponse(
|
return SkillResponse(
|
||||||
name=skill.name,
|
name=skill.name,
|
||||||
description=skill.description,
|
description=skill.description,
|
||||||
@@ -122,19 +194,45 @@ def _skill_to_response(skill: Skill) -> SkillResponse:
|
|||||||
state=entry.get("state", "active"),
|
state=entry.get("state", "active"),
|
||||||
pinned=entry.get("pinned", False),
|
pinned=entry.get("pinned", False),
|
||||||
source=entry.get("source", "upload"),
|
source=entry.get("source", "upload"),
|
||||||
|
owner_user_id=owner_user_id,
|
||||||
|
published=published,
|
||||||
|
builtin=builtin,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _build_ownership_index(skill_store: SkillStore, user_id: str) -> dict[str, dict[str, Any]]:
|
||||||
|
"""Index visible-to-user skill records by name. Internal call cache for list endpoints."""
|
||||||
|
records = await skill_store.list_visible(user_id)
|
||||||
|
return {record["name"]: record for record in records}
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/skills",
|
"/skills",
|
||||||
response_model=SkillsListResponse,
|
response_model=SkillsListResponse,
|
||||||
summary="List All Skills",
|
summary="List All Skills",
|
||||||
description="Retrieve a list of all available skills from both public and custom directories.",
|
description="Retrieve a list of all available skills from both public and custom directories.",
|
||||||
)
|
)
|
||||||
async def list_skills(config: AppConfig = Depends(get_config)) -> SkillsListResponse:
|
async def list_skills(
|
||||||
|
request: Request,
|
||||||
|
config: AppConfig = Depends(get_config),
|
||||||
|
skill_store: SkillStore = Depends(get_skill_store),
|
||||||
|
) -> SkillsListResponse:
|
||||||
try:
|
try:
|
||||||
skills = get_or_new_skill_storage(app_config=config).load_skills(enabled_only=False)
|
user_id = _current_user_id(request)
|
||||||
return SkillsListResponse(skills=[_skill_to_response(skill) for skill in skills])
|
all_skills = get_or_new_skill_storage(app_config=config).load_skills(enabled_only=False)
|
||||||
|
ownership = await _build_ownership_index(skill_store, user_id)
|
||||||
|
responses: list[SkillResponse] = []
|
||||||
|
for skill in all_skills:
|
||||||
|
# Public/built-in skills (no DB row) are visible to everyone.
|
||||||
|
# Custom skills must have an ownership record in the visible set.
|
||||||
|
if skill.category != SkillCategory.CUSTOM:
|
||||||
|
responses.append(_skill_to_response(skill))
|
||||||
|
continue
|
||||||
|
record = ownership.get(skill.name)
|
||||||
|
if record is None:
|
||||||
|
continue
|
||||||
|
responses.append(_skill_to_response(skill, record))
|
||||||
|
return SkillsListResponse(skills=responses)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to load skills: {e}", exc_info=True)
|
logger.error(f"Failed to load skills: {e}", exc_info=True)
|
||||||
raise HTTPException(status_code=500, detail=f"Failed to load skills: {str(e)}")
|
raise HTTPException(status_code=500, detail=f"Failed to load skills: {str(e)}")
|
||||||
@@ -219,19 +317,43 @@ async def validate_skill_upload(
|
|||||||
description="Install a skill directly from an uploaded .zip/.skill archive. Set ?overwrite=true to replace an existing skill with the same name.",
|
description="Install a skill directly from an uploaded .zip/.skill archive. Set ?overwrite=true to replace an existing skill with the same name.",
|
||||||
)
|
)
|
||||||
async def install_skill_upload(
|
async def install_skill_upload(
|
||||||
|
request: Request,
|
||||||
file: UploadFile = File(..., description="The .zip or .skill archive to install"),
|
file: UploadFile = File(..., description="The .zip or .skill archive to install"),
|
||||||
overwrite: bool = False,
|
overwrite: bool = False,
|
||||||
config: AppConfig = Depends(get_config),
|
config: AppConfig = Depends(get_config),
|
||||||
|
skill_store: SkillStore = Depends(get_skill_store),
|
||||||
) -> SkillInstallResponse:
|
) -> SkillInstallResponse:
|
||||||
|
user_id = _current_user_id(request)
|
||||||
tmp_path = await _persist_uploaded_skill(file)
|
tmp_path = await _persist_uploaded_skill(file)
|
||||||
try:
|
try:
|
||||||
storage = get_or_new_skill_storage(app_config=config)
|
storage = get_or_new_skill_storage(app_config=config)
|
||||||
|
# Pre-parse the archive to learn the skill name without writing anything,
|
||||||
|
# so we can reject the upload before touching the filesystem if another
|
||||||
|
# user already owns this name.
|
||||||
|
try:
|
||||||
|
validation = storage.validate_skill_archive(tmp_path)
|
||||||
|
preview_name = validation.get("skill_name") if isinstance(validation, dict) else None
|
||||||
|
except Exception:
|
||||||
|
preview_name = None
|
||||||
|
if preview_name:
|
||||||
|
existing = await skill_store.get_any(preview_name)
|
||||||
|
if existing and existing.get("owner_user_id") not in (None, user_id):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=409,
|
||||||
|
detail=f"Skill '{preview_name}' already exists and is owned by another user",
|
||||||
|
)
|
||||||
result = await storage.ainstall_skill_from_archive(tmp_path, overwrite=overwrite, require_skill_suffix=False)
|
result = await storage.ainstall_skill_from_archive(tmp_path, overwrite=overwrite, require_skill_suffix=False)
|
||||||
try:
|
try:
|
||||||
from deerflow.skills.usage import mark_uploaded
|
from deerflow.skills.usage import mark_uploaded
|
||||||
mark_uploaded(result["skill_name"])
|
mark_uploaded(result["skill_name"])
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
# Register ownership in the SkillStore. ``ensure_legacy`` is idempotent
|
||||||
|
# and only patches a NULL owner; it never overwrites an existing owner,
|
||||||
|
# so the pre-parse check above is what really guards cross-user steals.
|
||||||
|
await skill_store.ensure_legacy(
|
||||||
|
{"name": result["skill_name"], "owner_user_id": user_id, "published": False}
|
||||||
|
)
|
||||||
await refresh_skills_system_prompt_cache_async()
|
await refresh_skills_system_prompt_cache_async()
|
||||||
return SkillInstallResponse(**result)
|
return SkillInstallResponse(**result)
|
||||||
except FileNotFoundError as e:
|
except FileNotFoundError as e:
|
||||||
@@ -255,15 +377,24 @@ async def install_skill_upload(
|
|||||||
summary="Install Skill",
|
summary="Install Skill",
|
||||||
description="Install a skill from a .skill file (ZIP archive) located in the thread's user-data directory.",
|
description="Install a skill from a .skill file (ZIP archive) located in the thread's user-data directory.",
|
||||||
)
|
)
|
||||||
async def install_skill(request: SkillInstallRequest, config: AppConfig = Depends(get_config)) -> SkillInstallResponse:
|
async def install_skill(
|
||||||
|
body: SkillInstallRequest,
|
||||||
|
request: Request,
|
||||||
|
config: AppConfig = Depends(get_config),
|
||||||
|
skill_store: SkillStore = Depends(get_skill_store),
|
||||||
|
) -> SkillInstallResponse:
|
||||||
try:
|
try:
|
||||||
skill_file_path = resolve_thread_virtual_path(request.thread_id, request.path)
|
user_id = _current_user_id(request)
|
||||||
|
skill_file_path = resolve_thread_virtual_path(body.thread_id, body.path)
|
||||||
result = await get_or_new_skill_storage(app_config=config).ainstall_skill_from_archive(skill_file_path)
|
result = await get_or_new_skill_storage(app_config=config).ainstall_skill_from_archive(skill_file_path)
|
||||||
try:
|
try:
|
||||||
from deerflow.skills.usage import mark_uploaded
|
from deerflow.skills.usage import mark_uploaded
|
||||||
mark_uploaded(result["skill_name"])
|
mark_uploaded(result["skill_name"])
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
await skill_store.ensure_legacy(
|
||||||
|
{"name": result["skill_name"], "owner_user_id": user_id, "published": False}
|
||||||
|
)
|
||||||
await refresh_skills_system_prompt_cache_async()
|
await refresh_skills_system_prompt_cache_async()
|
||||||
return SkillInstallResponse(**result)
|
return SkillInstallResponse(**result)
|
||||||
except FileNotFoundError as e:
|
except FileNotFoundError as e:
|
||||||
@@ -280,10 +411,21 @@ async def install_skill(request: SkillInstallRequest, config: AppConfig = Depend
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/skills/custom", response_model=SkillsListResponse, summary="List Custom Skills")
|
@router.get("/skills/custom", response_model=SkillsListResponse, summary="List Custom Skills")
|
||||||
async def list_custom_skills(config: AppConfig = Depends(get_config)) -> SkillsListResponse:
|
async def list_custom_skills(
|
||||||
|
request: Request,
|
||||||
|
config: AppConfig = Depends(get_config),
|
||||||
|
skill_store: SkillStore = Depends(get_skill_store),
|
||||||
|
) -> SkillsListResponse:
|
||||||
try:
|
try:
|
||||||
skills = [skill for skill in get_or_new_skill_storage(app_config=config).load_skills(enabled_only=False) if skill.category == SkillCategory.CUSTOM]
|
user_id = _current_user_id(request)
|
||||||
return SkillsListResponse(skills=[_skill_to_response(skill) for skill in skills])
|
custom = [s for s in get_or_new_skill_storage(app_config=config).load_skills(enabled_only=False) if s.category == SkillCategory.CUSTOM]
|
||||||
|
ownership = await _build_ownership_index(skill_store, user_id)
|
||||||
|
responses = [
|
||||||
|
_skill_to_response(skill, ownership[skill.name])
|
||||||
|
for skill in custom
|
||||||
|
if skill.name in ownership
|
||||||
|
]
|
||||||
|
return SkillsListResponse(skills=responses)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("Failed to list custom skills: %s", e, exc_info=True)
|
logger.error("Failed to list custom skills: %s", e, exc_info=True)
|
||||||
raise HTTPException(status_code=500, detail=f"Failed to list custom skills: {str(e)}")
|
raise HTTPException(status_code=500, detail=f"Failed to list custom skills: {str(e)}")
|
||||||
@@ -348,9 +490,20 @@ async def update_custom_skill(skill_name: str, request: CustomSkillUpdateRequest
|
|||||||
|
|
||||||
|
|
||||||
@router.delete("/skills/custom/{skill_name}", summary="Delete Custom Skill")
|
@router.delete("/skills/custom/{skill_name}", summary="Delete Custom Skill")
|
||||||
async def delete_custom_skill(skill_name: str, config: AppConfig = Depends(get_config)) -> dict[str, bool]:
|
async def delete_custom_skill(
|
||||||
|
skill_name: str,
|
||||||
|
request: Request,
|
||||||
|
config: AppConfig = Depends(get_config),
|
||||||
|
skill_store: SkillStore = Depends(get_skill_store),
|
||||||
|
agent_store=Depends(get_agent_store),
|
||||||
|
) -> dict[str, bool]:
|
||||||
try:
|
try:
|
||||||
skill_name = skill_name.replace("\r\n", "").replace("\n", "")
|
skill_name = skill_name.replace("\r\n", "").replace("\n", "")
|
||||||
|
user_id = _current_user_id(request)
|
||||||
|
is_admin = _is_admin_user(request)
|
||||||
|
record, mode = await _get_editable_skill_or_raise(skill_store, skill_name, user_id, is_admin=is_admin)
|
||||||
|
operation = "admin_takedown" if mode == "admin" and record.get("owner_user_id") not in (None, user_id) else "delete"
|
||||||
|
|
||||||
storage = get_or_new_skill_storage(app_config=config)
|
storage = get_or_new_skill_storage(app_config=config)
|
||||||
storage.delete_custom_skill(
|
storage.delete_custom_skill(
|
||||||
skill_name,
|
skill_name,
|
||||||
@@ -369,8 +522,27 @@ async def delete_custom_skill(skill_name: str, config: AppConfig = Depends(get_c
|
|||||||
forget(skill_name)
|
forget(skill_name)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
# Persist ownership record removal so the DB stays consistent.
|
||||||
|
if mode == "admin":
|
||||||
|
await skill_store.delete_admin(skill_name)
|
||||||
|
else:
|
||||||
|
await skill_store.delete(skill_name, user_id)
|
||||||
|
|
||||||
|
# Strip references from every agent that pulled this skill in,
|
||||||
|
# and notify their owners (no-op for self).
|
||||||
|
await cascade_skill_unpublished_or_deleted(
|
||||||
|
skill_name,
|
||||||
|
actor_user_id=user_id,
|
||||||
|
operation=operation,
|
||||||
|
skill_owner_user_id=record.get("owner_user_id"),
|
||||||
|
agent_store=agent_store,
|
||||||
|
)
|
||||||
|
|
||||||
await refresh_skills_system_prompt_cache_async()
|
await refresh_skills_system_prompt_cache_async()
|
||||||
return {"success": True}
|
return {"success": True}
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
except FileNotFoundError as e:
|
except FileNotFoundError as e:
|
||||||
raise HTTPException(status_code=404, detail=str(e))
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
@@ -380,6 +552,71 @@ async def delete_custom_skill(skill_name: str, config: AppConfig = Depends(get_c
|
|||||||
raise HTTPException(status_code=500, detail=f"Failed to delete custom skill: {str(e)}")
|
raise HTTPException(status_code=500, detail=f"Failed to delete custom skill: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
class SkillPublishRequest(BaseModel):
|
||||||
|
"""Request body for toggling a custom skill's published flag."""
|
||||||
|
|
||||||
|
published: bool = Field(..., description="Whether to publish (True) or unpublish (False)")
|
||||||
|
|
||||||
|
|
||||||
|
class SkillPublishResponse(BaseModel):
|
||||||
|
name: str
|
||||||
|
owner_user_id: str | None
|
||||||
|
published: bool
|
||||||
|
|
||||||
|
|
||||||
|
@router.put(
|
||||||
|
"/skills/custom/{skill_name}/published",
|
||||||
|
response_model=SkillPublishResponse,
|
||||||
|
summary="Toggle Custom Skill Published State",
|
||||||
|
)
|
||||||
|
async def update_custom_skill_published(
|
||||||
|
skill_name: str,
|
||||||
|
body: SkillPublishRequest,
|
||||||
|
request: Request,
|
||||||
|
skill_store: SkillStore = Depends(get_skill_store),
|
||||||
|
agent_store=Depends(get_agent_store),
|
||||||
|
) -> SkillPublishResponse:
|
||||||
|
try:
|
||||||
|
skill_name = skill_name.replace("\r\n", "").replace("\n", "")
|
||||||
|
user_id = _current_user_id(request)
|
||||||
|
is_admin = _is_admin_user(request)
|
||||||
|
record, mode = await _get_editable_skill_or_raise(skill_store, skill_name, user_id, is_admin=is_admin)
|
||||||
|
|
||||||
|
was_published = bool(record.get("published", False))
|
||||||
|
if mode == "admin":
|
||||||
|
updated = await skill_store.update_admin(skill_name, {"published": body.published})
|
||||||
|
else:
|
||||||
|
updated = await skill_store.update(skill_name, user_id, {"published": body.published})
|
||||||
|
if updated is None:
|
||||||
|
raise HTTPException(status_code=500, detail="Failed to persist published state")
|
||||||
|
|
||||||
|
# If we are *removing* visibility (was published, now not), strip from
|
||||||
|
# other users' agents and notify them. admin takedown is signaled by
|
||||||
|
# the actor not being the owner.
|
||||||
|
if was_published and not body.published:
|
||||||
|
skill_owner = record.get("owner_user_id")
|
||||||
|
operation = "admin_takedown" if mode == "admin" and skill_owner not in (None, user_id) else "unpublish"
|
||||||
|
await cascade_skill_unpublished_or_deleted(
|
||||||
|
skill_name,
|
||||||
|
actor_user_id=user_id,
|
||||||
|
operation=operation,
|
||||||
|
skill_owner_user_id=skill_owner,
|
||||||
|
agent_store=agent_store,
|
||||||
|
)
|
||||||
|
await refresh_skills_system_prompt_cache_async()
|
||||||
|
|
||||||
|
return SkillPublishResponse(
|
||||||
|
name=updated["name"],
|
||||||
|
owner_user_id=updated.get("owner_user_id"),
|
||||||
|
published=bool(updated.get("published", False)),
|
||||||
|
)
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed to update published state for %s: %s", skill_name, e, exc_info=True)
|
||||||
|
raise HTTPException(status_code=500, detail=f"Failed to update published state: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
@router.get("/skills/custom/{skill_name}/history", response_model=CustomSkillHistoryResponse, summary="Get Custom Skill History")
|
@router.get("/skills/custom/{skill_name}/history", response_model=CustomSkillHistoryResponse, summary="Get Custom Skill History")
|
||||||
async def get_custom_skill_history(skill_name: str, config: AppConfig = Depends(get_config)) -> CustomSkillHistoryResponse:
|
async def get_custom_skill_history(skill_name: str, config: AppConfig = Depends(get_config)) -> CustomSkillHistoryResponse:
|
||||||
try:
|
try:
|
||||||
|
|||||||
+7
@@ -16,4 +16,11 @@ __all__ = [
|
|||||||
"notify",
|
"notify",
|
||||||
"set_notification_store",
|
"set_notification_store",
|
||||||
"get_notification_store",
|
"get_notification_store",
|
||||||
|
"make_notification_store",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def make_notification_store(session_factory):
|
||||||
|
if session_factory is None:
|
||||||
|
return MemoryNotificationStore()
|
||||||
|
return NotificationRepository(session_factory)
|
||||||
+7
-1
@@ -4,4 +4,10 @@ from deerflow.persistence.skills.base import SkillStore
|
|||||||
from deerflow.persistence.skills.memory import MemorySkillStore
|
from deerflow.persistence.skills.memory import MemorySkillStore
|
||||||
from deerflow.persistence.skills.sql import SkillRepository
|
from deerflow.persistence.skills.sql import SkillRepository
|
||||||
|
|
||||||
__all__ = ["SkillStore", "MemorySkillStore", "SkillRepository"]
|
__all__ = ["SkillStore", "MemorySkillStore", "SkillRepository", "make_skill_store"]
|
||||||
|
|
||||||
|
|
||||||
|
def make_skill_store(session_factory):
|
||||||
|
if session_factory is None:
|
||||||
|
return MemorySkillStore()
|
||||||
|
return SkillRepository(session_factory)
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
"""Cascade handling when a published skill is removed from circulation.
|
||||||
|
|
||||||
|
Called from the Gateway skills router whenever a published skill is
|
||||||
|
unpublished, deleted, or admin-taken-down. Walks every agent on disk that
|
||||||
|
referenced the skill, strips the skill from its ``config.yaml`` ``skills``
|
||||||
|
list, and emits an in-app notification to that agent's owner.
|
||||||
|
|
||||||
|
The harness/app firewall means we cannot import the FastAPI app side; the
|
||||||
|
``AgentStore`` instance must be passed in by the caller (the router gets
|
||||||
|
it from ``app.gateway.deps.get_agent_store``).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Literal
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
from deerflow.config.agents_config import load_agent_config
|
||||||
|
from deerflow.config.paths import get_paths
|
||||||
|
from deerflow.persistence.notifications import notify
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
CascadeOperation = Literal["unpublish", "delete", "admin_takedown"]
|
||||||
|
|
||||||
|
|
||||||
|
def _list_agent_dirs() -> list[Path]:
|
||||||
|
agents_dir = get_paths().agents_dir
|
||||||
|
if not agents_dir.exists():
|
||||||
|
return []
|
||||||
|
return [entry for entry in sorted(agents_dir.iterdir()) if entry.is_dir()]
|
||||||
|
|
||||||
|
|
||||||
|
def _remove_skill_from_yaml(config_file: Path, skill_name: str) -> bool:
|
||||||
|
"""Rewrite ``config.yaml`` removing ``skill_name`` from its ``skills`` list.
|
||||||
|
|
||||||
|
Returns True if the file was modified, False otherwise.
|
||||||
|
"""
|
||||||
|
if not config_file.exists():
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
with config_file.open("r", encoding="utf-8") as f:
|
||||||
|
data: dict[str, Any] = yaml.safe_load(f) or {}
|
||||||
|
except yaml.YAMLError:
|
||||||
|
logger.warning("Cascade: failed to parse %s, skipping", config_file)
|
||||||
|
return False
|
||||||
|
|
||||||
|
skills = data.get("skills")
|
||||||
|
if not isinstance(skills, list) or skill_name not in skills:
|
||||||
|
return False
|
||||||
|
|
||||||
|
data["skills"] = [s for s in skills if s != skill_name]
|
||||||
|
with config_file.open("w", encoding="utf-8") as f:
|
||||||
|
yaml.safe_dump(data, f, default_flow_style=False, allow_unicode=True)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
_OP_LABELS: dict[CascadeOperation, str] = {
|
||||||
|
"unpublish": "取消发布",
|
||||||
|
"delete": "删除",
|
||||||
|
"admin_takedown": "管理员强制下架",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def cascade_skill_unpublished_or_deleted(
|
||||||
|
skill_name: str,
|
||||||
|
*,
|
||||||
|
actor_user_id: str,
|
||||||
|
operation: CascadeOperation,
|
||||||
|
skill_owner_user_id: str | None,
|
||||||
|
agent_store: Any,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Detach ``skill_name`` from every referencing agent and notify owners.
|
||||||
|
|
||||||
|
Self-references (agent.user_id == actor_user_id) are still detached but
|
||||||
|
the owner gets no in-app notification — the user just did the action,
|
||||||
|
they know.
|
||||||
|
|
||||||
|
On ``admin_takedown`` we additionally notify ``skill_owner_user_id``.
|
||||||
|
|
||||||
|
Returns counters for testability: ``{checked, modified, notified}``.
|
||||||
|
"""
|
||||||
|
counters = {"checked": 0, "modified": 0, "notified": 0}
|
||||||
|
label = _OP_LABELS.get(operation, operation)
|
||||||
|
|
||||||
|
for agent_dir in _list_agent_dirs():
|
||||||
|
config_file = agent_dir / "config.yaml"
|
||||||
|
if not config_file.exists():
|
||||||
|
continue
|
||||||
|
counters["checked"] += 1
|
||||||
|
|
||||||
|
# Quick read to decide whether we need to mutate at all.
|
||||||
|
try:
|
||||||
|
agent_cfg = load_agent_config(agent_dir.name)
|
||||||
|
except Exception:
|
||||||
|
logger.debug("Cascade: failed to load %s, skipping", agent_dir.name, exc_info=True)
|
||||||
|
continue
|
||||||
|
if agent_cfg is None or not agent_cfg.skills or skill_name not in agent_cfg.skills:
|
||||||
|
continue
|
||||||
|
|
||||||
|
modified = _remove_skill_from_yaml(config_file, skill_name)
|
||||||
|
if not modified:
|
||||||
|
continue
|
||||||
|
counters["modified"] += 1
|
||||||
|
logger.info(
|
||||||
|
"Cascade: removed skill '%s' from agent '%s/config.yaml' (op=%s)",
|
||||||
|
skill_name,
|
||||||
|
agent_dir.name,
|
||||||
|
operation,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Look up agent owner from DB to decide where to send the notification.
|
||||||
|
record = await agent_store.get_any(agent_dir.name)
|
||||||
|
agent_owner = record.get("user_id") if record else None
|
||||||
|
if agent_owner and agent_owner != actor_user_id:
|
||||||
|
agent_display = (record.get("name") if record else None) or agent_dir.name
|
||||||
|
await notify(
|
||||||
|
agent_owner,
|
||||||
|
type="agent_skill_removed",
|
||||||
|
title=f"专家 “{agent_display}” 的一个技能已失效",
|
||||||
|
body=(
|
||||||
|
f"技能 “{skill_name}” 被 {label},已从该专家的技能列表中自动移除。"
|
||||||
|
),
|
||||||
|
payload={
|
||||||
|
"skill_name": skill_name,
|
||||||
|
"agent_id": agent_dir.name,
|
||||||
|
"agent_name": agent_display,
|
||||||
|
"operation": operation,
|
||||||
|
"actor_user_id": actor_user_id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
counters["notified"] += 1
|
||||||
|
|
||||||
|
if operation == "admin_takedown" and skill_owner_user_id and skill_owner_user_id != actor_user_id:
|
||||||
|
await notify(
|
||||||
|
skill_owner_user_id,
|
||||||
|
type="skill_taken_down",
|
||||||
|
title=f"你的技能 “{skill_name}” 已被管理员下架",
|
||||||
|
body="管理员对该技能执行了强制下架。如有疑问请联系管理员。",
|
||||||
|
payload={
|
||||||
|
"skill_name": skill_name,
|
||||||
|
"operation": operation,
|
||||||
|
"actor_user_id": actor_user_id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
counters["notified"] += 1
|
||||||
|
|
||||||
|
return counters
|
||||||
Reference in new issue
Block a user