diff --git a/helpers/projects.py b/helpers/projects.py index 11f4079527..4995b2d5a5 100644 --- a/helpers/projects.py +++ b/helpers/projects.py @@ -101,9 +101,32 @@ def delete_project(name: str): abs_path = files.get_abs_path(PROJECTS_PARENT_DIR, name) files.delete_dir(abs_path) deactivate_project_in_chats(name) + _notify_project_deleted(name) return name +def _notify_project_deleted(name: str) -> None: + """Notify enabled plugins that a project was deleted. + + Plugins may implement ``project_deleted(project_name: str, **kwargs)`` + in their ``hooks.py`` to clean up external resources tied to the project + (e.g. per-project memory banks). Hook failures must never break the + deletion itself, so every plugin notification is guarded. + """ + try: + from helpers import plugins as plugin_helper + + for plugin_name in plugin_helper.get_enabled_plugins(None): + try: + plugin_helper.call_plugin_hook( + plugin_name, "project_deleted", default=None, project_name=name + ) + except Exception: + pass + except Exception: + pass + + def create_project(name: str, data: BasicProjectData): extended_data = _project_extended_data_for_save(data) mcp_servers = data.get("mcp_servers") if isinstance(data, dict) else None diff --git a/helpers/projects.py.dox.md b/helpers/projects.py.dox.md index 4de0b93387..aba15afdd8 100644 --- a/helpers/projects.py.dox.md +++ b/helpers/projects.py.dox.md @@ -21,7 +21,8 @@ - `get_project_folder(name: str)` - `get_project_meta(name: str, *sub_dirs)` - `validate_project_name(name: str | None) -> str` -- `delete_project(name: str)` +- `delete_project(name: str)`: Deletes the project directory, deactivates it in chats, then notifies enabled plugins via `_notify_project_deleted`. +- `_notify_project_deleted(name: str)`: Calls the `project_deleted(project_name=...)` hook on every enabled plugin (from `hooks.py`) so plugins can clean up external per-project resources; failures are swallowed and never break deletion. - `create_project(name: str, data: BasicProjectData)` - `clone_git_project(name: str, git_url: str, git_token: str, data: BasicProjectData)`: Clone a git repository as a new A0 project. Token is used only for cloning via http header. - `load_project_header(name: str)` diff --git a/tests/test_project_deleted_hook.py b/tests/test_project_deleted_hook.py new file mode 100644 index 0000000000..604b3bad04 --- /dev/null +++ b/tests/test_project_deleted_hook.py @@ -0,0 +1,89 @@ +"""Tests for the project_deleted plugin hook fired by delete_project().""" + +from pathlib import Path + +from helpers import files, projects +from helpers import plugins as plugins_helper + +HOOKS_PLUGIN = "test-project-deleted-plugin" + +HOOKS_PY = '''\nfrom pathlib import Path\n\n\ndef project_deleted(project_name: str = "", **kwargs):\n log = Path(__file__).parent / "hook.log"\n log.write_text(project_name)\n''' + +RAISING_HOOKS_PY = '''\n\ndef project_deleted(project_name: str = "", **kwargs):\n raise RuntimeError("hook boom")\n''' + + +def _prepare_base(monkeypatch, tmp_path: Path) -> None: + monkeypatch.setattr(files, "_base_dir", str(tmp_path)) + (tmp_path / "usr" / "projects").mkdir(parents=True, exist_ok=True) + (tmp_path / "usr" / "plugins").mkdir(parents=True, exist_ok=True) + (tmp_path / "plugins").mkdir(parents=True, exist_ok=True) + + +def _clear_plugin_caches() -> None: + plugins_helper.cache.clear(plugins_helper.PLUGINS_LIST_CACHE_AREA) + plugins_helper.cache.clear(plugins_helper.ENABLED_PLUGINS_LIST_CACHE_AREA) + plugins_helper.cache.clear(plugins_helper.HOOKS_CACHE_AREA) + + +def _create_hook_plugin(hooks_source: str, *, disabled: bool = False) -> Path: + plugin_dir = Path(plugins_helper.get_plugin_roots(HOOKS_PLUGIN)[0]) + plugin_dir.mkdir(parents=True, exist_ok=True) + (plugin_dir / "plugin.yaml").write_text("name: test-project-deleted-plugin\n") + (plugin_dir / "hooks.py").write_text(hooks_source) + if disabled: + (plugin_dir / ".toggle-0").write_text("") + _clear_plugin_caches() + return plugin_dir + + +def _create_project(name: str) -> Path: + project_dir = Path(files.get_abs_path(projects.PROJECTS_PARENT_DIR, name)) + (project_dir / projects.PROJECT_META_DIR).mkdir(parents=True, exist_ok=True) + return project_dir + + +def test_delete_project_fires_project_deleted_hook(monkeypatch, tmp_path): + _prepare_base(monkeypatch, tmp_path) + plugin_dir = _create_hook_plugin(HOOKS_PY) + project_dir = _create_project("hook-test-project") + + result = projects.delete_project("hook-test-project") + + assert result == "hook-test-project" + assert not project_dir.exists() + assert (plugin_dir / "hook.log").read_text() == "hook-test-project" + + +def test_hook_failure_does_not_break_project_deletion(monkeypatch, tmp_path): + _prepare_base(monkeypatch, tmp_path) + _create_hook_plugin(RAISING_HOOKS_PY) + project_dir = _create_project("failing-hook-project") + + result = projects.delete_project("failing-hook-project") + + assert result == "failing-hook-project" + assert not project_dir.exists() + + +def test_disabled_plugin_is_not_notified(monkeypatch, tmp_path): + _prepare_base(monkeypatch, tmp_path) + plugin_dir = _create_hook_plugin(HOOKS_PY, disabled=True) + _create_project("disabled-hook-project") + + projects.delete_project("disabled-hook-project") + + assert not (plugin_dir / "hook.log").exists() + + +def test_plugin_without_hooks_does_not_break(monkeypatch, tmp_path): + _prepare_base(monkeypatch, tmp_path) + plugin_dir = Path(plugins_helper.get_plugin_roots(HOOKS_PLUGIN)[0]) + plugin_dir.mkdir(parents=True, exist_ok=True) + (plugin_dir / "plugin.yaml").write_text("name: test-project-deleted-plugin\n") + _clear_plugin_caches() + project_dir = _create_project("no-hooks-project") + + result = projects.delete_project("no-hooks-project") + + assert result == "no-hooks-project" + assert not project_dir.exists()