refactor(skills): Unified skill storage capability (#2613)

This commit is contained in:
Xun
2026-05-01 13:23:26 +08:00
committed by GitHub
parent eba3b9e18d
commit 1ad1420e31
29 changed files with 1031 additions and 968 deletions
+37 -23
View File
@@ -8,7 +8,7 @@ from fastapi import FastAPI
from fastapi.testclient import TestClient
from app.gateway.routers import skills as skills_router
from deerflow.skills.manager import get_skill_history_file
from deerflow.skills.storage import get_or_new_skill_storage
from deerflow.skills.types import Skill
@@ -58,7 +58,7 @@ def test_install_skill_archive_runs_security_scan(monkeypatch, tmp_path):
scan_calls = []
refresh_calls = []
async def _scan(content, *, executable, location):
async def _scan(content, *, executable, location, app_config=None):
from deerflow.skills.security_scanner import ScanResult
scan_calls.append({"content": content, "executable": executable, "location": location})
@@ -67,13 +67,21 @@ def test_install_skill_archive_runs_security_scan(monkeypatch, tmp_path):
async def _refresh():
refresh_calls.append("refresh")
from types import SimpleNamespace
from deerflow.skills.storage.local_skill_storage import LocalSkillStorage
storage = LocalSkillStorage(host_path=str(skills_root))
config = SimpleNamespace(
skills=SimpleNamespace(get_skills_path=lambda: skills_root, container_path="/mnt/skills", use="deerflow.skills.storage.local_skill_storage:LocalSkillStorage"),
skill_evolution=SimpleNamespace(enabled=True, moderation_model_name=None),
)
monkeypatch.setattr(skills_router, "resolve_thread_virtual_path", lambda thread_id, path: archive)
monkeypatch.setattr("deerflow.skills.installer.get_skills_root_path", lambda: skills_root)
monkeypatch.setattr(skills_router, "get_or_new_skill_storage", lambda **kw: storage)
monkeypatch.setattr("deerflow.skills.installer.scan_skill_content", _scan)
monkeypatch.setattr(skills_router, "refresh_skills_system_prompt_cache_async", _refresh)
app = FastAPI()
app.include_router(skills_router.router)
app = _make_test_app(config)
with TestClient(app) as client:
response = client.post("/api/skills/install", json={"thread_id": "thread-1", "path": "mnt/user-data/outputs/archive-skill.skill"})
@@ -105,13 +113,21 @@ def test_install_skill_archive_security_scan_block_returns_400(monkeypatch, tmp_
async def _refresh():
refresh_calls.append("refresh")
from types import SimpleNamespace
from deerflow.skills.storage.local_skill_storage import LocalSkillStorage
storage = LocalSkillStorage(host_path=str(skills_root))
config = SimpleNamespace(
skills=SimpleNamespace(get_skills_path=lambda: skills_root, container_path="/mnt/skills", use="deerflow.skills.storage.local_skill_storage:LocalSkillStorage"),
skill_evolution=SimpleNamespace(enabled=True, moderation_model_name=None),
)
monkeypatch.setattr(skills_router, "resolve_thread_virtual_path", lambda thread_id, path: archive)
monkeypatch.setattr("deerflow.skills.installer.get_skills_root_path", lambda: skills_root)
monkeypatch.setattr(skills_router, "get_or_new_skill_storage", lambda **kw: storage)
monkeypatch.setattr("deerflow.skills.installer.scan_skill_content", _scan)
monkeypatch.setattr(skills_router, "refresh_skills_system_prompt_cache_async", _refresh)
app = FastAPI()
app.include_router(skills_router.router)
app = _make_test_app(config)
with TestClient(app) as client:
response = client.post("/api/skills/install", json={"thread_id": "thread-1", "path": "mnt/user-data/outputs/blocked-skill.skill"})
@@ -128,11 +144,10 @@ def test_custom_skills_router_lifecycle(monkeypatch, tmp_path):
custom_dir.mkdir(parents=True, exist_ok=True)
(custom_dir / "SKILL.md").write_text(_skill_content("demo-skill"), encoding="utf-8")
config = SimpleNamespace(
skills=SimpleNamespace(get_skills_path=lambda: skills_root, container_path="/mnt/skills"),
skills=SimpleNamespace(get_skills_path=lambda: skills_root, container_path="/mnt/skills", use="deerflow.skills.storage.local_skill_storage:LocalSkillStorage"),
skill_evolution=SimpleNamespace(enabled=True, moderation_model_name=None),
)
monkeypatch.setattr("deerflow.config.get_app_config", lambda: config)
monkeypatch.setattr("deerflow.skills.manager.get_app_config", lambda: config)
monkeypatch.setattr("app.gateway.routers.skills.scan_skill_content", lambda *args, **kwargs: _async_scan("allow", "ok"))
refresh_calls = []
@@ -177,12 +192,13 @@ def test_custom_skill_rollback_blocked_by_scanner(monkeypatch, tmp_path):
edited_content = _skill_content("demo-skill", "Edited skill")
(custom_dir / "SKILL.md").write_text(edited_content, encoding="utf-8")
config = SimpleNamespace(
skills=SimpleNamespace(get_skills_path=lambda: skills_root, container_path="/mnt/skills"),
skills=SimpleNamespace(get_skills_path=lambda: skills_root, container_path="/mnt/skills", use="deerflow.skills.storage.local_skill_storage:LocalSkillStorage"),
skill_evolution=SimpleNamespace(enabled=True, moderation_model_name=None),
)
monkeypatch.setattr("deerflow.config.get_app_config", lambda: config)
monkeypatch.setattr("deerflow.skills.manager.get_app_config", lambda: config)
get_skill_history_file("demo-skill", app_config=config).write_text(
history_file = get_or_new_skill_storage(app_config=config).get_skill_history_file("demo-skill")
history_file.parent.mkdir(parents=True, exist_ok=True)
history_file.write_text(
'{"action":"human_edit","prev_content":' + json.dumps(original_content) + ',"new_content":' + json.dumps(edited_content) + "}\n",
encoding="utf-8",
)
@@ -218,11 +234,10 @@ def test_custom_skill_delete_preserves_history_and_allows_restore(monkeypatch, t
original_content = _skill_content("demo-skill")
(custom_dir / "SKILL.md").write_text(original_content, encoding="utf-8")
config = SimpleNamespace(
skills=SimpleNamespace(get_skills_path=lambda: skills_root, container_path="/mnt/skills"),
skills=SimpleNamespace(get_skills_path=lambda: skills_root, container_path="/mnt/skills", use="deerflow.skills.storage.local_skill_storage:LocalSkillStorage"),
skill_evolution=SimpleNamespace(enabled=True, moderation_model_name=None),
)
monkeypatch.setattr("deerflow.config.get_app_config", lambda: config)
monkeypatch.setattr("deerflow.skills.manager.get_app_config", lambda: config)
monkeypatch.setattr("app.gateway.routers.skills.scan_skill_content", lambda *args, **kwargs: _async_scan("allow", "ok"))
refresh_calls = []
@@ -255,11 +270,10 @@ def test_custom_skill_delete_continues_when_history_write_is_readonly(monkeypatc
custom_dir.mkdir(parents=True, exist_ok=True)
(custom_dir / "SKILL.md").write_text(_skill_content("demo-skill"), encoding="utf-8")
config = SimpleNamespace(
skills=SimpleNamespace(get_skills_path=lambda: skills_root, container_path="/mnt/skills"),
skills=SimpleNamespace(get_skills_path=lambda: skills_root, container_path="/mnt/skills", use="deerflow.skills.storage.local_skill_storage:LocalSkillStorage"),
skill_evolution=SimpleNamespace(enabled=True, moderation_model_name=None),
)
monkeypatch.setattr("deerflow.config.get_app_config", lambda: config)
monkeypatch.setattr("deerflow.skills.manager.get_app_config", lambda: config)
refresh_calls = []
async def _refresh():
@@ -268,7 +282,7 @@ def test_custom_skill_delete_continues_when_history_write_is_readonly(monkeypatc
def _readonly_history(*args, **kwargs):
raise OSError(errno.EROFS, "Read-only file system", str(skills_root / "custom" / ".history"))
monkeypatch.setattr("app.gateway.routers.skills.append_history", _readonly_history)
monkeypatch.setattr("deerflow.skills.storage.local_skill_storage.LocalSkillStorage.append_history", _readonly_history)
monkeypatch.setattr("app.gateway.routers.skills.refresh_skills_system_prompt_cache_async", _refresh)
app = _make_test_app(config)
@@ -288,11 +302,10 @@ def test_custom_skill_delete_fails_when_skill_dir_removal_fails(monkeypatch, tmp
custom_dir.mkdir(parents=True, exist_ok=True)
(custom_dir / "SKILL.md").write_text(_skill_content("demo-skill"), encoding="utf-8")
config = SimpleNamespace(
skills=SimpleNamespace(get_skills_path=lambda: skills_root, container_path="/mnt/skills"),
skills=SimpleNamespace(get_skills_path=lambda: skills_root, container_path="/mnt/skills", use="deerflow.skills.storage.local_skill_storage:LocalSkillStorage"),
skill_evolution=SimpleNamespace(enabled=True, moderation_model_name=None),
)
monkeypatch.setattr("deerflow.config.get_app_config", lambda: config)
monkeypatch.setattr("deerflow.skills.manager.get_app_config", lambda: config)
refresh_calls = []
async def _refresh():
@@ -301,7 +314,7 @@ def test_custom_skill_delete_fails_when_skill_dir_removal_fails(monkeypatch, tmp
def _fail_rmtree(*args, **kwargs):
raise PermissionError(errno.EACCES, "Permission denied", str(custom_dir))
monkeypatch.setattr("app.gateway.routers.skills.shutil.rmtree", _fail_rmtree)
monkeypatch.setattr("deerflow.skills.storage.local_skill_storage.shutil.rmtree", _fail_rmtree)
monkeypatch.setattr("app.gateway.routers.skills.refresh_skills_system_prompt_cache_async", _refresh)
app = _make_test_app(config)
@@ -320,7 +333,7 @@ def test_update_skill_refreshes_prompt_cache_before_return(monkeypatch, tmp_path
enabled_state = {"value": True}
refresh_calls = []
def _load_skills(*, enabled_only: bool, app_config=None):
def _load_skills(*, enabled_only: bool):
skill = _make_skill("demo-skill", enabled=enabled_state["value"])
if enabled_only and not skill.enabled:
return []
@@ -330,7 +343,8 @@ def test_update_skill_refreshes_prompt_cache_before_return(monkeypatch, tmp_path
refresh_calls.append("refresh")
enabled_state["value"] = False
monkeypatch.setattr("app.gateway.routers.skills.load_skills", _load_skills)
mock_storage = SimpleNamespace(load_skills=_load_skills)
monkeypatch.setattr("app.gateway.routers.skills.get_or_new_skill_storage", lambda **kwargs: mock_storage)
monkeypatch.setattr("app.gateway.routers.skills.get_extensions_config", lambda: SimpleNamespace(mcp_servers={}, skills={}))
monkeypatch.setattr("app.gateway.routers.skills.reload_extensions_config", lambda: None)
monkeypatch.setattr(skills_router.ExtensionsConfig, "resolve_config_path", staticmethod(lambda: config_path))