Repository navigation
Plugin runtime + reconcile dispatch + reftest sentinel - #24
Merged
Merged
Conversation
Closes the plugin-surface kernel commitment (PLAN.md step 11). Step 10
shipped discovery + GET /v1/plugins/types; this slice adds the runtime
that turns discovered types into running plugins, and wires reconcile
dispatch into the lock-toggle path. After this PR the kernel acceptance
plugin path (PLAN.md §"Acceptance: kernel done" steps 11-16) passes
end-to-end.
What's in:
- `core/curfew/plugin_runtime.py` — `PluginRuntime`. In-memory
`{(type, instance_id): AssignedInstance}` map, async-locked. Public
surface: `build_instance` (validate config + construct, no side
effects); `register` (add a pre-built instance to the map);
`unassign` (drop); `update` (re-instantiate iff config changed,
else just rewire users/paused); `dispatch_for_user` (find
governing instances, schedule reconcile concurrently with timeout);
`safety_net_resync` (walk every managed user, dispatch). Failures
caught at the reconcile boundary and audited as
`plugin.reconcile_failed`; never raised.
- `api/curfew_api/routes/plugins.py` — `GET/POST/PATCH/DELETE
/v1/plugins`. POST builds the live instance *before* writing the
DB row so a bad config / broken plugin returns 422 / 500 without
leaving an orphan row. PATCH on `config` re-instantiates (PLAN.md
option 1); on `users`/`paused` rewires without touching __init__.
All admin-gated and audited.
- `api/curfew_api/routes/locks.py` — schedule
`runtime.dispatch_for_user` via FastAPI `BackgroundTasks` after
the lock/unlock commit. The HTTP response returns immediately; the
reconcile fires after.
- `api/curfew_api/app.py` — switch to FastAPI lifespan context. At
startup: discover plugins, build the runtime, hydrate it from
`plugin_assignments` rows (skip + log per-row instantiation
errors so one missing folder doesn't break startup), start a
safety-net loop that reads `plugin_resync_seconds` fresh each
iteration. At shutdown: cancel cleanly.
- `plugins/reftest-plugin/plugin.py` — replace the no-op reconcile
with sentinel-file logic. Locked → write user's name to
`Config.sentinel_path`; unlocked → remove it (idempotent). What
the e2e test asserts against.
- `core/curfew/schemas.py` — `PluginAssignmentRead`,
`PluginAssignmentCreate`, `PluginAssignmentUpdate`.
- `core/curfew/__init__.py` — export the new runtime + schemas.
- `api/tests/conftest.py` — `client` fixture now uses TestClient as
a context manager so FastAPI lifespan fires; without it
`app.state.plugin_runtime` would never be built.
`client_with_repo_plugins` variant points
`CURFEW_PLUGINS_DIRS` at the repo's `plugins/` so reftest_plugin
is discovered.
Notable design choices:
- **Build then DB then register**: `build_instance` is split from
`register` so the API can pre-validate + instantiate, then write
the DB row, then commit the runtime mutation — keeping the
in-memory map and the `plugin_assignments` table in lock-step
even when 422/409 paths fire.
- **Re-instantiate on PATCH config (PLAN.md option 1)**. Plugin
authors keep `__init__` simple; the operator gets new config
without restarting curfew-core.
- **Per-call timeout via `asyncio.wait_for`**. Reads
`plugin_reconcile_timeout_seconds` from the singleton settings
row each call so PATCHes take effect without restart.
- **Safety-net resync reads `plugin_resync_seconds` fresh each
loop iteration**. Same propagation pattern the agent SDK will use
for `agent_tick_seconds` once the agent surface lands.
- **Filter managed-users in Python, not SQL**. Smaller mypy
surface; user table is tiny at homelab scale.
- **Reconcile errors caught + audited, never raised**. Originating
HTTP request has already returned by the time the BackgroundTask
runs; raising would just kill the task. `plugin.reconcile_failed`
rows are how the operator confirms a specific reconcile
succeeded.
- **Hydrate on startup is forgiving**. A `plugin_assignments` row
whose plugin folder was removed logs a warning and is skipped —
the row stays in the DB so the operator can decide whether to
fix the folder or unassign explicitly.
Verified:
- `just check` clean (ruff, ruff format, mypy, 250 pytest, 23
vitest, web build).
- New test count: 20 in test_plugin_runtime.py, 17 in
test_plugins_assignments.py, 6 in test_plugins_reconcile.py
(43 total).
- e2e covers kernel acceptance steps 11-16: assign reftest_plugin,
lock kid1 → sentinel appears, unlock → sentinel removed, paused
plugin doesn't reconcile, safety-net resync picks up missed
events, failed reconcile audited but lock 200s, hydrate on
startup brings rows back to life.
Out of scope (later features): `requirements.txt` auto-install,
plugin SDK helpers for HTTP retry/timeout (driven by adguard
landing), `target_apps` on Scope (PLAN.md per-rule user config
deferred until first non-kernel rule), concrete plugins (adguard,
smart-plug, router-acl).
1 task done
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Closes the plugin-surface kernel commitment (PLAN.md step 11). PR #23 shipped discovery +
GET /v1/plugins/types; this slice adds the runtime that turns discovered types into running plugins, and wires reconcile dispatch into the lock-toggle path. After this PR the kernel acceptance plugin path (PLAN.md §"Acceptance: kernel done" steps 11-16) passes end-to-end.What's in
core/curfew/plugin_runtime.py—PluginRuntime. In-memory{(type, instance_id): AssignedInstance}map, async-locked. Public surface:build_instance— validate config + construct, no side effects.register— add a pre-built instance to the map.unassign— drop.update— re-instantiate iffconfigchanged; else just rewireusers/paused.dispatch_for_user— find governing instances, schedule reconcile concurrently with timeout.safety_net_resync— walk every managed user, dispatch.plugin.reconcile_failed; never raised.api/curfew_api/routes/plugins.py—GET/POST/PATCH/DELETE /v1/plugins. POST builds the live instance before writing the DB row so a bad config / broken plugin returns 422 / 500 without leaving an orphan row. PATCH onconfigre-instantiates (PLAN.md option 1); onusers/pausedrewires without touching__init__. All admin-gated and audited.api/curfew_api/routes/locks.py— scheduleruntime.dispatch_for_uservia FastAPIBackgroundTasksafter the lock/unlock commit. HTTP response returns immediately; reconcile fires after.api/curfew_api/app.py— switch to FastAPI lifespan context. At startup: discover plugins, build the runtime, hydrate fromplugin_assignmentsrows (skip + log per-row instantiation errors so one missing folder doesn't break startup), start a safety-net loop readingplugin_resync_secondsfresh each iteration. Cancels cleanly on shutdown.plugins/reftest-plugin/plugin.py— replace the no-op reconcile with sentinel-file logic: locked → write user's name toConfig.sentinel_path; unlocked → remove it (idempotent). What the e2e test asserts against.core/curfew/schemas.py—PluginAssignmentRead,PluginAssignmentCreate,PluginAssignmentUpdate.api/tests/conftest.py—clientfixture now wrapsTestClientas a context manager so FastAPI lifespan fires (without itapp.state.plugin_runtimewould never be built).client_with_repo_pluginsvariant pointsCURFEW_PLUGINS_DIRSat the repo'splugins/soreftest_pluginis discovered.Notable design choices
build_instanceis split fromregisterso the API can pre-validate + instantiate, then write the DB row, then commit the runtime mutation — keeping the in-memory map and theplugin_assignmentstable in lock-step even when 422/409 paths fire.__init__simple; the operator gets new config without restarting curfew-core.asyncio.wait_for. Readsplugin_reconcile_timeout_secondsfrom the singleton settings row each call so PATCHes take effect without restart.plugin_resync_secondsfresh each loop iteration. Same propagation pattern the agent SDK will use foragent_tick_secondsonce the agent surface lands.BackgroundTaskruns; raising would just kill the task.plugin.reconcile_failedrows are how the operator confirms a specific reconcile succeeded.Out of scope (later features)
requirements.txtauto-install, plugin SDK helpers for HTTP retry/timeout (driven by adguard landing),target_appsonScope(PLAN.md per-rule user config deferred until first non-kernel rule), concrete plugins (adguard, smart-plug, router-acl).Test plan
just checkclean (ruff, ruff format, mypy, 250 pytest, 23 vitest, web build).test_plugin_runtime.py, 17 intest_plugins_assignments.py, 6 intest_plugins_reconcile.py(43 total).kid1; unlock → sentinel removed; paused plugin doesn't reconcile; safety-net resync picks up missed events; failed reconcile audited but the lock request still 200s; hydrate on startup brings rows back to life.just serve; assign reftest_plugin via curl; lock a managed kid; observe sentinel file; unlock; observe sentinel removed.