Skip to content

feat(plugins): reload plugin directories, upgrade plugin data, and expose plugin routes as OpenAPI and MCP tools - #2638

Open
jasper-yl wants to merge 30 commits into
oceanbase:masterfrom
jasper-yl:feat/plugin-reload-impl
Open

jasper-yl wants to merge 30 commits into
oceanbase:masterfrom
jasper-yl:feat/plugin-reload-impl

Conversation

@jasper-yl

@jasper-yl jasper-yl commented Nov 13, 2026 •

Copy link
Copy Markdown
Contributor

Which issue or RFC does this PR close?

Implements RFC #2579 (plugin hot reload, data upgrades, plugin route descriptions and MCP tools). Builds on #2616: this branch contains its commits, so review the commits after d4b0f1b0 and merge after #2616.

Rationale for this change

#2616 loads plugins only at Server startup, so updating a plugin needs a restart, and plugins have no hook to adapt their data to a new version. RFC #2579 lets a running Server follow the plugin directory, runs each version's upgrade before it is used, and makes plugin routes discoverable through OpenAPI and MCP.

What changes are included in this PR?

  • Reloading. Each Server process rescans the plugin directory every POWERCONTEXT_SERVER_PLUGINS_RESCAN_SECONDS (default 60, 0 off). A directory whose content digest is the same in two consecutive scans and differs from the version in use is copied to a read-only snapshot under the data directory, checked there, and added, updated, or removed without a restart. Plugin code is imported only from snapshots.
  • Switching versions. A new version's host processes take requests once ready; the old version's processes finish what they hold and stop. Workers launch from the version current at dispatch, so running Workers finish on their snapshot; the Supervisor binding is replaced only when scheduling fields change. Removed plugins unregister their family, merges, Source binding, routes, pages, and tools; Source Definition manifests stay registered.
  • Upgrade. ArtifactPlugin(upgrade=..., upgrade_timeout_seconds=600): called every time a version loads, before setup, as the plugin itself (reads and writer without a caller, at most one write transaction). Instances without host processes (the background role) run it in a short-lived child before using the new version. A failed check or upgrade keeps the previous version and is reported in last_check_failure.
  • Route descriptions and MCP. GET /v1/plugins/{name}/openapi.json describes a plugin's routes. Routes declared with mcp=True are MCP tools named <plugin>__<handler>, listed from the plugins loaded at each tools/list and called through the route itself.
  • Listing. /v1/plugins adds version_digest, declaration_digest, activated_at, last_check_failure, routes, and pages.
  • Observability. plugin.changed events with plugin_name, plugin_event, version_digest, declaration_digest; plugin.scanned with scan_ms.
  • Docs (en and zh): plugin guide sections on updating plugins, upgrade, MCP and OpenAPI; configuration and observability references.

Are there any user-facing changes?

Yes. Plugin directory changes take effect without a restart; set POWERCONTEXT_SERVER_PLUGINS_RESCAN_SECONDS=0 to keep startup-only loading. New API GET /v1/plugins/{name}/openapi.json and new fields on /v1/plugins; the Desktop contract is regenerated and qualified. No persisted format changes; release/upgrade-support.json records the review of the in-process authorization profile change.

How was this change tested?

  • Full suite on a Linux VM (Ubuntu 24.04, 4 cores) before the rebase onto d4b0f1b0 (same code, earlier hashes): uv run pytest -n 4 — 10373 passed, 995 skipped, 36 failed. 32 failures were Kimi Code, ZCode, and installation tests that need Node, which the VM lacked; with Node installed they pass (74 passed). The other 4 were the upgrade release gate, fixed in this PR (tests/test_upgrade_release_gate.py: 132 passed).
  • make docs-test on the VM: verified 1109 public pages and their internal links.
  • Hooks (uv run prek run) and uv run ty check pass locally.
  • Desktop qualification sqlite-plugin-reload-5e365242-v1: anonymous loopback, static Bearer, HTTPS with CA/base path, and enforced Provider modes passed against the wheel built from 5e365242; bundled_product_qualification_covers_every_desktop_operation passed.
  • tests/e2e/test_plugin_reload.py runs a real Server with a 0.5 s rescan: a version change takes effect without restart and its upgrade runs first; a version whose upgrade fails is not used and the previous version keeps serving; openapi.json; MCP lists and calls the plugin tool; an added plugin loads and a removed one disappears with its routes and tools.
  • The DSH, Cindy, and OpenCode bundles are rebuilt from the regenerated operation tables (make js-test, make opencode-test, and make cindy-test compare them).
  • The background-role upgrade child was checked directly: a succeeding upgrade lets the version be used, a raising one does not.

AI usage statement

Implemented with Claude Code (Claude Opus 5.5) under the author's direction; the author made the design decisions in the RFC review. Claude Code ran the tests and checks listed above.

🤖 Generated with Claude Code

@hidb4ai hidb4ai left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed e11070394247e80e1e19e0bcea4536ad6cd7399a, focusing on the changes after the #2616 head (d4b0f1b0). Requesting changes for four reproduced failures in snapshot retention, failed-update recovery, upgrade timeouts, and plugin OpenAPI references. Details are inline.

