From e52344a9c2016e9e7819145d02bae12f892a2936 Mon Sep 17 00:00:00 2001 From: jasper-yl Date: Tue, 10 Nov 2026 00:40:38 -0800 Subject: [PATCH 1/9] docs(rfc): propose plugin hot reload and plugin-defined APIs Co-Authored-By: Claude Opus 5.5 --- docs/en/rfcs/0000-plugin-reload-and-apis.md | 519 ++++++++++++++++++++ docs/zh/rfcs/0000-plugin-reload-and-apis.md | 453 +++++++++++++++++ 2 files changed, 972 insertions(+) create mode 100644 docs/en/rfcs/0000-plugin-reload-and-apis.md create mode 100644 docs/zh/rfcs/0000-plugin-reload-and-apis.md diff --git a/docs/en/rfcs/0000-plugin-reload-and-apis.md b/docs/en/rfcs/0000-plugin-reload-and-apis.md new file mode 100644 index 0000000000..083941878b --- /dev/null +++ b/docs/en/rfcs/0000-plugin-reload-and-apis.md @@ -0,0 +1,519 @@ +--- +title: Plugin hot reload and custom APIs +description: Building on the host processes of plugin interfaces RFC #2549, plugins can be added, updated, and removed at runtime, and can declare their own HTTP APIs and Dashboard pages, which core mounts, authorizes, and isolates. +--- + +- Proposal Name: `plugin_reload_and_apis` +- Start Date: 2026-11-10 +- RFC PR: [oceanbase/powercontext#0000](https://github.com/oceanbase/powercontext/pull/0000) + +# Summary + +This proposal adds three capabilities on top of [plugin interfaces RFC #2549](https://github.com/oceanbase/powercontext/pull/2549): + +- **Hot reload**: each Server process scans the plugin directory periodically and applies added, updated, or removed + plugins at runtime, with no Server restart. Plugin code is imported only in check child processes, host processes, and + Workers, so updating a plugin means replacing those child processes; no module is replaced inside a running Python + process. +- **Plugin-defined APIs**: a plugin can declare its own HTTP routes. Core mounts them at + `/v1/plugins/{name}/api/scopes/{scope_id}/...`, performs authentication and Scope authorization first, and then hands + the request to the plugin's host process. +- **Plugin pages**: a plugin can ship static pages with its code, shown in the Dashboard in a sandboxed iframe. A page can + call only its own plugin's APIs, through forwarding by the hosting page, and never gets the user's session. + +Deployers still only place plugin directories under `POWERCONTEXT_SERVER_PLUGINS_DIR`; core stores no plugin state and +does not distribute plugin code. + +This document describes the target design; it does not mean the mainline or a release already provides it. Interface +names and code examples illustrate the contract; the implementation determines the final syntax. + +# Motivation + +RFC #2549 defines the plugin interfaces with two limits: + +- **Changes require a restart**: adding, updating, or removing a plugin restarts Server, and every API is briefly + unavailable. When plugins change often, this hurts a shared central memory service. +- **Plugins are reachable only through existing APIs**: callers can use only core's existing Source and Artifact APIs. + Many plugins need domain-specific operations, for example: + - a document knowledge base needs file upload, paged reads of chunks at a fixed revision, reads of original files, and + hybrid search across several knowledge bases; + - a plugin needs to show users its own pages for processing progress, configuration, or search results. + + These operations do not fit core's generic APIs, and plugins have no other entry point. + +RFC #2549 already lays the groundwork for hot reload and plugin-defined APIs: + +- the Server process imports no plugin code, synchronous calls run in host processes core manages, and each background + call starts a new Worker; +- each Server process keeps an in-memory record of its active plugins and persists no plugin state. + +Hot reload in the old Artifact plugin framework (RFC #2175) relied on persisted desired state, management phases, and +registration generations. This proposal does not bring those mechanisms back: the plugin set is decided by the directory +contents, and switching happens only at the child process level. + +Goals: + +1. Deployers place, replace, or remove a plugin directory, and Server applies it within one scan interval, with no restart + and no management API call. +2. A call already started during an update either completes with the old code or fails and is retried; results from old + code are never committed under a new declaration. +3. Plugins can declare their own HTTP APIs; core handles routing, authentication, authorization, request size, and time + limits, and plugins write only the handlers. +4. Writes to core data from plugin APIs follow the same permission and provenance rules as user changes in RFC #2549. +5. Plugin pages are isolated from the Dashboard: page code cannot read the user's session or call any Server API other + than its own plugin's APIs. + +# Guide-level explanation + +## 1. Hot reload + +Deployers work on the plugin directory directly: + +| Action | Effect | +| --- | --- | +| Add a new plugin directory | Checked and loaded at the next scan; once the check passes, its Source types, family, APIs, and pages become available | +| Replace the contents of an existing plugin directory | Checked again at the next scan; once the check passes, new synchronous and background calls use the new code | +| Remove a plugin directory | Unloaded at the next scan; its Source types, family, APIs, and pages stop being available, and saved data is kept | + +- **When it applies**: each Server process scans every `POWERCONTEXT_SERVER_PLUGINS_RESCAN_SECONDS` (30 seconds by + default). Setting it to 0 turns hot reload off, and the behavior matches RFC #2549. +- **Replace as a whole**: a plugin directory is checked only after its contents are unchanged across two consecutive + scans. Deployers should write the new version to a temporary directory and rename it into place, so a scan never sees + half-written files. +- **A failed update keeps existing data usable**: when a new version fails its check, the plugin becomes `degraded`: + reads and search keep working (they run no plugin code), synchronous calls and plugin APIs return 503, and background + calls pause until a later version passes the check. +- **Multiple instances**: each Server instance scans the plugin directory on its own machine. Deployers distribute the + code to every instance; until every instance has scanned, different instances may briefly run different versions. + +## 2. Plugin-defined APIs + +A plugin adds `api` to its declaration and lists its routes; the handlers are methods on the object `create(ctx)` +returns: + +```python +from pydantic import BaseModel + +from powercontext.plugins import ApiRequest, BinaryResponse, PluginApi, Route, SourcePlugin + + +class UploadResult(BaseModel): + source_id: str + + +class Documents: + def __init__(self, ctx) -> None: + self.ctx = ctx + + async def upload(self, request: ApiRequest) -> UploadResult: + # Original bytes go into the plugin's dedicated storage; the Source records only the location and digest + location, digest = await save_original(self.ctx.storage, request.body, request.query["name"][0]) + ref = await self.ctx.sources.submit("document-input", DocumentInput(location=location, sha256=digest)) + return UploadResult(source_id=ref.source_id) + + async def original(self, request: ApiRequest) -> BinaryResponse: + content, media_type = await load_original(self.ctx.storage, request.path_params["source_id"]) + return BinaryResponse(content=content, content_type=media_type) + + +async def create(ctx) -> Documents: + return Documents(ctx) + + +plugin = SourcePlugin( + definitions=(DocumentInputDefinition(),), + api=PluginApi(routes=( + Route(method="POST", path="/documents", handler="upload", access="scope.contribute", + body="binary", response_model=UploadResult), + Route(method="GET", path="/documents/{source_id}/original", handler="original", access="scope.read"), + )), + create=create, +) +``` + +Callers use it like this: + +```text +POST /v1/plugins/documents/api/scopes/team-a/documents?name=sop.md (the body is the file bytes) +GET /v1/plugins/documents/api/scopes/team-a/documents/{source_id}/original +GET /v1/plugins/documents/openapi.json (the OpenAPI description of this plugin's APIs) +``` + +- Every route is mounted under a Scope. Core authenticates the caller, checks the caller's permission on the Scope in the + path (the route's declared `access`), and then hands the request to the plugin's host process. +- Handlers can read and write core data under the same rules as user changes in RFC #2549: writes are made as the caller, + and core checks permissions and records provenance. +- When a handler involves other Scopes (for example, searching several knowledge bases), it checks the caller's + permission on each with `ctx.require(...)`. +- Plugin APIs are not part of `openapi/powercontext.yaml`, and generated clients and MCP do not include them; core + generates each plugin's API description from its declaration. + +## 3. Plugin pages + +A plugin adds `pages` to its declaration, pointing to static files in the plugin directory: + +```python +from powercontext.plugins import PluginPages + +plugin = SourcePlugin( + ..., + pages=PluginPages(directory="ui", entry="index.html", title={"zh": "文档", "en": "Documents"}), +) +``` + +- The plugin's page appears in the Dashboard navigation. When opened, the Dashboard loads it in a sandboxed iframe under + the current Scope. +- The plugin page runs in an opaque origin: it cannot read the Dashboard's cookies or call Server directly. It asks the + Dashboard to forward requests through `postMessage`, and the Dashboard forwards only to this plugin's APIs, as the + signed-in user. +- Pages are static files only (HTML, JavaScript, CSS, images, fonts). Core serves them directly from the plugin directory + and runs no plugin code for them. + +Calls from a page: + +```javascript +const client = powercontextPluginClient(); // a small script core provides that wraps postMessage +const { scopeId } = await client.context(); +const result = await client.request("GET", `/documents/${sourceId}/original`); +``` + +# Reference-level explanation + +## 1. Hot reload + +### 1.1 Scanning and checking + +```text +In each Server process, every rescan interval (and at startup): + -> list the direct subdirectories of the plugin directory and compute a fingerprint for each: the relative path, + size, and modification time of every file (excluding __pycache__) + -> the fingerprint matches the last scan and the active version: skip + -> the fingerprint differs from the last scan: mark it as settling and do not check it this round + -> the fingerprint is the same in two consecutive scans and differs from the active version: check it in a + time-limited check child process (the same check as RFC #2549's startup check) + -> add, update, degrade, or remove according to the result +``` + +- The fingerprint uses only file metadata and reads no file contents, so scan cost is proportional to the number of files + in the plugin directory. Plugin dependencies are installed in Server's Python environment, not in the plugin directory, + and are not scanned. +- The check child process computes a declaration digest over the serializable part of the declaration. It is kept in the + active plugin record and used to tell whether the declaration changed. +- The first scan at startup does not wait for settling, matching RFC #2549's startup loading. + +### 1.2 Switching + +| Case | Handling | +| --- | --- | +| Added, check passes | Register its Source types, family (reads, search, merge registry), APIs, and pages; register its binding when this process is the Supervisor leader | +| Updated, check passes, declaration unchanged | Replace the host process (see 1.3); background calls use the new code from the next dispatch | +| Updated, check passes, declaration changed | Re-register from the new declaration: added Source types, routes, and pages take effect and removed ones stop being available; the binding's scheduling parameters follow the new declaration; replace the host process | +| Updated, check fails | Becomes `degraded`: the read registrations of its Source types and family are kept, the host process stops, the plugin's binding pauses, and synchronous calls and plugin APIs return 503 | +| Removed | Unregister its Source types, family, APIs, pages, and binding; stop the host process after in-flight synchronous calls finish or time out | + +- Renaming a family is the same as removing the old family and adding a new one. The old family's Artifacts stay in core + and are handled as for a disabled plugin in RFC #2549. +- A removed Source type no longer accepts writes and returns 422; saved Sources are kept. +- Invocation intents left behind by a removed plugin are no longer processed, as in RFC #2549's execution constraints; + when a plugin with the same name comes back, it continues from its cursor and checkpoints. +- The leader process registers Supervisor bindings. When leadership moves, the new leader registers bindings from its own + active plugin record. + +### 1.3 Replacing the host process + +```text +The new version passes its check + -> start a new host process, import the new code, and compare the declaration + -> once the new process is ready, send new synchronous calls and plugin API requests to it + -> the old process accepts no new requests and waits for in-flight ones; it is terminated once a call exceeds its limit +``` + +If the new process fails to start, this is handled as a failed check (`degraded`), and the old process stops too: the old +code is no longer on disk, so there is no guarantee that modules it imports later match the parts already loaded. + +### 1.4 Fencing background calls + +A Worker re-imports the plugin on every call. To avoid committing results from old code under a new declaration, the +executor passes the plugin directory's fingerprint to the Worker at dispatch: + +- before importing the plugin, the Worker recomputes the fingerprint and fails without calling the plugin if it differs + from the one at dispatch; +- before committing the write transaction block, it computes the fingerprint again; if it differs, it rolls back, the call + fails, and the Supervisor redispatches after backoff. + +The Worker and the plugin directory are on the same machine, so comparing fingerprints needs no persisted state and no +registration generation. The existing Supervisor lease fence still blocks stale commits after a leadership change. A small +window remains between the last check and the commit: what is committed then is the old code's result under the old +declaration, equivalent to "finished just before the update". + +### 1.5 Differences between processes + +The active plugin record lives only in each process's memory and is not synchronized across processes: + +- Until every instance has scanned, the same request may get different results on different instances (for example, + one instance has loaded a new plugin and another has not). +- `GET /v1/plugins` and `GET /v1/plugins/{name}` return the view of the instance that handled the request. The response + gains `declaration_digest`, `state` (`loaded`, `degraded`), and `activated_at`, so deployers can compare instances. +- Background calls run only in the leader's Workers, fenced as in 1.4; synchronous calls run on the instance receiving the + request, with that instance's current version. + +## 2. Plugin-defined APIs + +### 2.1 Declaration + +```python +class Route(BaseModel): + method: Literal["GET", "POST", "PUT", "PATCH", "DELETE"] + path: str # relative path starting with /, may contain {parameters} + handler: str # a method name on the object create(ctx) returns + access: Literal["scope.read", "scope.contribute"] + body: Literal["none", "json", "binary"] = "none" + request_model: type[BaseModel] | None = None # validates a json body and feeds the API description + response_model: type[BaseModel] | None = None + timeout_seconds: float = 30 + +class PluginApi(BaseModel): + routes: Sequence[Route] +``` + +- Both `SourcePlugin` and `ArtifactPlugin` can declare `api`. A plugin that declares `api` needs a host process. +- Routes are mounted at `/v1/plugins/{name}/api/scopes/{scope_id}{path}`. `(method, path)` must be unique within a plugin; + `handler` must exist and be an async method, which the check child process verifies. +- `access` is the permission the caller must have on the Scope in the path. A route that writes core data must declare + `scope.contribute`. +- Plugins cannot declare global routes outside a Scope, nor routes that require `server.admin`. + +### 2.2 Requests and responses + +```python +class Caller(BaseModel): + principal_id: str # caller identity; carries no credentials + +class ApiRequest(BaseModel): + scope_id: str + route: str # the path template from the declaration + path_params: dict[str, str] + query: dict[str, list[str]] + body: BaseModel | bytes | None # per the body declaration: a request_model instance, bytes, or None + caller: Caller + +class BinaryResponse(BaseModel): + content: bytes + content_type: str + filename: str | None = None + +class ApiError(Exception): + def __init__(self, status: int, code: str, message: str) -> None: ... # status must be 4xx +``` + +- A handler returns an instance of `response_model` (serialized as JSON with status 200), a `BinaryResponse`, or `None` + (204). +- An `ApiError` returns its status, which must be within 400–499 and must not be 401 or 403 (core decides authentication + and authorization); any other exception returns 500, and only the exception type is logged. +- Requests and responses have size limits: 1 MiB for JSON request bodies, and + `POWERCONTEXT_SERVER_PLUGIN_API_MAX_BODY_BYTES` (32 MiB by default) for binary request bodies and responses. Beyond + them, core returns 413 without calling the plugin. +- Exceeding `timeout_seconds` returns 504; an unavailable host process or a `degraded` plugin returns 503; an unknown + plugin or route returns 404. +- Core provides no idempotency keys or request records for plugin APIs; the plugin defines retry semantics. + +### 2.3 What handlers can do + +Handlers run in the host process, and `create(ctx)` runs once per Scope and is then reused (as in RFC #2549). Besides the +RFC #2549 context (dedicated storage, models, logger), plugin API calls provide: + +| Capability | Description | +| --- | --- | +| `ctx.require(action, scope_id)` | Checks whether the caller has `scope.read` or `scope.contribute` on the given Scope and raises otherwise, so the API returns 403. Used for operations across Scopes | +| `ctx.reads(scope_id=None)` | A read-only Artifact view of the given Scope (the path's Scope by default), checked against the caller's permission before reading; `reads(...).reads(family)` reads other families | +| `ctx.writer` | The Artifact plugin's writer, provided only on `scope.contribute` routes, scoped to the path's Scope and this plugin's family | +| `ctx.sources.submit(definition, value)` | A Source plugin submits one Source of a definition it provides, with the same validation, `resolve`, and Projections as the Source write API, and gets a `SourceRef`; provided only on `scope.contribute` routes | + +Write rules: + +- Writes from plugin APIs are made as the caller, with the same permissions as the generic APIs: creating needs the Scope + contribute permission, and the caller owns the result; changing an existing Artifact needs the caller's write + permission on it (`artifact.write`). Core checks each write operation as it runs and raises when the check fails, so + the API returns 403. +- For every write operation that creates or changes content, core records a `lineage_only` Content Source under RFC + #2549's user-change rules. +- A request opens at most one write transaction block, whose writes commit in one transaction, following RFC #2549's write + transaction rules. +- Handlers cannot advance cursors or checkpoints and cannot write other plugins' families. + +### 2.4 API description + +`GET /v1/plugins/{name}/openapi.json` (authenticated; readable with any Scope permission) returns an OpenAPI document +that core generates from the route declarations: paths, methods, the JSON Schema of `request_model` and `response_model`, +and the common error responses. The plugin owns its API compatibility; for an incompatible change it can put a version in +its paths (for example `/v2/documents`). + +## 3. Plugin pages + +### 3.1 Declaration and static files + +```python +class PluginPages(BaseModel): + directory: str = "ui" # relative path inside the plugin directory + entry: str = "index.html" + title: dict[str, str] # navigation title per locale; must include en +``` + +- Static files are served at `/dashboard/plugins/{name}/ui/{path}`, only from under `directory`; `..` and symlinks that + point outside are rejected. +- Only these types are allowed: `.html`, `.js`, `.css`, `.json`, `.svg`, `.png`, `.jpg`, `.webp`, `.woff2`; each file is + at most 5 MiB. +- Static files contain only plugin code and assets, no user data, so they do not require sign-in. +- Response headers: + + ```text + Content-Security-Policy: sandbox allow-scripts allow-forms allow-downloads; + default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; + img-src 'self' data:; font-src 'self'; connect-src 'none'; frame-ancestors 'self' + X-Content-Type-Options: nosniff + ``` + + The `sandbox` directive keeps the page in an opaque origin even when opened directly; `connect-src 'none'` prevents + the page from making network requests itself; `frame-ancestors 'self'` allows only the Dashboard to embed it. + +### 3.2 The hosting page and forwarding + +```text +/dashboard/plugins/{name}?scope=... (a core Dashboard page; requires sign-in) + -> renders