Skip to content

Plugin runtime + reconcile dispatch + reftest sentinel - #24

Merged
luma-founder merged 1 commit into
mainfrom
plugin-runtime-reconcile
May 9, 2026
Merged

luma-founder merged 1 commit into
mainfrom
plugin-runtime-reconcile

Conversation

@luma-founder

@luma-founder luma-founder commented May 9, 2026 •

Copy link
Copy Markdown

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 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. 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 from plugin_assignments rows (skip + log per-row instantiation errors so one missing folder doesn't break startup), start a safety-net loop reading plugin_resync_seconds fresh 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 to Config.sentinel_path; unlocked → remove it (idempotent). What the e2e test asserts against.
  • core/curfew/schemas.py — PluginAssignmentRead, PluginAssignmentCreate, PluginAssignmentUpdate.
  • api/tests/conftest.py — client fixture now wraps 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 → DB → register, in that order. 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.
  • 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 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.

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).

Test plan

  • 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 with content 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.
  • Live smoke: just serve; assign reftest_plugin via curl; lock a managed kid; observe sentinel file; unlock; observe sentinel removed.

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).
@luma-founder
luma-founder merged commit 7484dd8 into main May 9, 2026
4 checks passed
@luma-founder
luma-founder deleted the plugin-runtime-reconcile branch May 9, 2026 00:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants