Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,25 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [2.6.0] - 2026-09-28

### Added
- **Persistent plugin enable/disable state** (`PluginStateStore`, `xcore/registry/state_store.py`): until now there was no way to disable a plugin short of deleting its folder — the manifest schema's `enabled` field lived under `runtime.health_check`, not on the plugin itself. `PluginLoader.load_all()` now consults a JSON file (`<plugins_dir>/../.xcore/plugins_state.json`) and skips disabled plugins at boot; `PluginSupervisor.enable()`/`disable(reason=...)` toggle the state live *and* persist it, so a process restart honors the same active/inactive set.
- **Forced garbage collection on unload/disable** (`PluginResourceTracker`, `xcore/kernel/runtime/plugin_gc.py`): `LifecycleManager._do_unload()` used to trust only the plugin's own `on_stop`/`on_unload` hooks plus `sys.modules` cleanup — no scheduler job, health check, or event/hook subscription was ever unregistered, and `PluginRegistry.unregister()` existed but was never called. The kernel now wraps scheduler/health/events/hooks at load time to track what a plugin registers, and forces their release on unload regardless of how well the plugin's own hooks behave. New `ctx.spawn_task()` for background tasks that are tracked and cancelled automatically.
- **HTTP routes unmounted on unload**: `xcore/__init__.py` only stripped a plugin's FastAPI routes from `app.routes` on reload — never on unload/disable, leaving a disabled plugin's endpoints reachable indefinitely. Extracted into `_unmount_plugin_router()`, now also subscribed to `plugin.*.unloaded`.
- **HTTP control center** on `/plugins/ipc/*`: `GET /registry` (the full plugin truth table — including disabled or never-loaded plugins, which `status()` never exposed), `POST /{name}/enable`, `POST /{name}/disable`.
- **IPC call supervision**: `PluginSupervisor.ipc_audit()`/`ipc_stats()` log every call (`plugin`, `action`, `caller`, `tenant_id`, status, duration) to a bounded audit trail, mirroring the existing `PermissionEngine.audit_log()` pattern. Exposed via `GET /plugins/ipc/audit`.
- **Event/hook supervision**: `EventBus.recent_emissions()`/`.stats()` and `HookManager.recent_emissions()` keep a record of recent emissions (event, handlers/hooks matched, errors, duration) — `EventBus` previously had no metrics at all. Exposed via `GET /plugins/ipc/events`.

### Fixed
- **`propagate_services()` broke reload/re-enable of a `TrustedBase` plugin**: `self._services` exposes the entire `ctx.services` dict for backward compatibility (including db/cache/scheduler), and `propagate_services()` tried to re-register those as the plugin's own exports. This passed on first boot (the registry doesn't protect core services until after `load_all()` runs), but any later reload raised `PermissionError: Impossible d'écraser le service protégé`. A collision on an object identical to the one already protected (received via injection, not exported) is now ignored; a genuinely different object (an actual override attempt) still raises.
- **Sandboxed subprocess wasn't recycled on IPC timeout**: `SandboxProcessManager.call()` only caught `IPCProcessDead`, not `IPCTimeoutError` — a subprocess that stopped responding without actually dying stayed unusable until the next periodic `_health_loop` check caught up with it. `call()` now recycles the subprocess immediately on either exception, on the failing request's own path, instead of waiting for the next health-check interval.
- **`PermissionEngine` audit log had no way to skip cache-hit entries**: every `allows()`/`check()` cache hit still appended to `_audit_log` unconditionally — the expensive part (event emission) was already skipped on cache hits, but the log append wasn't. New optional `PermissionEngine(audit_cache_hits=False)` skips it; default (`True`) keeps the existing behavior (complete audit trail) unchanged.
- **Stray temp directories from crashed test runs**: `tests/conftest.py`'s `plugins_dir`/`temp_dir` fixtures already clean up via `yield` + `shutil.rmtree`, but that teardown never runs if a test crashes hard (e.g. `SIGKILL`) before reaching it. `temp_dir` now uses the same distinguishing `xcore_test_` prefix as `plugins_dir`, and a new session-scoped autouse fixture sweeps any `xcore_test_*` directories left behind in the system temp dir at the end of the run — scoped to that exact prefix only, never a broader temp-dir sweep.

### Changed
- **Trimmed unused core dependencies**: `uvicorn` and `pydantic-settings` were never imported anywhere in `xcore` — both are already pulled in transitively by `fastapi[standard]` (confirmed against its own metadata) for anyone who needs them. `rich` had zero usage in the core package (it belongs to `xcorecli`, a separate install). `pydantic[email]` is now plain `pydantic` — `EmailStr`/`email-validator` were never used, and `fastapi[standard]` already brings in `email-validator` regardless. No behavior change; `poetry.lock` regenerated to match.

