Skip to content

Plugin Operations

This page describes how plugin lifecycle changes (enable, disable, uninstall) behave at runtime across a FrankenPHP worker pool, and the operator contract for making an administrative change propagate immediately.

For building a plugin see Plugin-Development.md; for distribution see Plugin-Distribution.md.


Whity Core runs under FrankenPHP with a pool of long-lived worker processes (worker ... {$FRANKENPHP_WORKERS:8} in the Caddyfile). Each worker owns its own in-memory PluginLoader, which holds the per-plugin PluginLifecycle state machine (Active, Disabled, Failed, …). That state is worker-local: it lives for the lifetime of the worker and is not shared between workers.

A request to GET /api/plugins is answered by whichever worker the proxy routed it to, so the listing reflects that worker’s view. The response carries a typed indicator of this:

{
"data": [ /* … plugin entries … */ ],
"meta": {
"worker_local": true,
"note": "Plugin lifecycle state is per-worker. Admin enable/disable/uninstall persist to disk and converge across workers on reload or worker restart; auto-fail (consecutive-error) state is per-worker until the shared store lands."
}
}

Disk is the source of truth for admin changes (WC-210)

Section titled “Disk is the source of truth for admin changes (WC-210)”

Administrative enable / disable / uninstall now persist to disk, so disk is the authoritative record and every worker converges on it independently:

  • Single-file plugins (plugins/Foo.php): disabling renames the file to plugins/Foo.php.disabled. Discovery skips *.php.disabled, so a fresh worker never registers it. Re-enabling renames it back. The plugin still appears in GET /api/plugins with enabled: false / status: "disabled" — it does not vanish from the listing.
  • Directory plugins (plugins/Foo/FooPlugin.php): renaming the entry file would break PSR-4 autoloading, so disabling instead writes an empty sentinel marker plugins/Foo/.disabled into the folder. Discovery honours the sentinel: the plugin is loaded straight into the Disabled lifecycle state with its routes not registered — exactly as if disablePlugin() had been called in that worker. Re-enabling removes the sentinel.

Because the signal lives on disk, any worker that performs a load() or reload() after the change converges on the new state — it does not matter which worker handled the original admin request.

Environment Convergence trigger
Development (APP_ENV=development) The worker loop calls reload() at the start of every request (public/index.php), so added/removed plugins are picked up almost immediately. An edit to an already-loaded plugin cannot be redefined in-process: reload() invalidates the file’s opcache entry and requests a worker recycle, and after the response is sent the worker breaks its loop so FrankenPHP respawns a fresh worker that recompiles the new source (WC-212).
Production A worker re-reads disk on its next recycle. Workers recycle after MAX_REQUESTS (default 500, see docker-compose.yml) or when the memory limit is hit. POST /api/plugins/reload reloads only the worker that handles that request — not the whole pool.

POST /api/plugins/reload is not a fleet-wide broadcast; it converges a single worker. For an administrative change to take effect across the entire pool immediately, the operator contract is a full worker-pool restart, e.g.:

Terminal window
docker compose up -d --force-recreate frankenphp

Without a restart, the change still propagates — but lazily, as each worker recycles. Disk remains the source of truth throughout, so there is no risk of a worker resurrecting a disabled plugin: a recycled worker re-reads the .php.disabled rename / .disabled sentinel and stays converged.

The manifest cache self-invalidates (WC-213)

Section titled “The manifest cache self-invalidates (WC-213)”

The on-disk plugin manifest (plugin_manifest.json) stores a filesystem fingerprint — a mtime:size signature per plugin file — alongside its FQCN -> path map. On a warm cache, discovery recomputes the fingerprint and trusts the cached map only when it matches exactly. Any added, removed, or in-place-modified file (a changed class, namespace, or an extra plugin dropped beside an existing one) shifts the signature and forces a full rescan, so a freshly-booted worker never serves a stale map left behind by a previous worker or deploy. A manifest written before WC-213 (no fingerprint key) is treated as a miss and rebuilt.

A plugin that throws repeatedly is auto-failed by the error boundary after PluginLifecycle::MAX_CONSECUTIVE_ERRORS consecutive errors (recordError() flips it to Failed). This is intentionally not persisted to disk: a single flaky worker must not be able to disable a plugin fleet-wide. Auto-fail therefore remains per-worker and transient — a fresh worker loads the plugin as Active.

Sharing auto-fail (and lifecycle state generally) across the pool requires a shared store and is deferred to the Phase-F work. Until then, to clear an auto-failed plugin everywhere, either re-enable it (which converges via the disk signal if it had also been administratively disabled) or restart the worker pool.

Agent context: prompts and resources (WHIT-993)

Section titled “Agent context: prompts and resources (WHIT-993)”

A code plugin’s MCP prompts and resources go straight into AI agents’ context, so they are served only from Verified plugins the operator grants, on the server. Verified means a live mark on the exact package that was installed: the Whity store’s signed mark (WHIT-1014, fetched every 15 minutes, so a store revocation counts at the next fetch) or a Whity mark recorded with the install. The older plugins.mcp_prompts_require_mark setting still applies on top, but a granted plugin is Verified anyway. An unverified plugin can’t be granted, and a grant stops counting the moment its mark is revoked:

Terminal window
whity-cli plugin agent-context list
whity-cli plugin agent-context grant PriceBook
whity-cli plugin agent-context revoke PriceBook

Upgrade note: before this, plugin prompts reached agents without a grant. After upgrading they stop until you grant the plugin. The grant is install-wide, and each grant, refusal and revoke is in the audit log. A revoke applies to the next call. A grant applies once workers reload plugins (plugin reload) or restart. Grant only plugins you would let speak to every agent in every workspace. Even then, each prompt and resource still needs its own permission for each caller.

Who changes plugins, and uninstalling (S7, WHIT-869, WHIT-870)

Section titled “Who changes plugins, and uninstalling (S7, WHIT-869, WHIT-870)”

A code plugin is install-wide, so only the system tenant may install, upload, turn on or off, remove, reload or update one. Workspaces can read the list, without errors or file names. plugins:install installs from a store; plugins:upload uploads unreviewed code.

Uninstalling keeps everything for 30 days. The plugin turns off and leaves the Installed list, and Reinstall restores it as it was. When the time is up, the core.plugins.purge_removed job runs its migrations’ down() and removes its files. A down() that fails leaves the code and the row in place, and it’s tried again. plugins uninstall <id> --now skips the wait.

Updates are by hand. POST /api/plugins/{name}/update fetches the store’s latest version and checks it like an install. With no new access it applies; with new permissions or outside domains it waits for .../update/approve or .../update/reject, and the old version stays. A new version lands turned off, and turning it on runs its migrations. Permissions come from the package; outside domains are only what the store says.