Comment on lines +85 to +90
for snapshot in plugin.iterdir():
if snapshot.resolve() in keep:
continue
try:
if snapshot.stat().st_mtime < older_than:
remove_snapshot(snapshot)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Preserve snapshots still used by another Server process

keep contains only the starting process's current snapshots, but this directory is shared across Server processes. I reproduced this with two real Servers sharing POWERCONTEXT_HOME: A loaded v1 with rescanning disabled; after deploying v2, starting B deleted A's v1 snapshot. A's previously successful handler reading a file under ctx.plugin_dir then returned HTTP 500, while B served v2 successfully. The same exposure applies to an old host or Worker still draining during a rolling update. Snapshot age and this process's current version do not establish that no other process is using it; retain snapshots across process lifetimes before deleting them. The scan-time collector also needs this guarantee because _activate() adds reused snapshots to _created.

Comment on lines +522 to +525
except Exception as error:
await self._abandon(plugin, manifest, prepared, digest, error)
if old_host is not None:
self._spawn(self._retire(old_host, old_snapshot))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Keep the old plugin when the new declaration cannot register

A declaration can pass the child-process check and still fail _register(), for example when it changes a Source Definition schema without changing that definition's name/version. On a real Server, changing the ticket input's min_length from 1 to 2 produced StoredPayloadConflictError; this branch then unloaded the working plugin, cleared its version/family/routes, and made its OpenAPI endpoint return 404. Rejecting the conflicting definition is correct, but the promised failed-update behavior is to keep the old version serving and record last_check_failure. Complete the fallible registration checks before switching, or restore the previous manifest, registrations, binding, and host on failure.

Comment on lines +489 to +495
await host.start()
# Without upgrade the first process starts in the background, so a slow start delays no request
# path; an upgrade must finish before the version is used.
if manifest.upgrade or plugin.host is not None:
failure = await host.wait_ready(READY_TIMEOUT_SECONDS + manifest.spec.upgrade_timeout_seconds)
if failure is not None:
await host.close()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Apply the upgrade deadline to the host watchdog too

Increasing wait_ready()'s timeout does not extend PluginHost._watch(): it still terminates every not-ready host after READY_TIMEOUT_SECONDS (60 seconds), and the host sends HostReady only after upgrade() and setup(). I reproduced a reload with upgrade_timeout_seconds=90 and an upgrade that sleeps for 65 seconds: the host was killed with start_timeout at 60 seconds, the plugin reported last_check_failure="start_failed", and v1 kept serving. Thus valid upgrades longer than 60 seconds cannot activate even with the documented 600-second default. Make the watchdog honor the declared upgrade allowance as well.

Comment on lines +896 to +898
if route.body == "json":
schema = route.request_schema or {}
operation["requestBody"] = {"required": True, "content": {"application/json": {"schema": schema}}}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Rebase nested model references in the plugin OpenAPI document

Pydantic schemas containing nested models use references such as #/$defs/Child. Embedding that schema here leaves the references relative to the OpenAPI document root, while $defs remains inside the individual request/response schema. I reproduced a plugin with Envelope(child: Child): its POST route returned 200, but the returned openapi.json contained two unresolved #/$defs/Child references, one for the request and one for the response. Consumers cannot resolve these otherwise valid models. Relocate definitions and rewrite references, or otherwise give each embedded schema a valid reference base; the response-schema insertion above needs the same treatment.

@jasper-yl
jasper-yl force-pushed the feat/plugin-reload-impl branch from e110703 to ffd85ec Compare November 14, 2026 08:34
@jasper-yl
jasper-yl requested a review from frf12 as a code owner November 14, 2026 08:34
jasper-yl and others added 17 commits November 14, 2026 09:09
… and change their own Artifacts