## [2.5.3] - 2026-09-09

### Fixed
Expand Down
19 changes: 19 additions & 0 deletions doc/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,25 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [2.6.0] - 2026-09-28

### Added
- **Persistent plugin enable/disable state** (`PluginStateStore`, `xcore/registry/state_store.py`): until now there was no way to disable a plugin short of deleting its folder — the manifest schema's `enabled` field lived under `runtime.health_check`, not on the plugin itself. `PluginLoader.load_all()` now consults a JSON file (`<plugins_dir>/../.xcore/plugins_state.json`) and skips disabled plugins at boot; `PluginSupervisor.enable()`/`disable(reason=...)` toggle the state live *and* persist it, so a process restart honors the same active/inactive set.
- **Forced garbage collection on unload/disable** (`PluginResourceTracker`, `xcore/kernel/runtime/plugin_gc.py`): `LifecycleManager._do_unload()` used to trust only the plugin's own `on_stop`/`on_unload` hooks plus `sys.modules` cleanup — no scheduler job, health check, or event/hook subscription was ever unregistered, and `PluginRegistry.unregister()` existed but was never called. The kernel now wraps scheduler/health/events/hooks at load time to track what a plugin registers, and forces their release on unload regardless of how well the plugin's own hooks behave. New `ctx.spawn_task()` for background tasks that are tracked and cancelled automatically.
- **HTTP routes unmounted on unload**: `xcore/__init__.py` only stripped a plugin's FastAPI routes from `app.routes` on reload — never on unload/disable, leaving a disabled plugin's endpoints reachable indefinitely. Extracted into `_unmount_plugin_router()`, now also subscribed to `plugin.*.unloaded`.
- **HTTP control center** on `/plugins/ipc/*`: `GET /registry` (the full plugin truth table — including disabled or never-loaded plugins, which `status()` never exposed), `POST /{name}/enable`, `POST /{name}/disable`.
- **IPC call supervision**: `PluginSupervisor.ipc_audit()`/`ipc_stats()` log every call (`plugin`, `action`, `caller`, `tenant_id`, status, duration) to a bounded audit trail, mirroring the existing `PermissionEngine.audit_log()` pattern. Exposed via `GET /plugins/ipc/audit`.
- **Event/hook supervision**: `EventBus.recent_emissions()`/`.stats()` and `HookManager.recent_emissions()` keep a record of recent emissions (event, handlers/hooks matched, errors, duration) — `EventBus` previously had no metrics at all. Exposed via `GET /plugins/ipc/events`.

### Fixed
- **`propagate_services()` broke reload/re-enable of a `TrustedBase` plugin**: `self._services` exposes the entire `ctx.services` dict for backward compatibility (including db/cache/scheduler), and `propagate_services()` tried to re-register those as the plugin's own exports. This passed on first boot (the registry doesn't protect core services until after `load_all()` runs), but any later reload raised `PermissionError: Impossible d'écraser le service protégé`. A collision on an object identical to the one already protected (received via injection, not exported) is now ignored; a genuinely different object (an actual override attempt) still raises.
- **Sandboxed subprocess wasn't recycled on IPC timeout**: `SandboxProcessManager.call()` only caught `IPCProcessDead`, not `IPCTimeoutError` — a subprocess that stopped responding without actually dying stayed unusable until the next periodic `_health_loop` check caught up with it. `call()` now recycles the subprocess immediately on either exception, on the failing request's own path, instead of waiting for the next health-check interval.
- **`PermissionEngine` audit log had no way to skip cache-hit entries**: every `allows()`/`check()` cache hit still appended to `_audit_log` unconditionally — the expensive part (event emission) was already skipped on cache hits, but the log append wasn't. New optional `PermissionEngine(audit_cache_hits=False)` skips it; default (`True`) keeps the existing behavior (complete audit trail) unchanged.
- **Stray temp directories from crashed test runs**: `tests/conftest.py`'s `plugins_dir`/`temp_dir` fixtures already clean up via `yield` + `shutil.rmtree`, but that teardown never runs if a test crashes hard (e.g. `SIGKILL`) before reaching it. `temp_dir` now uses the same distinguishing `xcore_test_` prefix as `plugins_dir`, and a new session-scoped autouse fixture sweeps any `xcore_test_*` directories left behind in the system temp dir at the end of the run — scoped to that exact prefix only, never a broader temp-dir sweep.

