Skip to content

Plugin SDK + discovery (GET /v1/plugins/types) - #23

Merged
luma-founder merged 1 commit into
mainfrom
plugin-sdk-discovery
May 9, 2026
Merged

luma-founder merged 1 commit into
mainfrom
plugin-sdk-discovery

Conversation

@luma-founder

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

Copy link
Copy Markdown

Summary

First half of the plugin-surface kernel commitment (PLAN.md step 10). Lands what plugin authors import + the loader that walks CURFEW_PLUGINS_DIRS at startup. Operator-facing surface is just GET /v1/plugins/types — assignment CRUD + reconcile dispatch + the sentinel-writing reftest plugin land in step 11, after which the kernel acceptance plugin path passes end-to-end.

What's in

  • core/curfew/plugin.py — SDK module plugin authors import: Plugin base class with async def reconcile(scope) -> ReconcileResult, Scope (user, locked, reasons — no target_apps yet, deferred until first concrete consumer per PLAN.md), ReconcileResult (with ok()/error(msg) helpers), PluginManifest.
  • core/curfew/plugin_loader.py — discover_plugins(dirs) walks colon-separated dirs, parses manifest.toml, imports plugin.py under a synthetic curfew_plugin_<type> module, picks the leaf Plugin subclass (so authors can have helper bases like BaseAdGuardClient(Plugin) plus the leaf), resolves the Pydantic config model by name. Per-plugin errors captured on the registry entry, never raised — one bad plugin folder doesn't break startup. Later-wins on duplicate type per ADR-013.
  • GET /v1/plugins/types — operator-visible list of discovered types including any per-plugin errors. Reads from app.state.plugin_registry built once at startup.
  • plugins/reftest-plugin/ — minimal stub (no-op reconcile) so the default CURFEW_PLUGINS_DIRS entry has a valid plugin proving discovery works. Step 11 turns this into the sentinel writer.

Notable design choices

  • Rename models.Plugin → models.PluginAssignment to free up the Plugin name for the SDK base class (what authors import per PLUGINS.md). DB row represents the operator's assignment — name matches CLI verbs (assign/unassign). Tablename plugins unchanged; no migration.
  • No requirements.txt auto-install. ADR-013 lists it as optional; running pip-install at every startup is slow + a failure mode for no immediate gain. Reftest has no deps; real plugins land this when they need it. Loader logs an info line and records has_requirements_txt on the registry entry so the operator sees it.
  • Errors don't break startup. Non-negotiable for a homelab. Loader catches per-plugin and surfaces via /v1/plugins/types.
  • Leaf-class entry-point selection. Filters to subclasses defined in the plugin's own module so importing Plugin itself doesn't count. Zero subclasses → error; multiple leaves → error with names listed.

Drive-by fix

  • api/tests/test_migration.py + one test in core/tests/test_config.py now monkeypatch.delenv("CURFEW_DB_PATH") so they're not redirected by api/migrations/env.py's env-var override. just test was silently failing 12 migration tests + 1 config test on every PR since the justfile started pinning CURFEW_DB_PATH; without this fix just check is broken for the whole repo.

Out of scope (step 11)

Plugins-table CRUD endpoints, in-memory instance manager (instantiate on assign / re-instantiate on PATCH / drop on unassign), reconcile dispatch via BackgroundTasks on lock-toggle, safety-net resync as lifespan task, per-call timeout, reftest sentinel logic, e2e exercising kernel acceptance steps 11–16.

Test plan

  • just check clean (ruff, ruff format, mypy, 207 pytest, 23 vitest, web build)
  • 6 new SDK tests (test_plugin_sdk.py)
  • 18 new discovery tests (test_plugin_loader.py) — missing manifest, malformed manifest, no Plugin subclass, multiple leaves → error, helper-base + leaf, config_schema missing or wrong type, override-on-duplicate, multi-dir, per-plugin error containment
  • 5 new endpoint tests (test_plugins_types.py) — auth gate, empty registry, successful plugin shape, failed plugin shape, alphabetical ordering
  • Live smoke: drop a deliberately-broken folder into plugins/, restart, see it surfaced with its error in GET /v1/plugins/types