Implements the Artifact plugin part of RFC oceanbase#2549 and the plugin APIs and
Dashboard pages of RFC oceanbase#2579, and replaces the managed Artifact plugin
framework (oceanbase#2246).

- A plugin is a directory with plugin.py assigning an ArtifactPlugin. Server
  checks it in a child process; generate runs in Supervisor Workers, and
  render_artifact, search_artifacts, user changes, Source writes of the
  plugin's own types, and plugin routes run in a per-plugin host process.
- Plugins present (GET .../rendering) and search their own families: generic
  search passes the complete request to search_artifacts, and Prepared Context
  sections may list plugin families. Core builds no index for them.
- Revision v1_5_0_005 removes the previous framework's storage only when it is
  empty, adds pc_artifacts.metadata (plugin name and code digest), and accepts
  plugin: bindings; frozen revision resources are unchanged. Portable revision
  records move to schema version 3 and still restore version 2.
- Plugin APIs mount at /v1/plugins/{name}/api with a configurable body
  ceiling, and plugin pages run in a sandboxed Dashboard frame.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… cancel only timed-out calls

- Each plugin keeps one host process and starts more while every process is full and
  requests queue, up to `host_processes` (overridable per plugin with
  POWERCONTEXT_SERVER_PLUGIN_HOST_PROCESSES, capped in total by
  POWERCONTEXT_SERVER_PLUGIN_TOTAL_HOST_PROCESSES); extra processes stop after 5 idle minutes.
- A call past its time limit is cancelled in the host and returns 504 `plugin_timeout`;
  other calls continue. A host whose heartbeat stops for 5 seconds is terminated, its other
  calls return 503, and it restarts after a backoff.
- Prepared Context leaves out a plugin section whose plugin is unavailable and records the
  failed search in the receipt.
- `Cron` without a time zone uses the machine's time zone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…n tables

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…d lifecycle writes

Plugin route writers act as the caller, but set_tags, bind_handle, and the
direct lifecycle transition (retirement, deprecation with a replacement)
wrote through repositories without the shared Artifact access check, so a
Scope contributor could retag or retire another user's Artifact through a
plugin route. These writes now run the same artifact.write check as content
changes; Worker writers keep their unchanged security.

The new acceptance test also restores the read-side assertions removed with
the old plugin authorization suite: a viewer of one Scope gets 403 for plugin
Artifacts, renderings, search, and Prepared Context sections of another.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s and report the committed revision

The host checked expected revisions and governance versions before calling
the plugin, but the writer then decided forget, merge, and update against the
rows it reread, so a write committed after the precheck could be forgotten or
merged by a stale request. User changes now carry the caller's expectations
into the transaction, which rejects a changed target with 409 (412 for
updates) whether or not the plugin repeats the check.

Responses no longer reread the head after the plugin returns: the writer
keeps the snapshot and records its agreed write committed, and the Server
builds replace, create, forget, and merge responses from them, so a later
write cannot appear as the result of an earlier request.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… on plugin writes

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Schema version 2 artifact_revision records were verified against their
archived digest and then staged as version 3 with that same digest, so
restoring any archive exported by v1.4.1 through v1.5.1 failed with "record
digest does not match content", and the restored revisions would have been
reported as immutable conflicts on a repeat restore. A verified earlier-schema
record is now re-identified at the current schema for staging and target
comparison, while the bundle total digest still covers the archived digests.

The regression test restores, re-restores, and re-exports an archive produced
by the released v1.5.1 runtime.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…it for the plugin API contract

The OpenAPI changes removed the managed plugin operations and custom family
schemas and opened Artifact families to loaded plugins, but the Dify tool
declarations and contract, the Desktop IPC types, and the Bub access audit
still described the old contract. Regenerate them, and classify the new
plugin listing, detail, and Scope progress operations as returning no Scope
content.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A plugin family's create and replace bodies accept any content object, so a
built-in family's request also matched the plugin branch and failed oneOf's
exactly-one rule in JSON Schema validators; the Dify tools rejected every
built-in Artifact edit as an invalid request. The Server selects the branch by
family name, which anyOf states accurately. Generated Python models keep the
same unions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ct request bodies

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The host process groups added tzlocal to the builtin extras without relocking
the projects that depend on powercontext by path, so their --locked checks
failed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…odies

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ness and fix new test typing

The Prepared Context client drops an unused per-section search so an older
Server accepts the request; the Bub compatibility check still compared the
payload with the full model dump.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…n runtime and its Dashboard titles

Point plugin imports at powercontext.builtin.authz, pass the section to the
Prepared Context budget evaluation's section builder, and title plugin pages
through the Dashboard's document_title.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e after rebasing onto master

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…tion tables

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
jasper-yl and others added 13 commits November 14, 2026 09:16
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…, and expose routes as OpenAPI and MCP tools

- Each Server process rescans the plugin directory every POWERCONTEXT_SERVER_PLUGINS_RESCAN_SECONDS
  (default 60, 0 off). A directory whose content is stable for two scans is copied to a read-only
  snapshot, checked, and added, updated, or removed without a restart. Code is imported only from
  snapshots; a new version's host process takes requests once ready while the old one drains, and
  Workers launch from the version current at dispatch.
- `upgrade(ctx)` runs every time a version loads, before setup, as the plugin itself; instances without
  host processes run it in a short-lived child. A failed check or upgrade keeps the old version and is
  reported as `last_check_failure`.
- GET /v1/plugins/{name}/openapi.json describes a plugin's routes; routes with `mcp=True` are MCP tools
  named <plugin>__<handler>, listed from the plugins loaded now.
- /v1/plugins reports version and declaration digests, activation time, routes, and pages.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…support

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Read-only directories made the data directory impossible to remove with ordinary tools;
snapshot files stay read-only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…cription operation

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…the review fixes

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ess audit

get_plugin_openapi is authorized for any authenticated caller, as the Access
API is, but returns only a plugin's route descriptions and no Scope content.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…-in runtime

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e after rebasing onto master

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… description operation

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… in their own section

Rebasing onto master's grouped navigation dropped the closing tag of the group
loop, so every Dashboard page with plugin pages failed to render.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@jasper-yl
jasper-yl force-pushed the feat/plugin-reload-impl branch from ffd85ec to f7a41a6 Compare November 14, 2026 18:29

This branch has not been deployed

No deployments
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