### Changed
- **Trimmed unused core dependencies**: `uvicorn` and `pydantic-settings` were never imported anywhere in `xcore` — both are already pulled in transitively by `fastapi[standard]` (confirmed against its own metadata) for anyone who needs them. `rich` had zero usage in the core package (it belongs to `xcorecli`, a separate install). `pydantic[email]` is now plain `pydantic` — `EmailStr`/`email-validator` were never used, and `fastapi[standard]` already brings in `email-validator` regardless. No behavior change; `poetry.lock` regenerated to match.

## [2.5.3] - 2026-09-09

### Fixed
Expand Down
2 changes: 1 addition & 1 deletion poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 2 additions & 5 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "XCoreRuntime"
version = "2.5.3"
version = "2.6.0"
description = "Plugin-first orchestration framework built on FastAPI"
authors = [
{ name = "Eliezer Traore", email = "68350805+traoreera@users.noreply.github.com" },
Expand All @@ -18,9 +18,7 @@ classifiers = [
requires-python = ">=3.12,<4.0"
dependencies = [
"fastapi[standard]>=0.135.1,<1.0.0",
"uvicorn>=0.38.0,<1.0.0",
"pydantic[email]>=2.11.7,<3.0.0",
"pydantic-settings>=2.14.2,<3.0.0",
"pydantic>=2.11.7,<3.0.0",
"sqlalchemy>=2.0.41,<3.0.0",
"alembic>=1.16.1,<2.0.0",
"psycopg2>=2.9.11,<3.0.0",
Expand All @@ -29,7 +27,6 @@ dependencies = [
"apscheduler>=3.11.0,<4.0.0",
"python-dotenv>=1.1.0,<2.0.0",
"pyyaml>=6.0.3,<7.0.0",
"rich>=14.0.0,<15.0.0",
"celery>=5.6.3,<6.0.0",
"opentelemetry-api>=1.27.0,<2.0.0",
"opentelemetry-sdk>=1.27.0,<2.0.0",
Expand Down
4 changes: 2 additions & 2 deletions roadmap/executed_roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@ Ce document présente l'état actuel du framework XCore par rapport aux objectif
| :--- | :--- | :--- | :--- |
| **V1** | Fondation Kernel | **Terminé** | 100% |
| **V2** | Industrialisation | **Terminé** | 100% |
| **V3** | Distribution | **Avancé** | 60% |
| **V4** | Cloud Native | **Démarré** | 15% |
| **V3** | Distribution | **Avancé** | 25% |
| **V4** | Cloud Native | **Démarré** | 5% |
| **V5** | Intelligence Native | **Concept** | 0% |

---
Expand Down
29 changes: 28 additions & 1 deletion tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -131,11 +131,38 @@ def fake_plugin_dir(plugins_dir: Path) -> Path:
@pytest.fixture
def temp_dir() -> Generator[Path, None, None]:
"""Fixture utilitaire pour un dossier temporaire propre."""
tmp = tempfile.mkdtemp()
# Préfixe distinctif : permet à _cleanup_stray_tmp_dirs (ci-dessous) de
# cibler précisément ces dossiers sans risquer de toucher aux tmp d'un
# autre processus si ce yield+rmtree n'a pas pu s'exécuter (crash dur).
tmp = tempfile.mkdtemp(prefix="xcore_test_")
yield Path(tmp)
shutil.rmtree(tmp, ignore_errors=True)


def sweep_stray_tmp_dirs() -> None:
"""
Balaie tout ce qui porte le préfixe `xcore_test_` restant dans le dossier
temp système — jamais plus large, pour ne pas toucher aux fichiers
temporaires d'un autre processus. Fonction plain (pas une fixture) pour
rester testable directement.
"""
tmp_root = Path(tempfile.gettempdir())
for leftover in tmp_root.glob("xcore_test_*"):
shutil.rmtree(leftover, ignore_errors=True)


@pytest.fixture(scope="session", autouse=True)
def _cleanup_stray_tmp_dirs() -> Generator[None, None, None]:
"""
Filet de sécurité en fin de session : `plugins_dir`/`temp_dir` nettoient
déjà systématiquement leur propre dossier via yield+rmtree, mais ce
teardown ne s'exécute pas si un test crashe durement (SIGKILL) avant
d'y arriver.
"""
yield
sweep_stray_tmp_dirs()


@pytest.fixture
def invalid_plugin_dir(plugins_dir: Path) -> Path:
"""Plugin invalide — manque PLUGIN_INFO."""
Expand Down
125 changes: 125 additions & 0 deletions tests/integration/test_plugin_enable_disable.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
"""
Integration tests — table de vérité persistante (enable/disable) + centre de
contrôle (registry_table). Vérifie que désactiver un plugin survit à un
redémarrage complet du process, pas seulement au unload en mémoire.
"""

import pytest

from xcore import Xcore


@pytest.fixture
def config_with_plugin(tmp_path):
"""Config xcore pointant vers un plugin de test réel sur disque."""
plugins_dir = tmp_path / "plugins"
plugins_dir.mkdir()

test_plugin = plugins_dir / "test_plugin"
test_plugin.mkdir()
src_dir = test_plugin / "src"
src_dir.mkdir()

(test_plugin / "plugin.yaml").write_text("""
name: test_plugin
version: 1.0.0
execution_mode: trusted
permissions:
- resource: "*"
actions: ["*"]
""")
(src_dir / "main.py").write_text("""
from xcore.sdk import TrustedBase, ok

class Plugin(TrustedBase):
async def handle(self, action, payload):
return ok(message="pong")
""")

config_content = f"""
app:
name: test-app
secret_key: test-secret-key-32-chars-long!!!

plugins:
directory: {plugins_dir}
strict_trusted: false

services:
databases: {{}}
cache:
backend: memory
ttl: 300
"""
config_path = tmp_path / "config.yaml"
config_path.write_text(config_content)
return str(config_path)


class TestRegistryTable:
@pytest.mark.asyncio
async def test_loaded_plugin_appears_enabled_and_ready(self, config_with_plugin):
xcore = Xcore(config_path=config_with_plugin)
try:
await xcore.boot()
table = {row["name"]: row for row in xcore.plugins.registry_table()}
assert table["test_plugin"]["enabled"] is True
assert table["test_plugin"]["state"] == "ready"
assert table["test_plugin"]["loaded"] is True
finally:
await xcore.shutdown()


class TestEnableDisable:
@pytest.mark.asyncio
async def test_disable_unloads_and_persists(self, config_with_plugin):
xcore = Xcore(config_path=config_with_plugin)
try:
await xcore.boot()
assert "test_plugin" in xcore.plugins.list_plugins()

await xcore.plugins.disable("test_plugin", reason="maintenance")

assert "test_plugin" not in xcore.plugins.list_plugins()
table = {row["name"]: row for row in xcore.plugins.registry_table()}
assert table["test_plugin"]["enabled"] is False
assert table["test_plugin"]["state"] == "not_loaded"
assert table["test_plugin"]["reason"] == "maintenance"
finally:
await xcore.shutdown()

@pytest.mark.asyncio
async def test_disabled_state_survives_restart(self, config_with_plugin):
"""Le cœur de la demande : désactiver doit tenir après un redémarrage du process."""
first = Xcore(config_path=config_with_plugin)
await first.boot()
await first.plugins.disable("test_plugin")
await first.shutdown()

# Nouveau process xcore, même config/dossier de plugins.
second = Xcore(config_path=config_with_plugin)
try:
await second.boot()
assert "test_plugin" not in second.plugins.list_plugins()
table = {row["name"]: row for row in second.plugins.registry_table()}
assert table["test_plugin"]["enabled"] is False
assert table["test_plugin"]["state"] == "not_loaded"
finally:
await second.shutdown()

@pytest.mark.asyncio
async def test_enable_reloads_plugin(self, config_with_plugin):
xcore = Xcore(config_path=config_with_plugin)
try:
await xcore.boot()
await xcore.plugins.disable("test_plugin")
assert "test_plugin" not in xcore.plugins.list_plugins()

await xcore.plugins.enable("test_plugin")

assert "test_plugin" in xcore.plugins.list_plugins()
table = {row["name"]: row for row in xcore.plugins.registry_table()}
assert table["test_plugin"]["enabled"] is True
assert table["test_plugin"]["state"] == "ready"
finally:
await xcore.shutdown()
31 changes: 31 additions & 0 deletions tests/test_conftest_cleanup.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
"""
Tests for the tmp-dir cleanup safety net in tests/conftest.py.
"""

import tempfile
from pathlib import Path

from conftest import sweep_stray_tmp_dirs


def test_sweeps_stray_xcore_test_dirs():
stray = Path(tempfile.mkdtemp(prefix="xcore_test_leftover_"))
(stray / "marker.txt").write_text("leftover")
assert stray.exists()

sweep_stray_tmp_dirs()

assert not stray.exists()


def test_does_not_touch_unrelated_tmp_dirs():
unrelated = Path(tempfile.mkdtemp(prefix="not_xcore_related_"))
try:
sweep_stray_tmp_dirs()
assert unrelated.exists()
finally:
unrelated.rmdir()


def test_temp_dir_fixture_uses_distinguishing_prefix(temp_dir):
assert temp_dir.name.startswith("xcore_test_")
Loading
Loading