First half of the plugin-surface kernel commitment (PLAN.md step 10).
Lands what plugin authors import + the loader that walks
CURFEW_PLUGINS_DIRS at startup. Operator-facing surface is just
`GET /v1/plugins/types` — assignment CRUD + reconcile dispatch + the
sentinel-writing reftest plugin land in step 11, after which the kernel
acceptance plugin path passes end-to-end.

What's in:

- `core/curfew/plugin.py` — the SDK module plugin authors import. One
  base class (`Plugin`) with `async def reconcile(scope) -> ReconcileResult`,
  plus `Scope` (`user, locked, reasons` — no `target_apps` yet, deferred
  until first concrete consumer per PLAN.md), `ReconcileResult` (with
  `ok()` and `error(msg)` helpers), `PluginManifest` dataclass.
- `core/curfew/plugin_loader.py` — `discover_plugins(dirs)` walks
  colon-separated dirs, parses manifest.toml (required: type, name,
  version, description, config_schema), imports plugin.py via
  `spec_from_file_location` under a synthetic `curfew_plugin_<type>`
  module name, picks the leaf `Plugin` subclass (so plugins can have
  internal helper bases like `BaseAdGuardClient(Plugin)` plus a leaf),
  resolves the Pydantic config model by name. Per-plugin errors are
  captured on the registry entry, never raised — one bad plugin folder
  doesn't break startup (PLUGINS.md "What can go wrong"). Later-wins on
  duplicate `type` per ADR-013.
- `GET /v1/plugins/types` — operator-visible list of discovered types
  including any per-plugin discovery errors. Reads from
  `app.state.plugin_registry` built once at startup.
- `plugins/reftest-plugin/` — minimal stub (no-op reconcile) so the
  default `CURFEW_PLUGINS_DIRS` entry has at least one valid plugin to
  prove discovery works. Step 11 turns this into the sentinel writer.

Notable design choices:

- **Rename `models.Plugin` → `models.PluginAssignment`** to free up the
  `Plugin` name for the SDK base class (which is what plugin authors
  import per PLUGINS.md). The DB row represents the operator's
  *assignment* of a plugin type to a set of users — the name matches
  the CLI verbs (`assign`/`unassign`). Tablename `plugins` is unchanged;
  no migration needed.
- **No `requirements.txt` auto-install.** PLAN.md ADR-013 lists it as
  optional; running pip-install at every startup is slow + a failure
  mode for no immediate gain. Reftest has no deps; real plugins land
  this when they need it. Loader logs an info line and records
  `has_requirements_txt` on the registry entry so the operator sees it.
- **Errors don't break startup.** Non-negotiable for a homelab — one
  operator-authored plugin shouldn't take down the whole core. Loader
  catches per-plugin and surfaces via `/v1/plugins/types`.
- **Leaf-class entry-point selection.** Filters to subclasses defined
  in the plugin's own module (`__module__` check) so importing
  `Plugin` itself doesn't count. Zero subclasses → error; multiple
  leaves → error with names listed.

Drive-by fix:

- `api/tests/test_migration.py` and one test in `core/tests/test_config.py`
  now `monkeypatch.delenv("CURFEW_DB_PATH")` so they're not redirected
  by `api/migrations/env.py`'s env-var override. `just test` was
  failing 12 migration tests + 1 config test on every PR since the
  justfile started pinning `CURFEW_DB_PATH`; without this fix `just
  check` is broken for the whole repo.

Verified:

- `just check` clean (ruff, ruff format, mypy, 207 pytest, web-check
  with 23 vitest + build).
- New test count: 6 in test_plugin_sdk, 18 in test_plugin_loader, 5 in
  test_plugins_types — covering missing manifest, malformed manifest,
  no Plugin subclass, multiple leaves → error, helper-base + leaf,
  config_schema missing or wrong type, override-on-duplicate,
  per-plugin error containment.

Out of scope (step 11): plugins-table CRUD endpoints, in-memory
instance manager (instantiate on assign / re-instantiate on PATCH /
drop on unassign), reconcile dispatch via BackgroundTasks on
lock-toggle, safety-net resync as lifespan task, per-call timeout,
reftest sentinel logic, e2e exercising kernel acceptance steps 11–16.
@luma-founder
luma-founder merged commit 1af0a5d into main May 9, 2026
4 checks passed
@luma-founder
luma-founder deleted the plugin-sdk-discovery branch May 9, 2026 00:11
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