From 0ac3cce6f3aaf119f9918866a74c56be237a3abb Mon Sep 17 00:00:00 2001 From: steven_li Date: Tue, 16 Jun 2026 12:01:12 +0800 Subject: [PATCH] feat(api): manage declarative plugins --- .../backend/beaver/interfaces/web/app.py | 102 ++++++++++++++++++ .../backend/tests/unit/test_plugin_web_api.py | 67 ++++++++++++ 2 files changed, 169 insertions(+) create mode 100644 app-instance/backend/tests/unit/test_plugin_web_api.py diff --git a/app-instance/backend/beaver/interfaces/web/app.py b/app-instance/backend/beaver/interfaces/web/app.py index a3a6ac1..0575593 100644 --- a/app-instance/backend/beaver/interfaces/web/app.py +++ b/app-instance/backend/beaver/interfaces/web/app.py @@ -1971,6 +1971,71 @@ def create_app( ) return result + @app.get("/api/plugins") + async def list_plugins(request: Request) -> list[dict[str, Any]]: + loaded = get_agent_service(request).create_loop().boot() + return [_plugin_payload(loaded, state) for state in loaded.plugin_manager.list_plugins()] # type: ignore[union-attr] + + @app.post("/api/plugins/sync") + async def sync_plugins(request: Request) -> list[dict[str, Any]]: + loaded = get_agent_service(request).create_loop().boot() + try: + states = loaded.plugin_manager.sync_enabled().values() # type: ignore[union-attr] + except ValueError as exc: + raise _plugin_http_error(exc) from exc + return [_plugin_payload(loaded, state) for state in states] + + @app.post("/api/plugins/{plugin_id}/enable") + async def enable_plugin(plugin_id: str, request: Request) -> dict[str, Any]: + loaded = get_agent_service(request).create_loop().boot() + try: + state = loaded.plugin_manager.enable(plugin_id) # type: ignore[union-attr] + except ValueError as exc: + raise _plugin_http_error(exc) from exc + return _plugin_payload(loaded, state) + + @app.post("/api/plugins/{plugin_id}/pause") + async def pause_plugin(plugin_id: str, request: Request) -> dict[str, Any]: + loaded = get_agent_service(request).create_loop().boot() + try: + state = loaded.plugin_manager.pause(plugin_id) # type: ignore[union-attr] + except ValueError as exc: + raise _plugin_http_error(exc) from exc + return _plugin_payload(loaded, state) + + @app.post("/api/plugins/{plugin_id}/resume") + async def resume_plugin(plugin_id: str, request: Request) -> dict[str, Any]: + loaded = get_agent_service(request).create_loop().boot() + try: + state = loaded.plugin_manager.resume(plugin_id) # type: ignore[union-attr] + except ValueError as exc: + raise _plugin_http_error(exc) from exc + return _plugin_payload(loaded, state) + + @app.post("/api/plugins/{plugin_id}/disable") + async def disable_plugin(plugin_id: str, request: Request, payload: dict[str, Any] | None = None) -> dict[str, Any]: + loaded = get_agent_service(request).create_loop().boot() + try: + state = loaded.plugin_manager.disable( # type: ignore[union-attr] + plugin_id, + disable_linked_skills=bool((payload or {}).get("disable_linked_skills")), + ) + except ValueError as exc: + raise _plugin_http_error(exc) from exc + return _plugin_payload(loaded, state) + + @app.post("/api/plugins/{plugin_id}/skills/{skill_name}/adopt") + async def adopt_plugin_skill(plugin_id: str, skill_name: str, request: Request) -> dict[str, Any]: + loaded = get_agent_service(request).create_loop().boot() + try: + loaded.plugin_manager.adopt(plugin_id, skill_name) # type: ignore[union-attr] + state = loaded.plugin_manager.state_store.get_plugin(plugin_id) # type: ignore[union-attr] + except ValueError as exc: + raise _plugin_http_error(exc) from exc + if state is None: + raise HTTPException(status_code=404, detail="Plugin not found") + return _plugin_payload(loaded, state) + @app.get("/api/skills") async def list_skills(request: Request) -> list[dict[str, Any]]: loaded = get_agent_service(request).create_loop().boot() @@ -4123,6 +4188,43 @@ def _skill_draft_http_error(exc: ValueError) -> HTTPException: return HTTPException(status_code=status_code, detail=detail) +def _plugin_payload(loaded: Any, state: Any) -> dict[str, Any]: + manifest = loaded.plugin_manager.manifests.get(state.plugin_id) # type: ignore[union-attr] + return { + "id": state.plugin_id, + "name": manifest.name if manifest is not None else state.plugin_id, + "discovered_version": manifest.version if manifest is not None else None, + "installed_version": state.installed_version, + "enabled": state.enabled, + "status": state.status, + "last_error": state.last_error, + "manifest_path": manifest.display_path if manifest is not None else state.manifest_path, + "updates_paused": state.updates_paused, + "skills": [ + { + "name": name, + "status": binding.status, + "current_beaver_version": binding.current_beaver_version, + "accepted_upstream_tree_hash": binding.accepted_upstream_tree_hash, + "observed_upstream_tree_hash": binding.observed_upstream_tree_hash, + "accepted_beaver_version": binding.accepted_beaver_version, + "pending_candidate_id": binding.pending_candidate_id, + } + for name, binding in sorted(state.skills.items()) + ], + } + + +def _plugin_http_error(exc: ValueError) -> HTTPException: + detail = str(exc) + lowered = detail.lower() + if "unknown plugin" in lowered or "unknown plugin state" in lowered or "not found" in lowered: + return HTTPException(status_code=404, detail=detail) + if "conflict" in lowered or "busy" in lowered: + return HTTPException(status_code=409, detail=detail) + return HTTPException(status_code=400, detail=detail) + + def _mask_secret(value: str | None) -> str: secret = _clean_text(value) if not secret: diff --git a/app-instance/backend/tests/unit/test_plugin_web_api.py b/app-instance/backend/tests/unit/test_plugin_web_api.py new file mode 100644 index 0000000..34718b4 --- /dev/null +++ b/app-instance/backend/tests/unit/test_plugin_web_api.py @@ -0,0 +1,67 @@ +from __future__ import annotations + +import json +from pathlib import Path + +from fastapi.testclient import TestClient + +from beaver.interfaces.web.app import create_app +from beaver.services.agent_service import AgentService + + +def _write_plugin(workspace: Path) -> None: + plugin_root = workspace / "plugins" / "baoyu-comic" + skill_root = plugin_root / "skills" / "baoyu-comic" + skill_root.mkdir(parents=True, exist_ok=True) + (skill_root / "SKILL.md").write_text( + "---\nname: baoyu-comic\ndescription: Comic workflow\n---\n\n# Comic\n\nDraw.\n", + encoding="utf-8", + ) + (plugin_root / "beaver.plugin.json").write_text( + json.dumps( + { + "schema_version": 1, + "id": "baoyu-comic", + "name": "Baoyu Comic", + "version": "1.0.0", + "skills": [{"name": "baoyu-comic", "path": "skills/baoyu-comic"}], + } + ), + encoding="utf-8", + ) + + +def test_plugin_management_api_lifecycle(tmp_path: Path) -> None: + _write_plugin(tmp_path) + service = AgentService(workspace=tmp_path) + app = create_app(service=service, manage_service_lifecycle=False) + + with TestClient(app) as client: + listed = client.get("/api/plugins") + enabled = client.post("/api/plugins/baoyu-comic/enable") + paused = client.post("/api/plugins/baoyu-comic/pause") + resumed = client.post("/api/plugins/baoyu-comic/resume") + disable_rejected = client.post("/api/plugins/baoyu-comic/disable", json={}) + adopted = client.post("/api/plugins/baoyu-comic/skills/baoyu-comic/adopt") + synced = client.post("/api/plugins/sync") + + assert listed.status_code == 200 + assert listed.json()[0]["manifest_path"] == "plugins/baoyu-comic/beaver.plugin.json" + assert enabled.status_code == 200 + assert enabled.json()["enabled"] is True + assert paused.json()["updates_paused"] is True + assert resumed.status_code == 200 + assert disable_rejected.status_code == 400 + assert adopted.status_code == 200 + assert adopted.json()["skills"] == [] + assert synced.status_code == 200 + + +def test_plugin_management_api_unknown_plugin_returns_404(tmp_path: Path) -> None: + service = AgentService(workspace=tmp_path) + app = create_app(service=service, manage_service_lifecycle=False) + + with TestClient(app) as client: + response = client.post("/api/plugins/missing/enable") + + assert response.status_code == 404