diff --git a/.github/workflows/pr-build-check.yml b/.github/workflows/pr-build-check.yml
index 65a592a29f..2f9e550523 100644
--- a/.github/workflows/pr-build-check.yml
+++ b/.github/workflows/pr-build-check.yml
@@ -1028,6 +1028,23 @@ jobs:
fi
+ # --- PLUGINS ---
+ # Official plugin manifests (packages/plugins) are validated against
+ # the @propr/shared schema on every pull request, like the automation
+ # template catalog: the check reads TypeScript sources through tsx
+ # and needs no build. `--online` is for local use only.
+ echo -e "\n--- Plugins: Manifest Check ---" >> build_log.txt
+ PLUGINS_FAILED=0
+ PLUGINS_OUTPUT=$(npm run plugins:check 2>&1) || {
+ echo "❌ Plugin manifest check FAILED (run npm run plugins:check)" >> build_log.txt
+ echo "$PLUGINS_OUTPUT" >> build_log.txt
+ PLUGINS_FAILED=1
+ EXIT_CODE=1
+ }
+ if [ $PLUGINS_FAILED -eq 0 ]; then
+ echo "✅ Plugin manifests are valid" >> build_log.txt
+ fi
+
# --- API REFERENCE ---
# The OpenAPI spec is generated from the API route registry, published
# by the docs site and consumed by @propr/client, so a change to any
diff --git a/apps/desktop/scripts/packaged-acceptance-csp-reload.test.mjs b/apps/desktop/scripts/packaged-acceptance-csp-reload.test.mjs
index 958386db04..e68c7f817f 100644
--- a/apps/desktop/scripts/packaged-acceptance-csp-reload.test.mjs
+++ b/apps/desktop/scripts/packaged-acceptance-csp-reload.test.mjs
@@ -562,15 +562,27 @@ test('exact observer preserves native denied evidence across real Chromium navig
response.end(`
CSP fixture`);
});
const listen = server => new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
- const close = server => new Promise((resolve, reject) => server.close(error => error ? reject(error) : resolve()));
+ const close = server => new Promise((resolve, reject) => {
+ server.close(error => error ? reject(error) : resolve());
+ server.closeAllConnections();
+ });
+ // Teardown is bounded: an unbounded browser.close() in an after hook once
+ // held this CI job for ten minutes with only the main Chromium process left,
+ // which skipped the whole unsigned packaging matrix. The browser runs as an
+ // owned server process so a stuck close can be escalated to SIGKILL.
+ const within = (promise, ms) => Promise.race([promise.then(() => true, () => true),
+ new Promise(resolve => setTimeout(resolve, ms, false).unref())]);
+ let browserServer;
+ t.after(async () => {
+ if (browserServer && !await within(browserServer.close(), 10_000)) browserServer.process().kill('SIGKILL');
+ await Promise.all([api, document].filter(server => server.listening).map(close));
+ });
await listen(api);
- t.after(() => close(api));
const fixtureOrigin = `http://127.0.0.1:${api.address().port}`;
await listen(document);
- t.after(() => close(document));
const documentOrigin = `http://127.0.0.1:${document.address().port}`;
- const browser = await chromium.launch({ args: ['--no-sandbox'] });
- t.after(() => browser.close());
+ browserServer = await chromium.launchServer({ args: ['--no-sandbox'] });
+ const browser = await chromium.connect(browserServer.wsEndpoint());
t.diagnostic(`Chromium ${browser.version()}`);
const page = await browser.newPage();
await page.goto(documentOrigin + '/denied');
diff --git a/docs/docs/architecture/plugin-runtime.md b/docs/docs/architecture/plugin-runtime.md
new file mode 100644
index 0000000000..e501539e5e
--- /dev/null
+++ b/docs/docs/architecture/plugin-runtime.md
@@ -0,0 +1,435 @@
+---
+sidebar_label: "Plugin Runtime"
+title: "Plugin runtime: handlers and their context"
+description: "How plugin handlers run: the handler SDK, runPluginHandler, the event dispatcher (cursors, ordering, retries, lease, health), the scoped ctx.propr client, outbound HTTP through the endpoint policy, installation state, logging and redaction, and the import rule."
+---
+
+# Plugin runtime
+
+A [plugin](../features/plugins.md) can ship code: a `handlers.ts` in its folder
+that exports named async functions. This page describes how ProPR runs them and
+what they can reach. The feature page covers [writing a handler](../features/plugins.md#handlers).
+
+Official plugins are trusted code and their handlers run **in-process**, but only
+through a narrow interface: input goes in, and every effect goes out through
+`ctx`. A handler imports nothing from ProPR except the SDK types in
+`@propr/shared`, so the same plugin could later run in another process with `ctx`
+proxied to ProPR.
+
+Handlers *observe and create new work*. A handler can read a pull request, open
+an issue or start a task, but it never interacts with a task that is running.
+Ordinary task runs stay one-shot.
+
+## Components
+
+| Part | Where | What it does |
+| --- | --- | --- |
+| SDK types | `packages/shared/src/pluginSdk.ts` | `PluginContext`, the input envelopes, `PluginSdkError`, the limits and the operation → permission table. |
+| Handler loading | `packages/plugins/src/index.ts` (`loadPluginHandlers`) | Resolves a plugin's `handlers.ts` (compiled to `dist/plugins//handlers.js` in builds) and returns its exported functions. |
+| Runner | `packages/core/src/plugins/pluginHandlerRunner.ts` | `runPluginHandler(installation, handlerName, input, options)`. |
+| Context | `packages/core/src/plugins/pluginContext.ts` | Builds `ctx` for one invocation, captures and redacts its log. |
+| `ctx.propr` | `pluginProprClient.ts` (checks) on `pluginProprBackend.ts` (GitHub, event journal, task queue) | The scoped ProPR client. |
+| `ctx.http` | `pluginHttp.ts` on `extensions/endpointPolicy.ts` | Outbound HTTPS. |
+| `ctx.state` | `pluginStateStore.ts`, table `plugin_state` | Installation-scoped key-value store. |
+| Test kit | `packages/plugins/testing` | A fake `ctx` for plugin unit tests. |
+| Event dispatcher | `packages/core/src/plugins/pluginDispatcher.ts`, started by the daemon (`src/daemon/pluginEventDispatch.ts`) | Reads the journal per installation and repository, runs subscribed handlers, retries, holds the lease. |
+| Delivery records | `packages/core/src/plugins/pluginDeliveries.ts`, tables `plugin_dispatch`, `plugin_delivery_cursors`, `plugin_deliveries` | Activity records, health, Retry, Resume, retention, the Degraded notification. |
+| Activity API | `packages/api/routes/pluginActivityRoutes.ts` | `GET …/:pluginId/activity`, `POST …/activity/:id/retry`, `POST …/:pluginId/resume`. |
+
+## Invocation
+
+```ts
+runPluginHandler(installation, handlerName, input, options): Promise
+```
+
+`installation` is a plugin id (the installation is read from
+`plugin_installations` and the manifest from the registry) or an installation
+already loaded (`{ pluginId, manifest, record }`). The runner:
+
+1. Refuses a plugin that is not in the registry, not installed or not enabled, a
+ handler name that is not an identifier, and a name the module does not export
+ as a function.
+2. Builds `ctx` from the current manifest and the installation record.
+3. Calls `handler(input, ctx)` and waits at most `PLUGIN_HANDLER_TIMEOUT_MS`
+ (30 seconds). When the time runs out, `ctx.signal` is aborted.
+4. Returns `{ outcome, timedOut, durationMs, error, logs, result? }`.
+
+The runner **never throws**. A thrown error, a timeout, a missing handler and a
+result that is not JSON or is larger than 16 KiB are all `outcome: 'failed'`
+with a message. The message, the log lines and the result are redacted.
+
+When the run ends, whether it succeeded, failed or timed out, `ctx` stops working
+for anything the handler left running. A late call to `ctx.propr`, `ctx.http` or
+`ctx.state` throws `unavailable`, and late log lines are dropped. A handler that
+ignores `ctx.signal` can keep its own timers alive, but it can no longer act
+through ProPR.
+
+The [event dispatcher](#event-dispatcher) calls the runner with
+`PluginEventInput`. Later issues call it from the inbound webhook route
+(`PluginWebhookInput`) and commands (`PluginCommandInput`).
+`PluginInvokeInput` carries a JSON payload for direct calls and tests.
+
+## Event dispatcher
+
+`PluginDispatcher` delivers [journal events](./repository-event-journal.md) to
+the handlers a plugin names in `contributes.subscriptions`. The daemon starts
+one at boot and stops it on shutdown; the hourly maintenance sweep that prunes
+the journal also prunes delivery records. The feature page lists the
+[events, payloads and guarantees](../features/plugins.md#event-subscriptions).
+
+### Reading the journal
+
+The dispatcher only reads the journal. An append notifies it in the same
+process (`subscribeRepositoryEventAppends`): a pass starts within 200 ms, and
+a steady stream of appends never pushes that pass back. It also polls every 5
+seconds for appends from other processes. Nothing that appends an event waits for a
+delivery, so a handler can never fail or delay the step that produced the
+event.
+
+Each pass looks at every enabled installation whose current manifest has
+subscriptions and that holds `events.read`. For each one:
+
+1. `plugin_dispatch` holds `start_seq`, the journal's highest `seq` when the
+ plugin was installed or last enabled. Only later events are delivered. An
+ installation without a row (or with a row of an earlier installation)
+ starts at the journal's end. That row is written only in a transaction
+ that confirms the installation is still the one the pass read, and
+ enabled: a pass that read it before an uninstall, reinstall or disable
+ skips it and never writes over the replacement's start.
+2. The repositories to visit are those with events after `start_seq`, those
+ with unfinished deliveries, and those whose journal floor moved past
+ `start_seq` and past what their cursor last recorded (so a repository that
+ retention emptied is still visited), minus those outside the repository
+ limit. Repository names are case-insensitive: every spelling of a name is
+ one repository, keyed by its lower-case form. Each has a cursor in
+ `plugin_delivery_cursors`: the last `seq` whose deliveries are final.
+3. For each repository, the events after the cursor, in any spelling, are
+ read in `seq` order (50 per pass). Each event goes to every subscription whose `on` and
+ `filter` match it, in manifest order. A delivery gets a record in
+ `plugin_deliveries` the first time it is seen, keyed by its delivery id
+ `evt--`.
+4. When every delivery of an event is final (succeeded or failed), the cursor
+ moves past it. A delivery waiting for a retry stops that repository until the
+ retry is due; other repositories and other plugins go on. Installations are
+ delivered side by side.
+
+Before the events, failed deliveries an administrator retried get their one
+extra attempt. A retry is marked `manual` until that attempt is final, so one
+a crashed worker left `running` gets its attempt again from the next lease
+holder, with the same delivery id.
+
+### Attempts and retries
+
+An attempt marks the record `running`, increments `attempt`, and calls
+`runPluginHandler` with `{ kind: 'event', event, deliveryId, attempt }` and the
+30 second limit. Before each attempt the dispatcher renews its lease and reads
+the installation again: an installation that is paused, disabled, enabled
+again since the pass began, or no longer granted `events.read` ends the pass,
+and so does an event outside the current repository limit (a manual retry of
+one is marked failed instead). The handler gets the installation as it is now.
+An attempt that pauses the installation ends the pass too. The runner never throws. The outcome, duration, redacted error
+and redacted log lines (at most 100 lines and 64 KiB) are written to the record.
+A failed attempt becomes `retrying` with `next_attempt_at` 1 minute, 5 minutes,
+30 minutes and 2 hours later (`PLUGIN_DELIVERY_RETRY_DELAYS_MS`); the fifth
+failure is final (`failed`). A manual retry gets one attempt.
+
+### Lease
+
+Several daemons may run against one database. Only one delivers to a given
+installation: the holder of its lease, `lease_owner` and `lease_expires_at` on
+the `plugin_dispatch` row. ProPR runs on SQLite only, so the lease is this row
+with an expiry, not a database lock. A dispatcher takes the lease with one
+conditional `UPDATE` (free, expired or already its own) and renews it before
+every attempt. The lease lasts 2 minutes, longer than an attempt can run. On
+shutdown the dispatcher hands its leases back.
+
+The outcome of an attempt is written only while the dispatcher still holds the
+lease. A dispatcher that stalled past its lease writes nothing; the new holder
+finds the record still `running` and delivers it again with the same delivery
+id. This is why delivery is at least once.
+
+### Gaps
+
+Retention deletes journal events after 30 days and records each repository's
+floor (`getRepositoryEventFloor`). When a cursor is below the floor, events may
+have gone undelivered: the dispatcher records one `gap` record (status
+`skipped`, from the cursor to the floor) and goes on with the events that are
+left. `floor_seq` on the cursor keeps it from recording the same gap twice.
+Unfinished deliveries of events that are no longer in the journal become
+`skipped`. Unexpired events below the floor are still delivered.
+
+### Health
+
+`plugin_dispatch` also counts consecutive failed attempts. A success resets the
+count and clears Degraded. At 3 (`PLUGIN_DEGRADED_AFTER_FAILURES`) the
+installation becomes Degraded: `degraded_at` is set, and every durable
+administrator gets one `system_failure` Inbox notification (component
+`plugins.dispatcher`, deduplicated by installation and the time it became
+Degraded). The notification owed is queued in `degraded_notice` (a JSON list)
+in the same write that sets `degraded_at`, behind any notification of an
+earlier transition not sent yet, and each is removed only once it was created;
+when sending fails, or the worker stops first, later passes send it again (at
+most once a minute after a failure), paused installations included. At `PROPR_PLUGIN_PAUSE_AFTER_FAILURES` (default 10, at least 3)
+`paused_at` is set and the dispatcher skips the installation. Resume clears
+both, resets the count and makes waiting retries due.
+
+Installing a plugin, or enabling it again, restarts its dispatch state at the
+journal's end; deliveries left unfinished are marked failed. A pass writes a
+cursor or a delivery record only in a transaction that confirms it still holds
+the lease of the installation and start it read, so a pass that started before
+a restart writes nothing of the earlier start. Marking a delivery `running`
+before its handler starts is one of those writes, and renews the lease in the
+same transaction: a worker that lost the lease while suspended before an
+attempt never starts the handler. Uninstalling
+deletes the dispatch state, the cursors and the records.
+
+### What is stored
+
+A delivery record holds the plugin id, delivery id, kind, repository, event
+`seq` and `.`, handler, status, attempt, times, duration, the
+timeout flag, and the latest attempt's redacted error and log lines. It never
+holds the event payload, the handler's result or a credential. Records are
+kept for 14 days (`PLUGIN_DELIVERY_RETENTION_DAYS`); pending and retrying
+records are kept until they finish. A final record is also kept while its
+event is still in the journal and ahead of the repository's cursor (another
+handler of the event is retrying, or deliveries are paused), so reading the
+event again does not run a handler that already succeeded or failed for good.
+
+## `ctx.propr`: the scoped client
+
+Each operation needs one permission:
+
+| Operation | Permission |
+| --- | --- |
+| `getPullRequest(repository, number)` | `pull_requests.read` |
+| `getIssue(repository, number)` | `issues.read` |
+| `getTask(taskId)` | `tasks.read` |
+| `listEvents({ repository, afterSeq?, domains?, limit? })` | `events.read` |
+| `createIssue(repository, { title, body?, labels? })` | `issues.create` |
+| `createTask(repository, issueNumber)` | `tasks.create` |
+| `commentOnPullRequest(repository, number, body)` | `pull_requests.comment` |
+| `commentOnIssue(repository, number, body)` | `issues.comment` |
+| `addLabel(repository, number, label)` / `removeLabel(…)` | `labels.write` |
+
+Every call is checked before it reaches the backend:
+
+- **Grant.** The permission must be both granted to the installation and asked
+ for by the current manifest. A permission an upgrade added is unusable until
+ an administrator approves it, and one an upgrade dropped is unusable at once.
+- **Repository limit.** The repository must be in the installation's
+ repository limit (compared case-insensitively), unless the plugin is enabled
+ for all repositories. A task's repository is known only after the task is
+ read, so `getTask` reads it and refuses to return a task outside the limit.
+- **Input.** `owner/repo` names, positive numbers, and bounded titles (256),
+ bodies (64 KiB), labels (50 characters, at most 20) and event pages (at most
+ 100).
+- **Target kind.** GitHub's issue routes serve pull requests too, so the method
+ name alone does not establish what a number refers to. `getIssue` and
+ `commentOnIssue` read the number first and refuse a pull request;
+ `commentOnPullRequest` refuses an issue (`not_found`, nothing is posted). An
+ `issues.comment` grant cannot comment on a pull request, nor the reverse.
+- **Installation.** The permissions and the repository limit are first those
+ of the installation the run started with. Every call then requires that
+ installation to still be installed and enabled, and to still grant the
+ call's permission on the repository it names, as an administrator last set
+ them; the default backend checks the same again right before it sends each
+ GitHub request or adds a task job, after the App client or the queue is
+ ready. `getTask` names no repository until the task is read, so it checks
+ the task's repository against the current limit then, before returning it. A handler that resumes after its plugin was uninstalled, or
+ uninstalled and reinstalled, gets `unavailable` ("The plugin is no longer
+ installed"); after it was disabled, `unavailable` ("The plugin is
+ disabled"); after its repository limit was changed to exclude the
+ repository, `repository_not_allowed`; after a grant was revoked,
+ `permission_denied`. In each case it creates no issue, comment, label or
+ task. An administrative change does not wait for a GitHub request that
+ passed this check and is already being sent.
+
+A refused call throws `PluginSdkError` with code `permission_denied`,
+`repository_not_allowed`, `invalid_input`, `not_found` or `unavailable`. It is
+logged as a `warn` line in the run's log and in the server log. A backend failure reaches
+the handler as `unavailable` without the backend's details, which could name
+other repositories or carry tokens.
+
+### Attribution
+
+Every call, allowed or refused, is appended to `plugin_audit` with actor
+`plugin:` and detail
+`{ handler, operation, permission, callId, repository?, number?, taskId?, reason? }`.
+The action is `handler_call` for a completed call and `handler_refused` for a
+refused or failed one. A mutation (`createIssue`, `createTask`, the comment and
+label operations) is first recorded as `handler_attempt`, after its checks and
+before it is dispatched. If that record cannot be written, the call is refused
+as `unavailable` and nothing reaches GitHub or the task queue. Once the attempt
+is recorded, a failure to record the outcome is logged on the server and does
+not fail the call, because the mutation may already have happened. The attempt
+and its outcome share a `callId`. The calls appear in the plugin's audit trail
+(`GET /api/instance/plugins/v1/{id}`) with the management actions.
+
+Text a plugin writes to GitHub (issue bodies and comments) ends with an
+HTML comment, ``, so the writer is visible on the
+issue itself. A task the plugin creates is queued with `requestedBy:
+"plugin:"` in its job data. The task keeps that value in its stored job
+data, and its first task event, the `pending` entry of `task_history` that
+`get_task_events` returns, carries `{ "requestedBy": "plugin:" }` as
+metadata.
+
+## `ctx.http`: outbound HTTP
+
+`ctx.http(connection?)` returns a `fetch`-like function:
+`(url, { method?, headers?, body? | json? }) => Promise<{ status, ok, url, headers, text(), json() }>`.
+
+| Check | Refusal |
+| --- | --- |
+| `https:` only, default port, no credentials in the URL | `insecure_url`, `host_not_allowed`, `invalid_input` |
+| The hostname must be exactly one of the manifest's `outboundHosts`; IP literals and local names (`localhost`, `*.local`, cloud metadata) never are | `host_not_allowed`, `private_address` |
+| Every resolved address must be public: the extension endpoint policy's address check (private, loopback, link-local, CGNAT, documentation and fake-IP ranges are refused) | `private_address` |
+| Methods `GET`, `HEAD`, `POST`, `PUT`, `PATCH`, `DELETE`; headers ProPR sets (`Host`, `Content-Length`, hop-by-hop, `Proxy-*`) cannot be supplied | `invalid_input` |
+| Request bodies up to 256 KiB, responses up to 1 MiB, 10 seconds per request | `invalid_input`, `response_too_large`, `http_failed` |
+
+As with extension MCP servers, ProPR dials the address it checked, keeping the
+original hostname for TLS and the `Host` header. Redirects are not followed, and
+no proxy environment, cookies or decompression apply. The handler gets the
+redirect response itself.
+
+**Credentials.** With a connection name, the connection must be a `token`
+connection of the plugin and must be set. Its sealed secret is opened only when
+the request is sent and is injected as `Authorization: Bearer `. An
+`oauth` connection authenticates the plugin's MCP servers, and a
+`signing_secret` verifies inbound webhooks. Neither can be injected
+(`connection_unavailable`). The handler never receives the secret: it is not
+part of `ctx`, and the secret (plain, JSON-escaped, base64, base64url, hex,
+URL-encoded and as the full header value) is replaced with `[REDACTED]` in the
+response headers and body before the handler sees them. The hex spelling is
+matched in any letter case (`Buffer.from(hex, 'hex')` decodes `AB`, `ab` and
+`aB` alike); every other spelling is matched exactly, so a value that merely
+differs from the secret in case is not touched. Each of these spellings
+is also replaced where some of its characters are percent-escaped, with
+lowercase or uppercase hex digits (`%2f` as well as `%2F`, or an escaped
+letter), since `decodeURIComponent` recovers all of them. A JSON body is also
+checked after decoding, so an escape such as `\u0041` or `\"` cannot spell the
+secret back; when a decoded string held it, `text()` and `json()` return the
+body re-serialized with the secret replaced. A string that holds the secret
+only after it is decoded again, such as a JSON document serialized inside a
+JSON string or a value URL-encoded twice, is replaced with `[REDACTED]` whole.
+A response header or body whose text still holds the secret once its JSON and
+percent escapes (`%2f`, `%u002f`) are decoded, once or repeatedly and in any
+order, is replaced with `[REDACTED]` entirely. This covers a JSON-valued header
+(also inside a longer or joined value), `\uXXXX` escapes in an HTML script,
+nested serialized JSON and a percent escape inside a JSON escape. Text still
+decoding after 32 rounds, or decoding to more than 8 different texts in one
+round, counts as holding it. A response header whose name holds the secret in any of these
+spellings is dropped. Header names arrive lowercased, so they are compared
+case-insensitively. A
+careless upstream that echoes the `Authorization` header cannot hand the secret
+back.
+
+**Installation binding.** The secret is read from the installation the run
+belongs to, in one transaction with it, and every authenticated request checks
+again before it is sent that this installation is still installed and enabled.
+A run that resumes after its plugin was uninstalled, or uninstalled and
+reinstalled with a new connection of the same name, gets `unavailable` and
+sends nothing; it never receives the replacement installation's secret. A run
+that resumes after its plugin was disabled gets `unavailable` ("The plugin is
+disabled") and sends nothing either. An unauthenticated `ctx.http()` request
+carries nothing of the installation and is not bound to its enabled state.
+
+**Run lifetime.** The run's signal reaches the request: it is checked again
+after the DNS lookup and after the credential is read, and no request starts
+once the run has ended (`unavailable`). A request in flight when the run ends
+is cancelled. The default `ctx.propr` backend applies the same rule to its
+GitHub requests and queued tasks.
+
+Each request decision is a line in the run's log: `debug` when sent, `warn` when
+refused. The line names only the hostname, never the path or query.
+
+## `ctx.state`
+
+A JSON key-value store scoped to the installation, for cursors and mappings
+such as "this pull request maps to that chat thread":
+
+```ts
+await ctx.state.set(`thread:${repository}#${number}`, { channel, ts });
+const thread = await ctx.state.get<{ channel: string; ts: string }>(`thread:${repository}#${number}`);
+await ctx.state.delete(key); await ctx.state.keys('thread:');
+```
+
+| Limit | Value |
+| --- | --- |
+| Keys | 256 per installation; 1 to 200 printable characters without spaces |
+| Value | 16 KiB of serialized JSON; plain objects, arrays, text, finite numbers, booleans, `null` |
+| Total | 256 KiB per installation |
+
+A write that would exceed a limit throws `state_limit` and changes nothing.
+Rewriting a key replaces its size rather than adding to it, so an existing key
+can be updated at the key limit. Values live in `plugin_state`
+(`plugin_id`, `state_key`, `value`, `bytes`, `updated_at`; migration
+`20261114180000_plugin_state.js`) and are deleted when the plugin is
+uninstalled. A write runs in a transaction that first requires the
+installation the run started with (the same `installed_at`) to still exist
+and holds it until commit; uninstall deletes the installation row before the
+state rows. A handler still running across an uninstall therefore cannot
+recreate state, nor hand it to a later installation with the same id: its
+write either commits first and is deleted with the rest, or throws
+`unavailable`. Disabling keeps them. The uninstall's audit entry records how many
+keys were deleted. Native exports keep the table, since it holds no
+credentials.
+
+## `ctx.log` and redaction
+
+`ctx.log.debug|info|warn|error(message, fields?)` appends
+`{ level, message, fields?, at }` to the run's log, which is returned to the
+caller. Messages are cut at 2,000 characters, fields must be JSON of at most
+4 KiB, and a run keeps at most 200 lines, followed by one "log limit reached"
+line. Every message, field, error message and result is scrubbed of the
+connection credentials used in the run as a response is (every spelling, also
+percent-escaped in either case, and a string that still decodes to one is
+replaced whole), then passes the extension output redaction. That redaction
+removes those credentials again, plus the general secret patterns (GitHub,
+cloud and API tokens, `Authorization` values and secret-like assignments) and
+headers such as `authorization` and `cookie`.
+
+Messages (2,000 characters) and error messages (1,000) are cut only after the
+final redaction when the run ends, which uses every credential the run
+acquired. A credential that straddles the limit is replaced whole instead of
+being left one character short. A credential too long to be matched whole before the cut has
+its visible start withheld.
+
+## Import rule
+
+The root `eslint.config.js` has an override for `packages/plugins/**`, which
+covers handlers, the registry and the test kit. Its `no-restricted-imports` and
+`no-restricted-syntax` rules refuse:
+
+- `@propr/*` packages other than `@propr/shared`, and `propr-ui`;
+- relative paths into another ProPR package (`../../core/…`, `../../api/…`,
+ `../../shared/…`, …) or the root sources (`../../../src/…`);
+- dynamic `import()` of any of these.
+
+Node built-ins and files inside the plugins package are allowed. Run it with
+`npm run lint -w @propr/plugins`. `packages/core/test/pluginHandlerLint.test.ts`
+checks that a fixture plugin importing core fails.
+
+## Testing plugins
+
+`packages/plugins/testing` exports `createTestPluginContext(options)`. It
+returns a fake `ctx` together with what the handler did:
+
+```ts
+import { createTestPluginContext } from '../testing/index.js';
+import { recordEvent } from '../example-pack/handlers.js';
+
+const kit = createTestPluginContext({ grantedPermissions: ['events.read', 'tasks.read'], outboundHosts: ['api.example.com'] });
+kit.respond('https://api.example.com/v1/', { json: { ok: true } });
+await recordEvent(input, kit.ctx);
+kit.calls; // ctx.propr calls that passed the checks: [{ operation, args }]
+kit.requests; // ctx.http requests: [{ url, init, connection }]
+kit.state; // Map of the in-memory state
+kit.logs; // captured log lines
+```
+
+The fake applies the same permission, repository-limit, `https`, outbound-host
+and state-limit checks as the real context, so a handler test fails the way
+production would. That includes `getTask`, which refuses a task from a
+repository outside the limit once the answer is read. `ctx.propr` calls nobody
+answered resolve with minimal placeholders. A placeholder task belongs to the
+first repository in the limit. An HTTP request with no scripted response throws `http_failed`.
diff --git a/docs/docs/architecture/repository-event-journal.md b/docs/docs/architecture/repository-event-journal.md
index 70850c4649..6be7f06960 100644
--- a/docs/docs/architecture/repository-event-journal.md
+++ b/docs/docs/architecture/repository-event-journal.md
@@ -1,12 +1,12 @@
---
sidebar_label: "Repository Event Journal"
title: "Repository change-event journal"
-description: "The durable, append-only journal of pull request, epic queue and plan issue changes: table, event types, payloads, idempotency and retention."
+description: "The durable, append-only journal of pull request, epic queue, plan issue and task changes: table, event types, payloads, idempotency and retention."
---
# Repository change-event journal
-ProPR records pull request, epic queue and plan issue changes in a durable, append-only journal: the `repository_events` table. Each event is appended where the change is already persisted, so a coordinator can read one ordered stream per repository. It does not have to poll several endpoints and diff the results.
+ProPR records pull request, epic queue, plan issue and task changes in a durable, append-only journal: the `repository_events` table. Each event is appended where the change is already persisted, so a coordinator can read one ordered stream per repository. It does not have to poll several endpoints and diff the results.
The journal is the source of truth. After each append ProPR also publishes a bare `activity:update` event over Socket.IO so the web UI can refresh. That event carries no payload, and a missed one loses nothing: the row is still in the journal.
@@ -18,9 +18,9 @@ This page covers the storage, the writer and the event catalog. Clients read and
| --- | --- |
| `seq` | `INTEGER PRIMARY KEY AUTOINCREMENT`, used as the cursor. It only increases, and a position is never reused, even after retention deletes rows. |
| `repository` | `owner/repo`. Every read is scoped to one repository. |
-| `domain` | `pull_request`, `epic_queue` or `plan_issue`. |
+| `domain` | `pull_request`, `epic_queue`, `plan_issue` or `task`. |
| `type` | The event type within the domain (see below). |
-| `entity_id` | PR number, epic execution id, or plan issue id, as text. |
+| `entity_id` | PR number, epic execution id, plan issue id, or task id, as text. |
| `pr_number`, `issue_number`, `plan_id`, `head_sha` | Optional identifiers to filter on. `plan_id` is the plan (draft) id. |
| `payload` | Small JSON object (at most 4 KiB). |
| `dedupe_key` | Optional key, unique per repository. |
@@ -30,6 +30,8 @@ Indexes: `(repository, seq)`, `(repository, domain, entity_id, seq)`, and a uniq
`repository_event_floors` stores, for each repository, one past the highest `seq` that retention has deleted (see [Retention](#retention)).
+The `domain` column has a CHECK constraint listing the domains; the `task` domain was added by rebuilding the table, keeping every row, its `seq` and the AUTOINCREMENT high-water mark, so existing cursors stay valid.
+
Rows are written by explicit application appends, not SQLite triggers. A later migration that rebuilds a source table therefore has nothing to recreate. This is different from `goal_events`.
## Writer
@@ -44,7 +46,7 @@ Rows are written by explicit application appends, not SQLite triggers. A later m
## Event types
-Payloads carry identifiers and small scalars only. They never include PR bodies, comment text, diffs or log excerpts. The free-text reasons below are short, system-generated explanations, capped at 300 characters.
+Payloads carry identifiers and small scalars only. They never include PR bodies, comment text, diffs, prompts, agent or tool output, file contents or log excerpts. The free-text reasons below are short, system-generated explanations, capped at 300 characters.
### `pull_request`
@@ -86,6 +88,40 @@ A `blocked` event is appended only when the explanation changes, so rewriting th
Each writer reads the rows it changes, with their prior status, in the same transaction as its guarded update and the append. One event is written per changed row, and nothing for rows its filters excluded or rows already at the new status (a redelivered webhook). There is no dedupe key, so a transition that repeats within one clock tick is still its own event. These writers already publish a `plan` activity event, so the journal does not publish a second one.
+### `task`
+
+`entity_id` is the task id. `issue_number` holds the issue of an implementation task; `pr_number` holds the pull request of a follow-up, review or merge-conflict task. Events are appended by the task state manager (`WorkerStateManager`) right after it records the state change in `task_history`, so every job path journals the same way (`packages/core/src/services/taskJournalEvents.ts`).
+
+| Type | Written when | Dedupe key |
+| --- | --- | --- |
+| `queued` | The task state is created (`pending`), or the task returns to `pending` | `task:{taskId}:queued:v{version}` |
+| `started` | The task leaves `pending` for a running state, or a failed task is retried | `task:{taskId}:started:v{version}` |
+| `completed`, `failed`, `cancelled` | The task reaches that terminal state | `task:{taskId}:{type}:v{version}` |
+
+`version` is the task state's compare-and-set revision, so a republished transition appends nothing. A queue handoff (a `requeued` or `rescheduled` job recorded as `cancelled` without a terminal reason) is bookkeeping, not an end: it appends no `cancelled`, and resuming it appends no second `started`. A retry appends `started` and a new terminal event again.
+
+Every `task` payload has:
+
+| Field | Meaning |
+| --- | --- |
+| `taskId` | The task id. |
+| `kind` | `implementation` (an issue task), `follow-up` (a pull request comment task), `review`, `merge-conflict` or `automation` (an automation run). Other task types, such as goals and task imports, are not journaled. |
+| `issueNumber`, `prNumber` | The issue or pull request the task works on; the other is `null`. |
+| `agent`, `model` | The agent alias and model the task was queued with, or `null`. |
+| `attempts` | Retry attempts recorded so far. |
+
+Terminal events (`completed`, `failed`, `cancelled`) add:
+
+| Field | Meaning |
+| --- | --- |
+| `status` | The terminal state, the same as the event type. |
+| `terminalReason` | The coded terminal reason, for example `timed_out`, `stalled`, `blocked`, `cancelled_by_user`, `cancelled_pr_closed`; `null` when none was recorded. |
+| `durationMs` | Milliseconds from the run's `started` event to the terminal state, across any queue handoffs in between; `null` when the task never started. |
+| `pullRequest` | `opened` (the task opened a pull request, including a continuation pull request), `updated` (it pushed a commit to an existing one) or `null`. |
+| `failureCategory` | On `failed` only: the coded terminal reason, else the recorded error category (for example `claude_execution`, `git_operation`, `worker`), else `unknown`. Free-text categories are reported as `unknown`. `null` on other events. |
+
+Payloads are built from identifiers and coded fields only. They never include the task's reason text, error message, prompt, agent or tool output, comment bodies, logs, notification recaps or file contents. A task event has no activity mapping: the task state update already publishes its own realtime event. Like every append, a failed task event is logged and the task carries on with the same outcome.
+
## Realtime
The activity envelope (`packages/shared/src/activityEvents.ts`) has a `pull_request` domain. Each event mapping is shown below:
@@ -102,7 +138,7 @@ The activity envelope (`packages/shared/src/activityEvents.ts`) has a `pull_requ
## Retention
-The daemon's hourly maintenance sweep (the same one that reclaims stale draft context) deletes events older than 30 days, and only those: an unexpired event survives even if an older-stamped event (a delayed delivery, or an outcome stamped with its merge time) was appended after it. For each pruned repository it stores `floor_seq` in `repository_event_floors`. That value is one past the highest deleted `seq`, and it never moves backwards.
+The daemon's hourly maintenance sweep (the same one that reclaims stale draft context) deletes events of every domain, `task` included, older than 30 days, and only those: an unexpired event survives even if an older-stamped event (a delayed delivery, or an outcome stamped with its merge time) was appended after it. For each pruned repository it stores `floor_seq` in `repository_event_floors`. That value is one past the highest deleted `seq`, and it never moves backwards.
A reader holding cursor `c` for a repository may have missed pruned events when `c < floor_seq - 1`. Unexpired events below the floor stay readable. A `fromStart` read pages through them with cursors that also carry the repository's retention generation (a digest of its `repository_event_floors` rows); such a cursor stays valid below the floor until retention next prunes the repository, which rewrites `pruned_at` even when the floor does not move. This tells an expired cursor apart from an empty result. A repository that has never been pruned has floor `1`.
diff --git a/docs/docs/features/agent-extensions.md b/docs/docs/features/agent-extensions.md
index 2fa30416f7..c93960dff6 100644
--- a/docs/docs/features/agent-extensions.md
+++ b/docs/docs/features/agent-extensions.md
@@ -80,6 +80,24 @@ revision. Enabling or disabling an item carries the sealed credentials into the
revision unchanged, without decrypting them. Removing or renaming an item that an automation adds explicitly is
refused with a message naming those automations; remove it from them first.
+### Managed items
+
+An installed [plugin](./plugins.md#what-an-enabled-plugin-adds) adds its MCP
+servers and skills to the library as **managed items**, tagged with the plugin
+id (`managedBy: { plugin, item, active }`, plus `registrations` on an OAuth
+server: the client registration methods its sign-in may use; shown as a **From plugin:** *plugin name* badge linking to the
+plugin in the settings screen, where only those fields are editable). Their definition comes from the plugin manifest and is
+read-only: a save may change only `enabled`, `repositories`, `agents` and
+`autoApply`. Saves that change anything else on a managed item (for a package
+server, also its package, version, integrity value, arguments and variables),
+remove it or add an item with `managedBy` are refused, and so is replacing its
+credential: a managed item's credential or secret variables come from the
+plugin's connections. The plugin's own state wins over the item's: while the
+plugin is disabled, or a package server waits for a new acknowledgement after
+an upgrade, the item is inactive (`active: false`) and applies to no run
+whatever its enabled switch says. Uninstalling the plugin removes its managed
+items.
+
## Which runs use extensions
At launch, Core resolves the run's extension set against the **current** library
@@ -594,7 +612,7 @@ safe to re-run, and never blocks startup):
marked not configured and logs a warning; replace it in Settings.
The library holds at most 32 servers and 32 skill bundles, and an item at most
-64 repositories and 64 agents. If the previous configuration does not fit (for
+256 repositories and 64 agents. If the previous configuration does not fit (for
example five bundles of eight distinct servers each), nothing is dropped: the
copy is not committed, the startup log lists the items that do not fit, and
it retries at the next start. Until it completes, automations that selected a
@@ -633,3 +651,4 @@ Skill-bearing runs require a Linux worker with `/proc/self/fd` available for sec
- [MCP Connections and Operator Tools](/docs/features/mcp)
- [Agents and Models](/docs/features/agents-and-models)
+- [Plugins](/docs/features/plugins): official integrations that bundle MCP servers, skills and automation templates
diff --git a/docs/docs/features/plugins.md b/docs/docs/features/plugins.md
new file mode 100644
index 0000000000..c1b3618127
--- /dev/null
+++ b/docs/docs/features/plugins.md
@@ -0,0 +1,750 @@
+---
+sidebar_label: "Plugins"
+title: "Plugins: the manifest format"
+description: "What a ProPR plugin is, where official plugins live, and the manifest that describes their permissions, connections, settings and contributions."
+---
+# Plugins
+
+A **plugin** packages an integration with another service (a chat tool, an
+issue tracker, an observability or deployment platform) as one unit that an
+administrator can review and install. Official plugins are trusted code shipped
+with ProPR. Nothing is downloaded when a plugin is installed, except the pinned
+npm package of a local MCP server, which goes through the same package cache and
+integrity check as the [extension library's local package servers](./agent-extensions.md#local-package-servers).
+
+:::note Work in progress
+This page describes the plugin **manifest**, the official plugin registry, the
+[Plugins page](#the-plugins-page), the installation API and the
+[handler interface](#handlers) and [event subscriptions](#event-subscriptions).
+Inbound webhooks and commands are added by later releases.
+:::
+
+## Where plugins live
+
+Each official plugin is a folder `packages/plugins//` in the ProPR
+repository:
+
+| File | Purpose |
+| --- | --- |
+| `manifest.json` | Everything ProPR shows about the plugin, as pure data. ProPR can list a plugin, its permissions and its contributions without loading any of its code. |
+| `README.md` | What the plugin does and how to set it up. Required. |
+| icon | An `.svg` or `.png` file named by the manifest's `icon`. |
+| skill folders | Skill bundle sources, each with a `SKILL.md`, named by `contributes.skills`. |
+| automation template folders | Templates in the shipped automation template format (`template.json` and `prompt.md`), named by `contributes.automationTemplates`. |
+
+`packages/plugins/registry.json` lists the plugins in catalog order. The
+`@propr/plugins` workspace package exports that list with each manifest and the
+resolved paths of its assets.
+
+Development plugins are marked `"development": true` in `registry.json` and are
+left out of the list unless `PROPR_PLUGINS_DEVELOPMENT=1` is set. The
+`example-pack` plugin is one: it contributes one remote MCP server, one local
+package server, one skill and one automation template, and is used by ProPR's
+own tests and as a reference for plugin authors.
+
+## The manifest
+
+The schema is defined in `@propr/shared` (`packages/shared/src/plugins.ts`).
+
+| Field | Description |
+| --- | --- |
+| `manifestVersion` | Currently `1`. |
+| `id` | Lowercase slug such as `acme-chat`, unique among plugins and equal to the folder name. |
+| `name`, `description` | Shown in the catalog. |
+| `version` | Semantic version of the plugin, such as `1.2.0`. |
+| `category` | One of `messaging`, `issue-tracking`, `observability`, `deployment`, `context`, `other`. |
+| `homepage` | An `https://` URL. |
+| `icon` | Path of an `.svg` or `.png` file inside the plugin folder. |
+| `permissions` | What the plugin's handlers may do, from a closed list (below). Plugins that only contribute servers, skills and templates usually need none. |
+| `connections` | Named credentials the plugin needs (below). |
+| `settings` | The plugin's settings, in a JSON Schema subset (below). |
+| `outboundHosts` | Hostnames, such as `api.example.com`, that the plugin's handlers may call. No scheme, port, path, wildcard or IP address. |
+| `contributes` | What the plugin adds to ProPR (below). |
+
+Unknown fields are refused everywhere, so a misspelled field is an error rather
+than silently ignored.
+
+### Permissions
+
+`events.read`, `tasks.read`, `tasks.create`, `issues.read`, `issues.create`,
+`issues.comment`, `pull_requests.read`, `pull_requests.comment`,
+`labels.write`, `repositories.read` and `notifications.send`. A command can only
+use permissions the plugin requests, and event subscriptions need `events.read`.
+[Handlers](#what-ctxpropr-can-do) show which `ctx.propr` operation needs which
+permission.
+
+### Connections
+
+Each connection has a `name`, a `type`, a `label`, `help` text and a `helpUrl`
+pointing to where the credential is obtained.
+
+| Type | What the administrator provides |
+| --- | --- |
+| `oauth` | A sign-in, using the same flow as [OAuth library servers](./agent-extensions.md#oauth-servers). `registrations` limits the client registration methods (`metadata_document`, `dynamic`, `preregistered`; all three by default) and `scopes` lists the scopes to request. |
+| `token` | A secret pasted by the administrator, such as an API token. |
+| `signing_secret` | The shared secret that verifies inbound webhooks. |
+
+### Settings
+
+`settings` is an object schema: `{ "type": "object", "properties": { ... }, "required": [ ... ] }`.
+Every property has a `label`, optional `help` and an optional `default`, and is
+one of:
+
+| Shape | Meaning |
+| --- | --- |
+| `{ "type": "string" }` | Text, with optional `maxLength`. With `enum`, one of a fixed list of values. |
+| `{ "type": "number" }` | A number, with optional `minimum`, `maximum` and `integer`. |
+| `{ "type": "boolean" }` | A switch. |
+| `{ "type": "array", "items": { "type": "string" } }` | A list of strings, with optional `maxItems`. |
+| `{ "type": "object", "format": "repository-map", "additionalProperties": { "type": "string" } }` | A value per repository, keyed by `owner/repo`. |
+
+Defaults must satisfy their own constraints, and every `required` key must be
+defined.
+
+### Contributions
+
+| Field | Contents |
+| --- | --- |
+| `mcpServers` | MCP servers, in the [extension library](./agent-extensions.md)'s item shapes (below). |
+| `skills` | `{ "name", "path" }`: a skill bundle folder inside the plugin folder. Its `SKILL.md` front matter must use the same `name`. |
+| `automationTemplates` | `{ "id", "path" }`: a template folder in the automation template format. It must pass the same checks as the shipped templates. |
+| `commands` | `{ "name", "description", "permissions" }`. Accepted and validated; not used yet. |
+| `subscriptions` | `{ "on", "handler", "filter"? }`: deliver a repository event (`.`, such as `pull_request.merged`) to an exported handler. See [Event subscriptions](#event-subscriptions). Needs the `events.read` permission. |
+| `webhooks` | `{ "name", "description", "connection" }`, where the connection is a `signing_secret`. Accepted and validated; not used yet. |
+
+Names are unique within each list, and command names are unique across all
+plugins.
+
+#### Remote servers
+
+```json
+{
+ "kind": "https", "name": "acme", "url": "https://mcp.acme.example.com/mcp",
+ "tools": ["search"], "capability": "read",
+ "authentication": "oauth", "connection": "workspace"
+}
+```
+
+`authentication` is `none`, `bearer` or `oauth`. A `bearer` server names a
+`token` connection, an `oauth` server names an `oauth` connection, and a `none`
+server names no connection. The endpoint must be `https://`.
+
+#### Local package servers
+
+```json
+{
+ "kind": "package", "name": "acme-local",
+ "ecosystem": "npm", "package": "@acme/mcp-server", "version": "2.0.1",
+ "integrity": "sha512-…",
+ "bin": "acme-mcp", "args": ["--read-only"],
+ "env": [{ "name": "ACME_REGION", "value": "eu" }],
+ "secretEnv": [{ "name": "ACME_TOKEN", "connection": "api-token" }],
+ "tools": ["post"], "capability": "write"
+}
+```
+
+The rules are those of the extension library's local package servers: an npm
+package with an exact `version` (no ranges such as `^2.0.0` and no tags such as
+`latest`), and no paths, images or free-form commands. Unlike a library item,
+the manifest records the `integrity` value of the pinned version itself, and
+each `secretEnv` variable names one of the plugin's `token` connections instead
+of holding a value.
+
+## Checking manifests
+
+```bash
+npm run plugins:check
+```
+
+validates every plugin in `packages/plugins`: the manifest schema and its
+cross-references, that `registry.json` lists each plugin folder exactly once,
+that ids match their folders, that command names are unique, and that the
+README, icon, skill folders and automation templates exist and are valid. CI
+runs it on every pull request.
+
+```bash
+npm run plugins:check -- --online [--registry https://registry.npmjs.org]
+```
+
+also asks the npm registry for each package server's version and compares the
+recorded `integrity` value. It needs network access and is not run in CI.
+
+## The Plugins page
+
+**Plugins** (`/plugins`) is where an administrator installs a plugin and makes
+it work without knowing what it contributes internally. It is in the sidebar
+(on mobile, under **More**) and linked from **Settings → Agent Extensions &
+Packages → Skills & MCP servers**, and only durable administrators with
+`instance.manage_settings` see it. Everything on the page is drawn by ProPR
+from the manifest: manifest text is shown as plain text, links appear only for
+`https` URLs, and no code, image or markup supplied by the plugin runs in the
+browser.
+
+### Catalog
+
+The catalog shows one card per plugin: an icon for its category, its name,
+one-line description, category and a state badge. Filter by **Category** or
+type in **Search by name**.
+
+| Badge | Meaning |
+| --- | --- |
+| Not installed | Available in this ProPR, not installed. |
+| Off | Installed, every connection set, not enabled. |
+| Needs connection | Installed and not enabled, with a connection still to set or sign in. |
+| Enabled | Enabled, every connection set. |
+| Needs approval | An upgrade asks for permissions or a secret acknowledgement not yet approved. |
+| Degraded | Enabled while a connection is missing or needs a new sign-in, or no longer in this ProPR's registry. |
+
+### Walkthrough
+
+Open a card to see the plugin's steps, always in this order, each marked
+**Done** or **To do**:
+
+1. **Install.** The step says what the plugin adds in plain words, for example
+ "Adds 1 MCP server the agent can read from", "Runs 1 local server inside
+ the task container" or "Adds the /deploy command", and lists the
+ permissions it asks for, each with a one-line explanation. When a local
+ package server receives secret variables, the step shows the package, its
+ exact version and the variable names, with a required checkbox confirming
+ that those secrets are readable inside the task container. **Install**
+ stays unavailable until it is ticked. After an upgrade that needs approval,
+ the step shows what changed (see [Needs approval](#needs-approval)) and
+ offers **Approve**.
+2. **Connect.** One row per connection with its state, the manifest's help text
+ and a **How to get it** link. An OAuth connection has **Connect**,
+ **Reconnect** and **Disconnect**, the same controls as an OAuth server in
+ Skills & MCP servers; sign-in opens in a new tab and the page refreshes when
+ you come back. A `token` or `signing_secret` connection has a write-only
+ field: once saved, the page only says who stored it and when, and offers
+ **Replace** and **Clear**. A required connection cannot be cleared while
+ the plugin is enabled.
+3. **Configure.** A settings form ProPR generates from the manifest's
+ [settings schema](#settings), then **Limit to repositories**, which picks
+ the repositories the plugin applies in (leave every box unchecked for all
+ repositories). The step is done once every required setting has a value.
+ See [Settings form](#settings-form).
+4. **Enable.** A single **Enabled** checkbox. While a required connection,
+ required setting or secret acknowledgement is missing it cannot be ticked,
+ and the step lists what is missing, for example "Connection Example API
+ token (example-token) is not set". Untick it to disable the plugin; its
+ settings and connections are kept.
+
+Below the steps, **What it added** lists the plugin's managed MCP servers and
+skills, with a link to Skills & MCP servers where they are listed, and its
+automation templates, each with whether it applies now. **Activity** follows
+once the plugin is installed: each event delivered to the plugin's handlers and
+its result, with **Retry** on a failed delivery (see [Activity](#activity)). A
+plugin whose deliveries keep failing shows **Degraded** next to its state, and
+**Paused** with **Resume deliveries** once deliveries stopped (see
+[Health, Degraded and Resume](#health-degraded-and-resume)).
+
+**Uninstall** is a separate action. Its confirmation lists what is removed:
+the settings and repository limit, each connection and stored credential, the
+managed library items and the templates. The audit trail is kept. When an
+automation still adds one of the plugin's managed items, the API refuses the
+uninstall and the page shows its message, which names the automation; remove
+the item from that automation and try again.
+
+### Settings form
+
+Each setting becomes one field, labelled with its `label` and described by its
+`help`:
+
+| Setting | Field |
+| --- | --- |
+| `string` | A text box (limited to `maxLength`). |
+| `string` with `enum` | A list to pick one value from, or **Not set**. |
+| `number` | A number box, with the range and whether it must be a whole number shown under it. |
+| `boolean` | A list with **Not set**, **Yes** and **No**, so an explicit **No** (`false`) is saved and kept apart from an unset setting. |
+| `array` of `string` | One box per entry, with **Add entry** and **Remove**. Each entry is saved exactly as typed, including spaces, empty entries and line breaks. |
+| `object` with `format: repository-map` | One row per repository: the repository (`owner/name`, with this instance's repositories suggested) and its value, with **Add repository** and **Remove**. |
+
+The form starts from the saved settings; a fresh installation starts from the
+manifest's defaults. **Save settings** sends every setting at once
+(`PUT /api/instance/plugins/v1//settings`). A field you did not change
+sends its saved value unchanged. An emptied text or number field removes that
+setting, and so does choosing **Not set** for a true/false setting. A list or map whose last entry is removed is saved as empty;
+**Clear** removes the setting. The page checks the values with the same rules as the
+API before sending them, and when the API refuses a value its message (for
+example "maxItems must be at most 50") is shown under that field. A required
+setting is marked **Required to enable**: it may stay empty while the plugin is
+disabled, but **Enable** then lists it as missing (for example "Required setting
+Verbosity (verbosity) is missing"), and while the plugin is enabled it cannot
+be emptied.
+
+### Needs approval
+
+When a newer manifest asks for more than was approved, the plugin is badged
+**Needs approval** and keeps running with what it was granted. The Install step
+then shows:
+
+- **New permissions it asks for**, each with its explanation.
+- **Changed package servers**: for each package server with secret variables
+ whose package, exact version or secret variables changed since they were
+ acknowledged, what changed, for example "Version changes from 1.0.0 to 1.1.0"
+ or "Secret variables added: FIXTURE_REGION", or that the server is new.
+- The secret-variable acknowledgement for each of those servers, naming the
+ package, exact version and variables.
+
+**Approve** stays unavailable until every acknowledgement is ticked. Approving
+records the new permissions and acknowledgements and clears the badge.
+
+### Managed items in Skills & MCP servers
+
+In **Settings → Agent Extensions & Packages → Skills & MCP servers**, each
+server or skill a plugin contributes carries a **From plugin:** *plugin name* badge
+that opens the plugin's page. It has no **Remove** (uninstall the plugin
+instead), and **Edit limits** opens a form that shows its definition read-only:
+for a remote server the endpoint, authentication and tools; for a package
+server also the registry, package, exact version, command, arguments, variables
+and secret variable names. Only the enabled checkbox, the repository and agent
+limits and **Apply automatically** can be changed.
+
+## Installing a plugin
+
+A plugin is **available** when it is in this ProPR's registry, **installed**
+once an administrator installed it on the instance, and then **enabled** or
+disabled. Disabling keeps its settings and connections; uninstalling deletes
+them. What an enabled plugin adds to runs is described in
+[What an enabled plugin adds](#what-an-enabled-plugin-adds).
+
+Only durable instance administrators with `instance.manage_settings` can read
+or change plugin state, and every change (install, settings, repository limit,
+connection set or cleared, OAuth client configured or cleared, enable,
+disable, approve, uninstall) is recorded in
+the plugin audit trail with who and when. The audit trail stays after an
+uninstall. Changes need `Content-Type: application/json` and the header
+`X-ProPR-Plugins: 1`.
+
+| Step | Request |
+| --- | --- |
+| List plugins and their state | `GET /api/instance/plugins/v1` |
+| Read one plugin | `GET /api/instance/plugins/v1/`: the manifest, settings, repository limit, connection states, permissions and acknowledgements awaiting approval, why enabling is refused, and the newest 50 audit events. |
+| Install | `POST /api/instance/plugins/v1//install` with `{ "acknowledgements": ["", …] }` |
+| Change settings | `PUT /api/instance/plugins/v1//settings` with `{ "settings": { … } }`, the complete settings object |
+| Limit to repositories | `PUT /api/instance/plugins/v1//repositories` with `{ "repositories": ["owner/repo"] }`; `null` or `[]` for all repositories |
+| Set or clear a connection | `PUT /api/instance/plugins/v1//connections/` with `{ "value": "…" }` or `{ "value": null }` |
+| Enable or disable | `PUT /api/instance/plugins/v1//enabled` with `{ "enabled": true }` |
+| Approve after an upgrade | `POST /api/instance/plugins/v1//approve` with `{ "acknowledgements": [ … ] }` when needed |
+| Uninstall | `POST /api/instance/plugins/v1//uninstall` with `{ "confirm": "" }` |
+| Configure a pre-registered OAuth client | `PUT /api/instance/plugins/v1//connections//oauth/client` with `{ "client": { "clientId": "…", "clientSecret": "…" } }` (`clientSecret` optional) or `{ "client": null }` |
+| Start an OAuth sign-in | `POST /api/instance/plugins/v1//connections//oauth/start` with `{}`: returns `authorizationUrl` |
+| Complete it | `POST /api/instance/plugins/v1//connections//oauth/complete` with the `state` and `code` the provider returned (the extension OAuth callback completes it too) |
+| Disconnect it | `POST /api/instance/plugins/v1//connections//oauth/disconnect` with `{}` |
+
+The [API reference](../operations/api-reference.md) has the request and response schemas. MCP
+clients can read the same state with the read-only `list_plugins` and
+`get_plugin` tools.
+
+### Install flow
+
+1. Installing records the manifest version, the manifest's permissions as the
+ granted set, the settings defaults, who installed it and when. The plugin
+ starts disabled and applies to all repositories.
+2. If the plugin contributes a [local package server](#local-package-servers)
+ with `secretEnv`, installing needs the same acknowledgement the extension
+ library asks for: the secret is readable by the agent CLI, its shell tools
+ and repository setup scripts inside the task container. Name each such
+ server in `acknowledgements`; the acknowledgement is recorded on the
+ installation with who and when.
+3. Settings are validated against the manifest's settings schema on save:
+ unknown keys, wrong types, values outside `enum`, `minimum`/`maximum`,
+ `maxLength` or `maxItems`, and malformed repository maps are refused.
+4. Set the connections the plugin needs, then enable it.
+
+Enabling is refused with HTTP 409 and the reasons (also listed as
+`enableBlockers` when the plugin is read) while:
+
+- a `required` setting has no value,
+- a required `token` connection is not set (a `token` connection is required
+ when a contributed MCP server uses it, as a `bearer` server's connection or a
+ package server's secret variable), or
+- a package server's secret acknowledgement is missing.
+
+While a plugin is enabled, its required settings and required connections
+cannot be removed; disable it first.
+
+### Connections
+
+`token` and `signing_secret` connections are write-only secrets. They are
+sealed with the same private-secret mechanism as the extension library's
+credentials, bound to the instance, plugin and connection, and never returned
+by the API, MCP tools or logs; a read reports only `connected` or
+`not_connected`, who set it and when. Native database exports erase them, so a
+restored instance has its plugins installed with the same settings but every
+connection not connected.
+
+An `oauth` connection is signed in through the
+[extension OAuth sign-in](./agent-extensions.md#oauth-servers): start it, open
+the returned `authorizationUrl` in the same browser session, and sign in at the
+provider. ProPR identifies itself as it does for library servers (Client ID
+Metadata Document, then dynamic registration), but only through the methods the
+connection's `registrations` permit, in that order; when the provider supports
+none of them, the sign-in is refused with HTTP 409. When `registrations`
+permits `preregistered`, an administrator can configure the client registered
+with the provider for this instance on the connection (`oauth/client`): the
+client id becomes the managed servers' client id and the optional client
+secret is sealed like a `token` connection and never returned; a read reports
+who configured it in `setBy` and `setAt`. While a client is configured, sign-in
+always uses it, wherever `preregistered` appears in `registrations`. Changing or clearing the client,
+including replacing only its secret, discards the connection made with the
+previous one and any sign-in still in progress. A connection that does not
+permit `preregistered` refuses a client with HTTP 409. The managed server carries
+the permitted methods in its `managedBy.registrations`, so a sign-in started
+from the extension library is limited the same way. The provider redirects to
+the extension OAuth callback, which completes the sign-in. The connection
+belongs to the plugin's managed server that uses it, so it needs a public HTTPS
+base URL for the instance and is available once the plugin is installed. A read
+reports `connected` once every managed server using the connection is signed
+in, `needs_sign_in` after a failed token refresh, and otherwise
+`not_connected`. Disconnecting revokes the tokens where the provider supports it
+and deletes them. Tokens are never returned.
+
+### Permissions and upgrades
+
+The granted permissions are a set recorded at install. When a ProPR upgrade
+ships a newer manifest that asks for a permission that was not granted, the
+plugin keeps running with its old grant and is flagged **Needs approval**
+(`needsApproval`, with the new permissions in `pendingPermissions`) until an
+administrator approves the new set. Approving grants the current manifest's
+permissions and records its version as the installed version.
+
+A package server's acknowledgement is tied to its package, version and secret
+variable names. When an upgrade changes any of them, the plugin also needs
+approval (`pendingAcknowledgements`): it cannot be enabled until the approval
+names that server in `acknowledgements` again.
+
+An upgrade that changes neither permissions nor acknowledged packages needs no
+approval; the installed version follows the new manifest on the next change.
+A plugin that an upgrade removed from the registry stays listed as installed
+but unavailable; it can only be disabled or uninstalled.
+
+## What an enabled plugin adds
+
+### Managed library items
+
+A plugin's MCP servers and skills become **managed items** of the
+[extension library](./agent-extensions.md#managed-items), tagged with the plugin
+id (`managedBy`). They are created when the plugin is installed, inactive, so
+that its OAuth connection can be signed in before enabling, and they apply to
+runs only while the plugin is enabled. Runs resolve them like every other
+library item: the same limits, opt-out labels, run limits, write authorization
+and launch checks.
+
+- **Definition:** read-only; it comes from the manifest. The administrator can
+ still use the item's enabled checkbox, its repository and agent limits, and
+ its automatic-apply switch. Every other change through the library API,
+ removing the item and replacing its credential are refused.
+- **Defaults:** automatic apply on, enabled, limited to the plugin's repository
+ limit (all repositories when it has none). Changing the plugin's repository
+ limit sets the managed items' repository limit to it again.
+- **Names:** a managed server keeps its manifest name unless the library
+ already has a server of that name; it is then named `-`.
+- **Disabling** the plugin deactivates its managed items: they stay in the
+ library as they are, with their settings and connections, and apply to no
+ run. A disable never applies an upgraded manifest, reads skill folders or
+ decrypts credentials (the library's sealed credentials carry over unchanged),
+ so it succeeds even when the next plugin change would be refused, including
+ after a restore under another encryption root. Re-enabling
+ restores them, applying any upgrade.
+- **Uninstalling** removes the managed items and their OAuth connections. It is
+ refused, naming the automations, while an automation adds one of them
+ explicitly; remove it from those automations first. An upgrade that drops an
+ item, or changes the files of a skill an automation adds by its bundle id, is
+ refused the same way when the plugin is next changed (other than disabling
+ it).
+- The plugin read (`GET /api/instance/plugins/v1/`) lists its contributions
+ in `contributions`, each with the library item it became (`managed`, with its
+ library name or bundle id, its own enabled switch and whether the plugin keeps
+ it `active`).
+
+### Remote servers
+
+A managed remote server's credential is the plugin connection it names. A
+`bearer` server sends the `token` connection; an `oauth` server is left out of
+runs, with the reason `oauth_not_connected`, until its `oauth` connection is
+connected, exactly like any other OAuth library server, and disconnecting
+removes it from later runs.
+
+### Local servers contributed by plugins
+
+A managed package server is installed and launched through the extension
+library's [local package server](./agent-extensions.md#local-package-servers)
+path, with nothing relaxed:
+
+- the pinned version and the integrity value recorded in the manifest (ProPR
+ does not ask the registry for another one);
+- installed once per integrity value without lifecycle scripts, verified
+ again at launch, mounted read-only and run through the stdio bridge in the
+ task container;
+- a write-capability server needs explicit write authorization for the run.
+
+Its `secretEnv` values come from the plugin's `token` connections. When a
+value is missing, the server is skipped for that run with the reason
+`secret_not_configured` and the run proceeds. The acknowledgement recorded at
+install (or the latest approval) satisfies the library's acknowledgement
+requirement; while an upgrade waits for a new acknowledgement the server is
+inactive and is never installed or launched.
+
+### Skills and automation templates
+
+A plugin skill becomes a skill bundle whose source is the plugin, its version
+and the folder. When an upgrade changes the skill's files, the next plugin
+change stores a new bundle for it and keeps the item's settings. Automations
+add skills by bundle id, so that change is refused while an automation adds the
+old bundle explicitly; remove the skill from the automation, apply the change,
+then add the new bundle.
+
+An enabled plugin's automation templates appear in the template catalog
+(`GET /api/automation-definitions/templates` and the `list_automation_templates`
+MCP tool) after the shipped templates, with `plugin` naming the plugin's id and
+name. A template keeps its id unless a shipped template or another plugin uses
+it; it is then `-` (`catalogId` in the plugin read). When that
+id is itself another template's id or was already given to another template,
+it gets a `-2`, `-3`, … suffix, so catalog ids are always unique. Ids depend
+only on the registry, not on which plugins are enabled. Disabling or
+uninstalling the plugin hides its templates; automations created from them are
+ordinary automations and are not changed.
+
+## Handlers
+
+A plugin folder may contain a `handlers.ts` that exports named async functions.
+Each handler receives `(input, ctx)` and returns nothing or a small JSON result
+(at most 16 KiB). `ctx` is the only way a handler touches anything. Handlers of
+official plugins run in-process, but only through this interface, so the same
+plugin could later run out of process. How ProPR runs them is described in
+[Plugin runtime](../architecture/plugin-runtime.md).
+
+```ts
+// packages/plugins/my-plugin/handlers.ts
+import { definePluginHandler, type PluginEventInput } from '@propr/shared';
+
+export const onTaskEvent = definePluginHandler(async (input, ctx) => {
+ const { event } = input;
+ const thread = await ctx.state.get(`thread:${event.entityId}`);
+ const response = await ctx.http('chat-token')('https://api.chat.example/messages', {
+ method: 'POST', json: { thread, text: `Task ${event.entityId} is ${event.type}` },
+ });
+ if (!response.ok) ctx.log.warn('chat refused the message', { status: response.status });
+});
+```
+
+| `ctx` member | What it is |
+| --- | --- |
+| `ctx.settings` | The installation's settings, validated against the manifest, with its defaults. Read-only. |
+| `ctx.repositories` | The repository limit (`owner/repo` list), or `null` for all repositories. |
+| `ctx.propr` | ProPR, limited to the granted permissions and the repository limit (below). |
+| `ctx.http(connection?)` | `fetch`-like outbound HTTPS to the manifest's `outboundHosts` only. With a `token` connection name, its credential is injected. |
+| `ctx.state` | A small JSON key-value store for this installation: `get`, `set`, `delete`, `keys(prefix?)`. |
+| `ctx.log` | `debug`, `info`, `warn`, `error(message, fields?)`: structured lines returned with the run, redacted. |
+| `ctx.signal` | Aborted when the handler runs past its 30 second limit. |
+
+The input is one of the envelopes in `@propr/shared`: `{ kind: 'event', event }`
+(a [repository journal event](../architecture/repository-event-journal.md)),
+`{ kind: 'webhook', webhook, headers, body, receivedAt }`,
+`{ kind: 'command', command, args, repository, actor }` or
+`{ kind: 'invoke', payload }`. Events are delivered by
+[event subscriptions](#event-subscriptions), with a stable `deliveryId` and the
+`attempt` number. Delivering webhooks and commands to handlers is added by later
+releases.
+
+A call `ctx` refuses throws a `PluginSdkError` with a `code` such as
+`permission_denied`, `repository_not_allowed`, `host_not_allowed`,
+`insecure_url`, `private_address`, `connection_unavailable` or `state_limit`.
+
+### What `ctx.propr` can do
+
+| Operation | Permission |
+| --- | --- |
+| `getPullRequest(repository, number)` | `pull_requests.read` |
+| `getIssue(repository, number)` | `issues.read` |
+| `getTask(taskId)` | `tasks.read` |
+| `listEvents({ repository, afterSeq?, domains?, limit? })` | `events.read` |
+| `createIssue(repository, { title, body?, labels? })` | `issues.create` |
+| `createTask(repository, issueNumber)`: an ordinary one-shot task | `tasks.create` |
+| `commentOnPullRequest(repository, number, body)` | `pull_requests.comment` |
+| `commentOnIssue(repository, number, body)` | `issues.comment` |
+| `addLabel(repository, number, label)`, `removeLabel(…)` | `labels.write` |
+
+A permission counts only when the administrator granted it and the current
+manifest still asks for it. Each call must also stay inside the plugin's
+repository limit. These are checked again, as an administrator last set them,
+right before each call is dispatched: a handler that is still running when its
+plugin is disabled, restricted to other repositories or uninstalled cannot act
+under the authority it started with.
+
+Every call, allowed or refused, is recorded in the plugin's audit trail. The
+actor is `plugin:` and the action is `handler_call` or `handler_refused`.
+A call that changes something (an issue, comment, label or task) is also
+recorded as `handler_attempt` before it is sent. If that record cannot be
+written, the call is refused and nothing is sent.
+Issues and comments a plugin writes end with ``. A
+task it creates records `requestedBy: plugin:` in its first task event.
+
+Handlers observe and create new work: they never interact with a running task.
+
+### Outbound HTTP and secrets
+
+`ctx.http` reaches only the hostnames in `outboundHosts`, over HTTPS, on the
+default port, and only when every address the name resolves to is public.
+Private, loopback and link-local addresses and local names are refused, and
+redirects are not followed. Requests are limited to 256 KiB and 10 seconds, and
+responses to 1 MiB.
+
+Handlers never receive raw secrets. `ctx.http('my-token')` opens the stored
+`token` connection only to send the request, as `Authorization: Bearer …`, and
+removes the credential from the response before the handler reads it. Log lines,
+error messages and results pass the secret redaction.
+
+### State
+
+`ctx.state` holds up to 256 keys and 256 KiB per installation, each value at
+most 16 KiB of JSON. It is meant for cursors and mappings, for example "this
+pull request maps to that chat thread". It is kept when the plugin is disabled
+and deleted when it is uninstalled.
+
+### Imports
+
+Handler code may import `@propr/shared` (the SDK types live there), Node
+built-ins and files of its own package, but nothing else from ProPR. A lint rule
+for `packages/plugins/**` enforces this. Run it with
+`npm run lint -w @propr/plugins`.
+
+### Testing handlers
+
+`packages/plugins/testing` provides `createTestPluginContext()`: a fake `ctx`
+with recorded `ctx.propr` calls, scripted `ctx.http` responses, in-memory state
+and captured logs. It applies the same permission, repository, host and state
+checks as the real one. `packages/plugins/test/testing.test.ts` uses it on the
+example plugin's `recordEvent` handler.
+
+## Event subscriptions
+
+A plugin with the `events.read` permission can subscribe its handlers to
+[repository journal events](../architecture/repository-event-journal.md):
+
+```json
+"contributes": {
+ "subscriptions": [
+ { "on": "pull_request.merged", "handler": "onMerged" },
+ { "on": "task.completed", "handler": "onTask", "filter": { "payload.kind": ["implementation", "review"] } }
+ ]
+}
+```
+
+```ts
+// handlers.ts
+export const onMerged = definePluginHandler(async ({ event, deliveryId }, ctx) => {
+ if (await ctx.state.get(`posted:${deliveryId}`)) return; // delivered before: do nothing twice
+ await ctx.http('chat-token')('https://api.chat.example/messages', { method: 'POST', json: { text: `#${event.prNumber} merged` } });
+ await ctx.state.set(`posted:${deliveryId}`, true);
+});
+```
+
+`on` is `.` of a journal event. `handler` is an exported function
+of the plugin's `handlers.ts`. One event can go to several handlers, and one
+handler can take several events. A pair of `on` and `handler` may appear only
+once.
+
+`filter` is optional. Every key must match. A key is an event field
+(`repository`, `entityId`, `prNumber`, `issueNumber`, `planId`, `headSha`) or a
+payload field (`payload.`). A value is a string, number, boolean or `null`,
+or a list of them, which matches any entry. `repository` is compared without
+letter case. Filters only narrow what a subscription receives: the plugin's
+repository limit always applies.
+
+### Events and payloads
+
+Each handler receives `{ kind: 'event', event, deliveryId, attempt }`. `event`
+is the journal event: `seq`, `repository`, `domain`, `type`, `entityId`,
+`prNumber`, `issueNumber`, `planId`, `headSha`, `payload` and `occurredAt`.
+Payloads carry identifiers and short coded values only, never pull request
+bodies, comments, prompts, logs or diffs.
+
+| `on` | When | `payload` |
+| --- | --- | --- |
+| `pull_request.opened` | A pull request was opened | `baseRef`, `draft` |
+| `pull_request.head_changed` | New commits were pushed (`headSha` is the new head) | `previousHeadSha` |
+| `pull_request.review_scored` | A review was scored | `score`, `goal`, `goalReached`, `source`, `cycle`, `reviewerModel` |
+| `pull_request.ultrafix_started` | An Ultrafix loop started | `goal`, `maxCycles`, `workEpoch` |
+| `pull_request.ultrafix_ended` | An Ultrafix loop ended | `outcome`, `finalScore`, `reason`, `workEpoch` |
+| `pull_request.auto_merge_decision` | Auto-merge decided on a head | The auto-merge decision fields |
+| `pull_request.merged` | The pull request was merged | none |
+| `pull_request.closed` | The pull request was closed unmerged | none |
+| `epic_queue.started` | An epic queue started | `issues`, `advanceOn`, `parallel` |
+| `epic_queue.advanced` | The queue moved to the next issue | `fromIssue`, `toIssue`, `cursor` |
+| `epic_queue.blocked` | The queue head is blocked | `headIssue`, `blockedReason` |
+| `epic_queue.completed` | The queue finished | `fromIssue`, `cursor` |
+| `plan_issue.status_changed` | A plan issue changed status | `from`, `to` |
+| `task.queued`, `task.started` | A task was queued, or started running | `taskId`, `kind`, `issueNumber`, `prNumber`, `agent`, `model`, `attempts` |
+| `task.completed`, `task.failed`, `task.cancelled` | A task finished | The `task.queued` fields and `status`, `terminalReason`, `durationMs`, `pullRequest`, `failureCategory` |
+
+The [journal page](../architecture/repository-event-journal.md#event-types)
+describes each field.
+
+### Delivery guarantees and limits
+
+- **At least once.** A delivery can repeat, for example when a worker stops
+ while a handler runs. `deliveryId` (`evt--`) is the same on
+ every attempt, after a restart too, so a handler with side effects should
+ record the ids it has handled (`ctx.state` is meant for this).
+- **In order per repository.** One plugin receives the events of one
+ repository one at a time, in journal order. The next event waits until the
+ current one has succeeded or finally failed. Other repositories go on
+ meanwhile.
+- **30 seconds** per attempt. A handler still running then has failed.
+- **Retries.** A failed attempt is retried after 1 minute, 5 minutes,
+ 30 minutes and 2 hours. After the fifth attempt the delivery is marked
+ failed and the next event follows.
+- **No history.** Installing a plugin, or enabling it again, starts its
+ subscriptions at that moment. Earlier events, and events journaled while it
+ was disabled, are not delivered.
+- **Repository limit.** Events of repositories outside the plugin's limit are
+ not delivered. A change to the limit, disabling the plugin or revoking
+ `events.read` applies from the next delivery, even within a batch.
+- **Retention gaps.** The journal keeps 30 days of events. If events leave the
+ journal before they were delivered (for example while deliveries were
+ paused for a long time), they are skipped and the Activity list records the
+ gap.
+- **Never in the way.** Delivery runs after the event is journaled, in the
+ daemon. A handler that fails, times out or crashes never fails or delays the
+ task, review, merge or queue step that produced the event.
+- **One deliverer.** When several daemons run, only one delivers to a given
+ plugin at a time.
+
+### Health, Degraded and Resume
+
+After 3 failed attempts in a row the plugin is shown as **Degraded** on its
+page, with the last error, and every administrator gets one Inbox
+notification. A successful delivery clears it.
+
+After 10 failed attempts in a row (set `PROPR_PLUGIN_PAUSE_AFTER_FAILURES` to
+change this; it is never below 3) deliveries pause: nothing is delivered to the
+plugin, and new events wait in the journal. **Resume deliveries** on the plugin
+page starts them again; the failure count starts over and waiting retries run
+at once.
+
+### Activity
+
+Each delivery is recorded on the plugin page under **Activity**, newest
+first: the time, the event type and `seq`, the handler, the outcome, the
+duration, the attempt, the error message and the handler's log lines (all
+redacted). Event payloads, handler results and credentials are never stored.
+Records are kept for 14 days (longer while their event still waits on another
+handler or on Resume) and are deleted when the plugin is uninstalled.
+
+**Retry** on a failed delivery gives it one more attempt, with the same
+`deliveryId`, outside the repository's order. While deliveries are paused, the
+retry waits for Resume. A retried delivery of a repository now outside the
+plugin's limit is not delivered; it is marked failed again.
+
+The same records are available to administrators through the API:
+
+| Route | What it does |
+| --- | --- |
+| `GET /api/instance/plugins/v1/:pluginId/activity` | Delivery health and the newest 100 records. |
+| `POST /api/instance/plugins/v1/:pluginId/activity/:id/retry` | Retry a failed delivery (409 otherwise). |
+| `POST /api/instance/plugins/v1/:pluginId/resume` | Resume paused deliveries (409 when not paused). |
+
+Mutations need the same `X-ProPR-Plugins: 1` JSON contract as the other plugin
+routes, and Retry and Resume are recorded in the plugin's audit trail.
diff --git a/docs/docs/features/web-ui.md b/docs/docs/features/web-ui.md
index 816eebeb2b..72872219a3 100644
--- a/docs/docs/features/web-ui.md
+++ b/docs/docs/features/web-ui.md
@@ -9,7 +9,7 @@ The ProPR Web UI configures repositories and agents, launches work, and shows pr
## Navigation And Chrome
-The sidebar's work area contains **Dashboard**, **Inbox**, **Tasks**, **Goals**, **Automations** and **Plans**. The system area contains **Repositories**, **Coding Agents** (administrators), **Analytics**, the collapsible **Logs** group, **Settings** and **Access** (administrators). Count badges show current work and unread Inbox items. Agent Tank usage appears below navigation when enabled.
+The sidebar's work area contains **Dashboard**, **Inbox**, **Tasks**, **Goals**, **Automations** and **Plans**. The system area contains **Repositories**, **Coding Agents** (administrators), **Analytics**, the collapsible **Logs** group, **Settings**, **Plugins** and **Access** (administrators). Count badges show current work and unread Inbox items. Agent Tank usage appears below navigation when enabled.
The header includes search (`Cmd/Ctrl+K`), activity/review controls, quick add to-do (`Alt+T`) and system health. Search opens a command palette that groups matching repositories, plans and tasks, with a preview of the highlighted item beside the list on wider screens. Tab and Shift+Tab switch between the **All**, **Repos**, **Plans** and **Tasks** tabs, Enter opens the highlighted item, Cmd/Ctrl+Enter opens it on GitHub, and **Search all tasks** runs the term as a full Tasks search. **New Task** is the default creation action. It becomes **New Plan** in Plans/Planner Studio, **New Goal** in Goals and **New Automation** in Automations; the adjacent menu offers the other actions. See [Launching work](./launching-work.md) to choose between them. After quick add confirms **To-Do added**, choose **Add another** to enter the next item without closing the popover. Desktop puts application information in **About ProPR** rather than the sidebar footer.
@@ -122,6 +122,18 @@ for the operator walkthrough and the log's filters.

+## Plugins
+
+**Plugins** (`/plugins`) installs and manages [plugins](./plugins.md#the-plugins-page). It appears in the sidebar (on mobile, under **More**) and as an **Open Plugins** link above **Skills & MCP servers** in Settings, only for durable administrators with `instance.manage_settings`; anyone else who opens the address sees *Administrator access required*.
+
+1. The catalog shows a card per plugin with its name, description, category and a state badge (**Not installed**, **Enabled**, **Off**, **Needs connection**, **Needs approval** or **Degraded**). Filter by category or search by name, then open a card.
+2. **Install** lists what the plugin adds and the permissions it asks for. If a local package server receives secret variables, tick the acknowledgement that names its package, version and variables, then choose **Install**.
+3. **Connect** has one row per connection: **Connect** for an OAuth sign-in (it opens in a new tab), or a write-only field for an API token or signing secret.
+4. **Configure** shows the plugin's settings as a form generated from its manifest (text, number, Not set / Yes / No, list of choices, one-per-line list and a value per repository) and **Limit to repositories**; leave the limit empty to apply everywhere. A value the API refuses shows its message under the field. Required settings are marked **Required to enable**.
+5. Tick **Enabled**. While something is missing, the checkbox is unavailable and the step lists what to do first. Untick it to disable the plugin and keep its settings.
+6. After an upgrade that asks for more, **Install** shows the new permissions and what changed in each package server, with **Approve**.
+7. **What it added** links the plugin's managed items in Skills & MCP servers, where they carry a **From plugin** badge linking back and only their enabled checkbox, limits and automatic apply can be changed. **Uninstall…** lists what will be removed before you confirm, and shows the API's message if an automation still uses one of the plugin's items.
+
## Access
**Access** (`/admin/members`) is available to administrators. It creates durable `admin` and `member` role assignments using stable GitHub user IDs, shows environment administrators configured through `PROPR_ADMIN_USERS`, surfaces recent role-audit events, and prevents removal or demotion of the last durable administrator. On a new installation, sign in as a configured environment administrator and use **Store my administrator role** before removing that username from `PROPR_ADMIN_USERS`.
diff --git a/docs/docs/operations/api-reference.md b/docs/docs/operations/api-reference.md
index 1f2687a23a..a363675be2 100644
--- a/docs/docs/operations/api-reference.md
+++ b/docs/docs/operations/api-reference.md
@@ -121,7 +121,7 @@ An MCP receipt in state `unknown` with `OUTCOME_UNKNOWN` may have had its extern
## Repository change events
-`GET /api/repository-events?repository=owner/repo` reads a repository's durable change-event journal: pull request changes (`opened`, `head_changed`, `review_scored`, `ultrafix_started`, `ultrafix_ended`, `auto_merge_decision`, `merged`, `closed`), epic queue changes (`started`, `advanced`, `blocked`, `completed`) and plan issue status changes (`status_changed`). `GET /api/repository-events/wait` takes the same parameters plus `timeoutSeconds` (default 15, max 30) and blocks until a matching event exists after the cursor. The MCP tools `list_repository_events` and `wait_repository_events` and `propr events list|wait` return the same results.
+`GET /api/repository-events?repository=owner/repo` reads a repository's durable change-event journal: pull request changes (`opened`, `head_changed`, `review_scored`, `ultrafix_started`, `ultrafix_ended`, `auto_merge_decision`, `merged`, `closed`), epic queue changes (`started`, `advanced`, `blocked`, `completed`), plan issue status changes (`status_changed`) and task lifecycle changes (`queued`, `started`, `completed`, `failed`, `cancelled`; `entityId` is the task id). See the [journal reference](../architecture/repository-event-journal.md#task) for each domain's payloads. `GET /api/repository-events/wait` takes the same parameters plus `timeoutSeconds` (default 15, max 30) and blocks until a matching event exists after the cursor. The MCP tools `list_repository_events` and `wait_repository_events` and `propr events list|wait` return the same results.
Parameters: `repository` (required), `afterCursor`, `fromStart=true`, `domains` and `types` (comma-separated), `pullRequest`, `planId` and `limit` (1–100, default 50). The list returns `{events, cursor, truncated}`; the wait returns `{outcome, events, cursor, truncated, waitedMs, timeoutSeconds}`, where `outcome` is `events` or `timeout`. A wait returns at once when matching events already exist; a timeout is an ordinary `200` with the cursor unchanged. Each event has `cursor`, `domain`, `type`, `repository`, `entityId`, `payload` (identifiers and small scalars only) and `occurredAt`, plus `pullRequest`, `issueNumber`, `planId` and `head` when they apply.
diff --git a/docs/docs/operations/configuration-reference.md b/docs/docs/operations/configuration-reference.md
index 016278ed1e..b20499e2e8 100644
--- a/docs/docs/operations/configuration-reference.md
+++ b/docs/docs/operations/configuration-reference.md
@@ -162,6 +162,7 @@ Queue and worker behavior; see [Worker Runtime](../architecture/worker-runtime.m
| `PUSH_RESCUE_BUNDLE_DIR` | `/rescue` (next to the SQLite database) | Durable directory for git bundles written when a rejected push cannot reach a rescue ref. Bundles are stored as `///.bundle`. Must be visible to both the worker (which writes) and the daemon (which prunes). | Optional. |
| `PUSH_RESCUE_WORKTREE_RECORD_DIR` | `/rescue-worktrees` (next to the SQLite database) | Worker-controlled records of worktrees kept by the last salvage rung. These records protect salvage state independently of `WORKTREE_RETENTION_STRATEGY`; such worktrees are protected while the record has no cleanup deadline or its `scheduledCleanup` is still in the future; a `.retention-info.json` inside the worktree is informational, since repository contents could forge it. Must be shared at the same configured location by workers and the daemon, whose idle sweep reads these records. | Optional. |
| `PUSH_RESCUE_SWEEP_INTERVAL_MS` | `21600000` (6 hours) | How often the daemon deletes expired rescue refs and bundles. | Optional tuning. |
+| `PROPR_PLUGIN_PAUSE_AFTER_FAILURES` | `10` | Consecutive failed event deliveries after which the daemon pauses deliveries to a plugin until an administrator presses Resume. Values below `3` (the Degraded threshold) are ignored. See [Plugins → Health, Degraded and Resume](../features/plugins.md#health-degraded-and-resume). | Optional tuning. |
| `GIT_DEFAULT_BRANCH` | `main` | Default base branch for PRs. Per-repo overrides use `GIT_DEFAULT_BRANCH__` — see [Branch Configuration](../features/branch-config.md). | Optional. |
| `GIT_SHALLOW_CLONE_DEPTH` | Empty (full clones) | Depth for shallow clones; leave empty to clone full history. | Optional. |
diff --git a/docs/mcp-coverage.md b/docs/mcp-coverage.md
index 82c9b41083..ec8a028745 100644
--- a/docs/mcp-coverage.md
+++ b/docs/mcp-coverage.md
@@ -74,6 +74,7 @@ configuration. Live provider and chat-host acceptance are separate from this cat
| Runtime package configuration/build | `get_runtime_configuration`, `update_runtime_configuration` |
| Held follow-up handoffs (maintenance) | `list_followup_handoffs`, `get_followup_handoff`, `resume_followup_handoff`, `release_followup_handoff`; the operator decisions behind `/api/admin/followup-handoffs` and the Settings → Maintenance → Held follow-ups section, under `instance.manage_runtime` |
| Instance membership administration | `list_instance_members`, `add_instance_member`, `set_instance_member_role`, `remove_instance_member`, `get_instance_role_audit` manage directly assigned instance roles; existing last-admin guards. Trigger whitelist changes stay separate: follow up with `update_trigger_access_configuration`, as the web UI offers after an add or remove |
+| Plugin state (read-only) | `list_plugins`, `get_plugin` (`instance.manage_settings`): installation state, Needs approval, enable blockers, connection states, settings and recent audit events; never connection secrets. Install, configure and enable through `/api/instance/plugins/v1` |
| Shared repository chat history | `get_repository_chat`, `save_repository_chat_message`, `delete_repository_chat_message` |
| MCP access observability | Durable `mcp_access_log` row per tool call, resource read, prompt fetch and authentication failure; `GET /api/admin/mcp/logs` and `GET /api/admin/mcp/logs/stats`, both behind the existing `instance.manage_settings` permission; last-used and 24-hour request counts per connected app on `/mcp/apps`. Only names, identifiers, counts, sizes and outcomes are stored, and no MCP tool reads the log |
| GitHub credentials, provider login, agent secrets, push subscription | Browser settings/login links from connection/setup; never collect secrets through tools |
diff --git a/docs/mcp.md b/docs/mcp.md
index 8619a43356..7ce28e5d5b 100644
--- a/docs/mcp.md
+++ b/docs/mcp.md
@@ -119,8 +119,9 @@ schedule, next run, `eventTriggers`, capabilities and autonomy mode;
`get_automation_definition_contract` returns the shared field contract, including
`runTriggers` (with `event`) and `eventTriggers` (the events, here `plan.issue_blocked`,
their reason kinds and `maxRunsPerIssue`, the loop bound of 3 runs per issue of a plan), and
-`list_automation_templates` returns the shipped template catalog (for example daily
-issue triage). A template creates nothing; it is a starting point for a new automation.
+`list_automation_templates` returns the template catalog: the shipped templates (for
+example daily issue triage) and those of enabled plugins, marked with the plugin's id and
+name. A template creates nothing; it is a starting point for a new automation.
`list_automation_runs` lists a definition's runs newest first without report bodies,
and `get_automation_run` returns one run with its report. A run started by an event has
`trigger: "event"` and `triggerEvent`: the repository, plan, execution, issue number,
@@ -361,6 +362,19 @@ branch is freed, files and commits are never deleted; a worktree that still
holds unpublished work is released only with force set to true). The same
decisions are in Settings → Maintenance → Held follow-ups.
+Instance administrators with `instance.manage_settings` can read plugin state
+with `list_plugins` and `get_plugin`. `list_plugins` returns one compact row
+per plugin of this ProPR's registry (and per installed plugin an upgrade
+removed from it): its state (available, installed or enabled), available and
+installed manifest versions, whether it needs approval, the permissions waiting
+for approval, why enabling is refused right now, and each connection's state.
+`get_plugin` adds the public manifest, settings, repository limit, granted
+permissions, package-server secret acknowledgements and the newest management
+events of the plugin audit trail. Both are read-only: installing, configuring,
+connecting, enabling, approving and uninstalling go through the
+[plugin REST API](docs/features/plugins.md#installing-a-plugin). Connection
+secrets are write-only and never appear in either result.
+
Start with `get_connection`, `list_repositories`, `list_models` and
`resolve_reference`. Resolve ambiguity before mutating. `create_plan` creates
a draft (with plan items it opens in Studio at Review, so its issues can be
diff --git a/docs/sidebars.ts b/docs/sidebars.ts
index 80234fb602..b8bd039446 100644
--- a/docs/sidebars.ts
+++ b/docs/sidebars.ts
@@ -123,6 +123,7 @@ const sidebars: SidebarsConfig = {
'architecture/agent-runtime',
'architecture/git-management',
'architecture/repository-event-journal',
+ 'architecture/plugin-runtime',
'operations/web-ui-integration',
'features/cli-workflows',
{
diff --git a/docs/static/openapi/propr-api.yaml b/docs/static/openapi/propr-api.yaml
index 9085cbcfe3..d927f1f568 100644
--- a/docs/static/openapi/propr-api.yaml
+++ b/docs/static/openapi/propr-api.yaml
@@ -29,8 +29,8 @@ info:
name: Apache-2.0
identifier: Apache-2.0
x-route-coverage:
- total: 326
- documented: 88
+ total: 342
+ documented: 104
undocumented: 238
servers:
- url: "{origin}"
@@ -4832,23 +4832,26 @@ paths:
x-propr-auth: member
x-propr-permission: instance.manage_settings
x-legacy-error: true
- /api/instance/storage/v1/capabilities:
+ /api/instance/plugins/v1:
get:
- operationId: getInstanceStorageCapabilities
- summary: Negotiate native instance storage capabilities and process generation.
- description: Requires the `instance.manage_settings` instance permission.
+ operationId: listPlugins
+ summary: List the plugins of this ProPR's registry, and installed plugins an upgrade removed from it, with their installation state.
+ description: |-
+ Durable instance administrator with instance.manage_settings required. Mutations require application/json and X-ProPR-Plugins: 1, and are recorded in the plugin audit trail. Connection secrets are never returned.
+
+ Requires the `instance.manage_settings` instance permission.
tags:
- - System
+ - Configuration
security:
- sessionCookie: []
- bearerAuth: []
responses:
"200":
- description: Instance administrator capabilities; no Connect subscription required.
+ description: Plugins in catalog order.
content:
application/json:
schema:
- $ref: "#/components/schemas/InstanceStorageCapabilities"
+ $ref: "#/components/schemas/PluginList"
"401":
$ref: "#/components/responses/LegacyUnauthorized"
"403":
@@ -4858,39 +4861,33 @@ paths:
x-propr-auth: member
x-propr-permission: instance.manage_settings
x-legacy-error: true
- /api/instance/storage/v1/cleanup/execute:
- post:
- operationId: executeInstanceStorageCleanup
- summary: Consume a principal-bound confirmation and revalidate before deleting any candidate.
+ /api/instance/plugins/v1/{pluginId}:
+ get:
+ operationId: getPlugin
+ summary: "Read one plugin: manifest, settings, connection states, pending approvals and acknowledgements, and its newest audit events."
description: |-
- Version 1 contract: docs/architecture/instance-storage-v1. Instance administrator required. Mutations require application/json and X-ProPR-Storage: 1. Offline/unknown usage is never zero.
+ Durable instance administrator with instance.manage_settings required. Mutations require application/json and X-ProPR-Plugins: 1, and are recorded in the plugin audit trail. Connection secrets are never returned.
Requires the `instance.manage_settings` instance permission.
tags:
- - System
+ - Configuration
security:
- sessionCookie: []
- bearerAuth: []
parameters:
- - name: X-ProPR-Storage
- in: header
+ - name: pluginId
+ in: path
required: true
- description: Must equal 1; browser calls require an authorized CORS preflight.
+ description: Plugin id, for example `example-pack`.
schema:
type: string
- requestBody:
- required: true
- content:
- application/json:
- schema:
- $ref: "#/components/schemas/InstanceStorageExecuteRequest"
responses:
"200":
- description: Versioned instance storage response.
+ description: The plugin.
content:
application/json:
schema:
- $ref: "#/components/schemas/InstanceStorageResult"
+ $ref: "#/components/schemas/PluginDetailResponse"
"401":
$ref: "#/components/responses/LegacyUnauthorized"
"403":
@@ -4900,62 +4897,69 @@ paths:
x-propr-auth: member
x-propr-permission: instance.manage_settings
x-legacy-error: true
- /api/instance/storage/v1/cleanup/operations/{operationId}:
+ /api/instance/plugins/v1/{pluginId}/activity:
get:
- operationId: getInstanceStorageCleanupResult
- summary: Read the initiating principal’s durable cleanup result.
+ operationId: getPluginActivity
+ summary: "Read the plugin's event delivery health (healthy, degraded or paused, with the last error) and its newest delivery records: time, event id and type, handler, outcome, duration, attempt, redacted error and log lines. Records are kept for 14 days."
description: |-
- Version 1 contract: docs/architecture/instance-storage-v1. Instance administrator required. Mutations require application/json and X-ProPR-Storage: 1. Offline/unknown usage is never zero.
+ Durable instance administrator with instance.manage_settings required. Mutations require application/json and X-ProPR-Plugins: 1, and are recorded in the plugin audit trail. Connection secrets are never returned.
Requires the `instance.manage_settings` instance permission.
tags:
- - System
+ - Configuration
security:
- sessionCookie: []
- bearerAuth: []
parameters:
- - name: operationId
+ - name: pluginId
in: path
required: true
- schema:
- type: string
- - name: instanceId
- in: query
- required: true
+ description: Plugin id, for example `example-pack`.
schema:
type: string
responses:
"200":
- description: Versioned instance storage response.
+ description: Health and delivery records, newest first.
content:
application/json:
schema:
- $ref: "#/components/schemas/InstanceStorageResult"
+ $ref: "#/components/schemas/PluginActivityResponse"
"401":
$ref: "#/components/responses/LegacyUnauthorized"
"403":
$ref: "#/components/responses/LegacyForbidden"
default:
$ref: "#/components/responses/LegacyUnexpectedError"
- x-propr-query-type: InstanceStorageResultQuery
x-propr-auth: member
x-propr-permission: instance.manage_settings
x-legacy-error: true
- /api/instance/storage/v1/cleanup/preview:
+ /api/instance/plugins/v1/{pluginId}/activity/{deliveryId}/retry:
post:
- operationId: previewInstanceStorageCleanup
- summary: Preview an explicit instance/category selection; worktrees includes only proven generated payloads; terminal task history requires explicit taskIds.
+ operationId: retryPluginDelivery
+ summary: "Retry a failed event delivery: it gets one more attempt, with the same delivery id, the next time the dispatcher runs (after Resume while deliveries are paused). Refused (409) for a delivery that has not failed."
description: |-
- Version 1 contract: docs/architecture/instance-storage-v1. Instance administrator required. Mutations require application/json and X-ProPR-Storage: 1. Offline/unknown usage is never zero.
+ Durable instance administrator with instance.manage_settings required. Mutations require application/json and X-ProPR-Plugins: 1, and are recorded in the plugin audit trail. Connection secrets are never returned.
Requires the `instance.manage_settings` instance permission.
tags:
- - System
+ - Configuration
security:
- sessionCookie: []
- bearerAuth: []
parameters:
- - name: X-ProPR-Storage
+ - name: pluginId
+ in: path
+ required: true
+ description: Plugin id, for example `example-pack`.
+ schema:
+ type: string
+ - name: deliveryId
+ in: path
+ required: true
+ description: The activity record id (`id` of a delivery).
+ schema:
+ type: string
+ - name: X-ProPR-Plugins
in: header
required: true
description: Must equal 1; browser calls require an authorized CORS preflight.
@@ -4966,14 +4970,14 @@ paths:
content:
application/json:
schema:
- $ref: "#/components/schemas/InstanceStoragePreviewRequest"
+ $ref: "#/components/schemas/PluginEmptyRequest"
responses:
"200":
- description: Versioned instance storage response.
+ description: The delivery, pending again.
content:
application/json:
schema:
- $ref: "#/components/schemas/InstanceStoragePreview"
+ $ref: "#/components/schemas/PluginDeliveryResponse"
"401":
$ref: "#/components/responses/LegacyUnauthorized"
"403":
@@ -4983,21 +4987,30 @@ paths:
x-propr-auth: member
x-propr-permission: instance.manage_settings
x-legacy-error: true
- /api/instance/storage/v1/exports:
+ /api/instance/plugins/v1/{pluginId}/approve:
post:
- operationId: startInstanceDatabaseExport
- summary: Start a private asynchronous customer database snapshot.
- description: Requires the `instance.manage_settings` instance permission.
+ operationId: approvePlugin
+ summary: Approve the permission set of the current manifest after an upgrade, with the secret acknowledgements it needs again.
+ description: |-
+ Durable instance administrator with instance.manage_settings required. Mutations require application/json and X-ProPR-Plugins: 1, and are recorded in the plugin audit trail. Connection secrets are never returned.
+
+ Requires the `instance.manage_settings` instance permission.
tags:
- - System
+ - Configuration
security:
- sessionCookie: []
- bearerAuth: []
parameters:
- - name: X-ProPR-Storage
+ - name: pluginId
+ in: path
+ required: true
+ description: Plugin id, for example `example-pack`.
+ schema:
+ type: string
+ - name: X-ProPR-Plugins
in: header
required: true
- description: Must equal 1.
+ description: Must equal 1; browser calls require an authorized CORS preflight.
schema:
type: string
requestBody:
@@ -5005,14 +5018,14 @@ paths:
content:
application/json:
schema:
- $ref: "#/components/schemas/InstanceDatabaseExportRequest"
+ $ref: "#/components/schemas/PluginAcknowledgementRequest"
responses:
- "202":
- description: Actor-bound expiring export job.
+ "200":
+ description: The plugin's new state.
content:
application/json:
schema:
- $ref: "#/components/schemas/InstanceDatabaseExport"
+ $ref: "#/components/schemas/PluginResponse"
"401":
$ref: "#/components/responses/LegacyUnauthorized"
"403":
@@ -5022,71 +5035,90 @@ paths:
x-propr-auth: member
x-propr-permission: instance.manage_settings
x-legacy-error: true
- /api/instance/storage/v1/exports/{operationId}:
- get:
- operationId: getInstanceDatabaseExport
- summary: Read the initiating administrator’s export status.
- description: Requires the `instance.manage_settings` instance permission.
+ /api/instance/plugins/v1/{pluginId}/connections/{connectionName}:
+ put:
+ operationId: setPluginConnection
+ summary: Set or clear a `token` or `signing_secret` connection. The secret is sealed and write-only.
+ description: |-
+ Durable instance administrator with instance.manage_settings required. Mutations require application/json and X-ProPR-Plugins: 1, and are recorded in the plugin audit trail. Connection secrets are never returned.
+
+ Requires the `instance.manage_settings` instance permission.
tags:
- - System
+ - Configuration
security:
- sessionCookie: []
- bearerAuth: []
parameters:
- - name: operationId
+ - name: pluginId
in: path
required: true
+ description: Plugin id, for example `example-pack`.
schema:
type: string
- - name: instanceId
- in: query
+ - name: connectionName
+ in: path
required: true
+ description: Connection name from the manifest.
schema:
type: string
- - name: generation
- in: query
+ - name: X-ProPR-Plugins
+ in: header
required: true
+ description: Must equal 1; browser calls require an authorized CORS preflight.
schema:
type: string
- format: uuid
- pattern: ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/PluginConnectionRequest"
responses:
"200":
- description: Export state, digest, size and schema version.
+ description: The plugin's new state.
content:
application/json:
schema:
- $ref: "#/components/schemas/InstanceDatabaseExport"
+ $ref: "#/components/schemas/PluginResponse"
"401":
$ref: "#/components/responses/LegacyUnauthorized"
"403":
$ref: "#/components/responses/LegacyForbidden"
default:
$ref: "#/components/responses/LegacyUnexpectedError"
- x-propr-query-type: InstanceDatabaseExportQuery
x-propr-auth: member
x-propr-permission: instance.manage_settings
x-legacy-error: true
- /api/instance/storage/v1/exports/{operationId}/cancel:
- post:
- operationId: cancelInstanceDatabaseExport
- summary: Abort and discard an administrator’s private export.
- description: Requires the `instance.manage_settings` instance permission.
+ /api/instance/plugins/v1/{pluginId}/connections/{connectionName}/oauth/client:
+ put:
+ operationId: setPluginOAuthClient
+ summary: Configure or clear the pre-registered OAuth client of an `oauth` connection whose `registrations` permit `preregistered` (409 otherwise). The client id becomes the managed MCP servers' client id and the secret is sealed and write-only; changing the client discards their OAuth connections.
+ description: |-
+ Durable instance administrator with instance.manage_settings required. Mutations require application/json and X-ProPR-Plugins: 1, and are recorded in the plugin audit trail. Connection secrets are never returned.
+
+ Requires the `instance.manage_settings` instance permission.
tags:
- - System
+ - Configuration
security:
- sessionCookie: []
- bearerAuth: []
parameters:
- - name: operationId
+ - name: pluginId
in: path
required: true
+ description: Plugin id, for example `example-pack`.
schema:
type: string
- - name: X-ProPR-Storage
+ - name: connectionName
+ in: path
+ required: true
+ description: An `oauth` connection name from the manifest.
+ schema:
+ type: string
+ - name: X-ProPR-Plugins
in: header
required: true
- description: Must equal 1.
+ description: Must equal 1; browser calls require an authorized CORS preflight.
schema:
type: string
requestBody:
@@ -5094,14 +5126,14 @@ paths:
content:
application/json:
schema:
- $ref: "#/components/schemas/InstanceDatabaseExportRequest"
+ $ref: "#/components/schemas/PluginOAuthClientRequest"
responses:
"200":
- description: Export discarded.
+ description: The plugin's new state.
content:
application/json:
schema:
- $ref: "#/components/schemas/JsonObject"
+ $ref: "#/components/schemas/PluginResponse"
"401":
$ref: "#/components/responses/LegacyUnauthorized"
"403":
@@ -5111,26 +5143,36 @@ paths:
x-propr-auth: member
x-propr-permission: instance.manage_settings
x-legacy-error: true
- /api/instance/storage/v1/exports/{operationId}/download:
+ /api/instance/plugins/v1/{pluginId}/connections/{connectionName}/oauth/complete:
post:
- operationId: downloadInstanceDatabaseExport
- summary: Redeem an export once using current instance-admin authority.
- description: Requires the `instance.manage_settings` instance permission.
+ operationId: completePluginOAuth
+ summary: Complete a sign-in of this connection with the state and code the provider returned, in the session that started it. The extension OAuth callback completes it too.
+ description: |-
+ Durable instance administrator with instance.manage_settings required. Mutations require application/json and X-ProPR-Plugins: 1, and are recorded in the plugin audit trail. Connection secrets are never returned.
+
+ Requires the `instance.manage_settings` instance permission.
tags:
- - System
+ - Configuration
security:
- sessionCookie: []
- bearerAuth: []
parameters:
- - name: operationId
+ - name: pluginId
in: path
required: true
+ description: Plugin id, for example `example-pack`.
schema:
type: string
- - name: X-ProPR-Storage
+ - name: connectionName
+ in: path
+ required: true
+ description: An `oauth` connection name from the manifest.
+ schema:
+ type: string
+ - name: X-ProPR-Plugins
in: header
required: true
- description: Must equal 1.
+ description: Must equal 1; browser calls require an authorized CORS preflight.
schema:
type: string
requestBody:
@@ -5138,14 +5180,14 @@ paths:
content:
application/json:
schema:
- $ref: "#/components/schemas/InstanceDatabaseExportRequest"
+ $ref: "#/components/schemas/PluginOAuthCompleteRequest"
responses:
"200":
- description: Integrity-checked SQLite snapshot; attachment, no-store.
+ description: The plugin's new state.
content:
- application/vnd.sqlite3:
+ application/json:
schema:
- type: string
+ $ref: "#/components/schemas/PluginResponse"
"401":
$ref: "#/components/responses/LegacyUnauthorized"
"403":
@@ -5155,26 +5197,51 @@ paths:
x-propr-auth: member
x-propr-permission: instance.manage_settings
x-legacy-error: true
- /api/instance/storage/v1/policy:
- get:
- operationId: getInstanceStoragePolicy
- summary: Get opt-in repository cleanup policy (disabled, 24 idle hours by default).
+ /api/instance/plugins/v1/{pluginId}/connections/{connectionName}/oauth/disconnect:
+ post:
+ operationId: disconnectPluginOAuth
+ summary: "Disconnect an `oauth` connection: its managed servers' tokens are revoked where possible and deleted, and later runs leave the servers out."
description: |-
- Version 1 contract: docs/architecture/instance-storage-v1. Instance administrator required. Mutations require application/json and X-ProPR-Storage: 1. Offline/unknown usage is never zero.
+ Durable instance administrator with instance.manage_settings required. Mutations require application/json and X-ProPR-Plugins: 1, and are recorded in the plugin audit trail. Connection secrets are never returned.
Requires the `instance.manage_settings` instance permission.
tags:
- - System
+ - Configuration
security:
- sessionCookie: []
- bearerAuth: []
+ parameters:
+ - name: pluginId
+ in: path
+ required: true
+ description: Plugin id, for example `example-pack`.
+ schema:
+ type: string
+ - name: connectionName
+ in: path
+ required: true
+ description: An `oauth` connection name from the manifest.
+ schema:
+ type: string
+ - name: X-ProPR-Plugins
+ in: header
+ required: true
+ description: Must equal 1; browser calls require an authorized CORS preflight.
+ schema:
+ type: string
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/PluginEmptyRequest"
responses:
"200":
- description: Versioned instance storage response.
+ description: The plugin's new state.
content:
application/json:
schema:
- $ref: "#/components/schemas/InstanceStoragePolicy"
+ $ref: "#/components/schemas/PluginResponse"
"401":
$ref: "#/components/responses/LegacyUnauthorized"
"403":
@@ -5184,20 +5251,33 @@ paths:
x-propr-auth: member
x-propr-permission: instance.manage_settings
x-legacy-error: true
- put:
- operationId: updateInstanceStoragePolicy
- summary: Persist the explicitly selected instance cleanup policy.
+ /api/instance/plugins/v1/{pluginId}/connections/{connectionName}/oauth/start:
+ post:
+ operationId: startPluginOAuth
+ summary: Start the extension OAuth sign-in of an `oauth` connection for its managed MCP server, bound to this browser session. ProPR identifies itself as it does for library servers.
description: |-
- Version 1 contract: docs/architecture/instance-storage-v1. Instance administrator required. Mutations require application/json and X-ProPR-Storage: 1. Offline/unknown usage is never zero.
+ Durable instance administrator with instance.manage_settings required. Mutations require application/json and X-ProPR-Plugins: 1, and are recorded in the plugin audit trail. Connection secrets are never returned.
Requires the `instance.manage_settings` instance permission.
tags:
- - System
+ - Configuration
security:
- sessionCookie: []
- bearerAuth: []
parameters:
- - name: X-ProPR-Storage
+ - name: pluginId
+ in: path
+ required: true
+ description: Plugin id, for example `example-pack`.
+ schema:
+ type: string
+ - name: connectionName
+ in: path
+ required: true
+ description: An `oauth` connection name from the manifest.
+ schema:
+ type: string
+ - name: X-ProPR-Plugins
in: header
required: true
description: Must equal 1; browser calls require an authorized CORS preflight.
@@ -5208,14 +5288,14 @@ paths:
content:
application/json:
schema:
- $ref: "#/components/schemas/InstanceStoragePolicyRequest"
+ $ref: "#/components/schemas/PluginEmptyRequest"
responses:
"200":
- description: Versioned instance storage response.
+ description: The authorization URL to open.
content:
application/json:
schema:
- $ref: "#/components/schemas/InstanceStoragePolicy"
+ $ref: "#/components/schemas/PluginOAuthStartResponse"
"401":
$ref: "#/components/responses/LegacyUnauthorized"
"403":
@@ -5225,23 +5305,45 @@ paths:
x-propr-auth: member
x-propr-permission: instance.manage_settings
x-legacy-error: true
- /api/instance/storage/v1/repositories:
- get:
- operationId: getInstanceRepositoryInventory
- summary: Read a bounded partial repository storage inventory with protection reasons.
- description: Requires the `instance.manage_settings` instance permission.
+ /api/instance/plugins/v1/{pluginId}/enabled:
+ put:
+ operationId: setPluginEnabled
+ summary: Enable or disable. Enabling is refused (409, with the reasons) while a required setting, a required token connection or a secret acknowledgement is missing. Enabling activates the plugin's managed extension library items and lists its automation templates; disabling deactivates and hides them and keeps settings and connections.
+ description: |-
+ Durable instance administrator with instance.manage_settings required. Mutations require application/json and X-ProPR-Plugins: 1, and are recorded in the plugin audit trail. Connection secrets are never returned.
+
+ Requires the `instance.manage_settings` instance permission.
tags:
- - System
+ - Configuration
security:
- sessionCookie: []
- bearerAuth: []
+ parameters:
+ - name: pluginId
+ in: path
+ required: true
+ description: Plugin id, for example `example-pack`.
+ schema:
+ type: string
+ - name: X-ProPR-Plugins
+ in: header
+ required: true
+ description: Must equal 1; browser calls require an authorized CORS preflight.
+ schema:
+ type: string
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/PluginEnabledRequest"
responses:
"200":
- description: Safe identifiers and sizes; no host paths or contents.
+ description: The plugin's new state.
content:
application/json:
schema:
- $ref: "#/components/schemas/InstanceRepositoryInventory"
+ $ref: "#/components/schemas/PluginResponse"
"401":
$ref: "#/components/responses/LegacyUnauthorized"
"403":
@@ -5251,490 +5353,704 @@ paths:
x-propr-auth: member
x-propr-permission: instance.manage_settings
x-legacy-error: true
- /api/instance/storage/v1/usage:
- get:
- operationId: getInstanceStorageUsage
- summary: Get bounded asynchronous per-instance storage observations.
+ /api/instance/plugins/v1/{pluginId}/install:
+ post:
+ operationId: installPlugin
+ summary: Install a registry plugin, disabled, with its default settings; grants the manifest's permissions. A package server with secret variables must be acknowledged.
description: |-
- Version 1 contract: docs/architecture/instance-storage-v1. Instance administrator required. Mutations require application/json and X-ProPR-Storage: 1. Offline/unknown usage is never zero.
+ Durable instance administrator with instance.manage_settings required. Mutations require application/json and X-ProPR-Plugins: 1, and are recorded in the plugin audit trail. Connection secrets are never returned.
Requires the `instance.manage_settings` instance permission.
tags:
- - System
+ - Configuration
security:
- sessionCookie: []
- bearerAuth: []
- responses:
- "200":
- description: Versioned instance storage response.
- content:
- application/json:
- schema:
- $ref: "#/components/schemas/InstanceStorageUsage"
- "401":
- $ref: "#/components/responses/LegacyUnauthorized"
- "403":
- $ref: "#/components/responses/LegacyForbidden"
- default:
- $ref: "#/components/responses/LegacyUnexpectedError"
- x-propr-auth: member
- x-propr-permission: instance.manage_settings
- x-legacy-error: true
- /api/internal/agent-runs/{runId}/mcp-grants:
- post:
- operationId: postApiInternalAgentRunsRunIdMcpGrants
- summary: POST /api/internal/agent-runs/{runId}/mcp-grants
- description: This route is registered but not documented yet.
- tags:
- - Automations
- security: []
parameters:
- - name: runId
+ - name: pluginId
in: path
required: true
+ description: Plugin id, for example `example-pack`.
+ schema:
+ type: string
+ - name: X-ProPR-Plugins
+ in: header
+ required: true
+ description: Must equal 1; browser calls require an authorized CORS preflight.
schema:
type: string
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/PluginAcknowledgementRequest"
responses:
"200":
- description: Success. The response body is not documented yet.
+ description: The plugin's new state.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/PluginResponse"
+ "401":
+ $ref: "#/components/responses/LegacyUnauthorized"
+ "403":
+ $ref: "#/components/responses/LegacyForbidden"
default:
$ref: "#/components/responses/LegacyUnexpectedError"
- x-propr-auth: public
- x-undocumented: true
+ x-propr-auth: member
+ x-propr-permission: instance.manage_settings
x-legacy-error: true
- /api/internal/agent-runs/{runId}/mcp-grants/revoke:
- post:
- operationId: postApiInternalAgentRunsRunIdMcpGrantsRevoke
- summary: POST /api/internal/agent-runs/{runId}/mcp-grants/revoke
- description: This route is registered but not documented yet.
+ /api/instance/plugins/v1/{pluginId}/repositories:
+ put:
+ operationId: setPluginRepositories
+ summary: Limit the plugin to repositories, or clear the limit (all repositories).
+ description: |-
+ Durable instance administrator with instance.manage_settings required. Mutations require application/json and X-ProPR-Plugins: 1, and are recorded in the plugin audit trail. Connection secrets are never returned.
+
+ Requires the `instance.manage_settings` instance permission.
tags:
- - Automations
- security: []
+ - Configuration
+ security:
+ - sessionCookie: []
+ - bearerAuth: []
parameters:
- - name: runId
+ - name: pluginId
in: path
required: true
+ description: Plugin id, for example `example-pack`.
schema:
type: string
+ - name: X-ProPR-Plugins
+ in: header
+ required: true
+ description: Must equal 1; browser calls require an authorized CORS preflight.
+ schema:
+ type: string
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/PluginRepositoriesRequest"
responses:
"200":
- description: Success. The response body is not documented yet.
- default:
- $ref: "#/components/responses/LegacyUnexpectedError"
- x-propr-auth: public
- x-undocumented: true
- x-legacy-error: true
- /api/internal/hosted/bootstrap:
- get:
- operationId: getApiInternalHostedBootstrap
- summary: GET /api/internal/hosted/bootstrap
- description: This route is registered but not documented yet.
- tags:
- - System
- security:
- - fleetSecret: []
- responses:
- "200":
- description: Success. The response body is not documented yet.
- default:
- $ref: "#/components/responses/LegacyUnexpectedError"
- x-propr-auth: fleetSecret
- x-undocumented: true
- x-legacy-error: true
- /api/internal/hosted/queue:
- get:
- operationId: getApiInternalHostedQueue
- summary: GET /api/internal/hosted/queue
- description: This route is registered but not documented yet.
- tags:
- - System
- security:
- - fleetSecret: []
- responses:
- "200":
- description: Success. The response body is not documented yet.
- default:
- $ref: "#/components/responses/LegacyUnexpectedError"
- x-propr-auth: fleetSecret
- x-undocumented: true
- x-legacy-error: true
- /api/internal/hosted/status:
- get:
- operationId: getApiInternalHostedStatus
- summary: GET /api/internal/hosted/status
- description: This route is registered but not documented yet.
- tags:
- - System
- security:
- - fleetSecret: []
- responses:
- "200":
- description: Success. The response body is not documented yet.
+ description: The plugin's new state.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/PluginResponse"
+ "401":
+ $ref: "#/components/responses/LegacyUnauthorized"
+ "403":
+ $ref: "#/components/responses/LegacyForbidden"
default:
$ref: "#/components/responses/LegacyUnexpectedError"
- x-propr-auth: fleetSecret
- x-undocumented: true
+ x-propr-auth: member
+ x-propr-permission: instance.manage_settings
x-legacy-error: true
- /api/llm-logs:
- get:
- operationId: getApiLlmLogs
- summary: GET /api/llm-logs
- description: This route is registered but not documented yet.
+ /api/instance/plugins/v1/{pluginId}/resume:
+ post:
+ operationId: resumePluginDeliveries
+ summary: "Resume event deliveries paused after repeated failures: the failure count starts over, Degraded clears and waiting retries are due at once. Refused (409) when deliveries are not paused."
+ description: |-
+ Durable instance administrator with instance.manage_settings required. Mutations require application/json and X-ProPR-Plugins: 1, and are recorded in the plugin audit trail. Connection secrets are never returned.
+
+ Requires the `instance.manage_settings` instance permission.
tags:
- - Observability
+ - Configuration
security:
- sessionCookie: []
- bearerAuth: []
+ parameters:
+ - name: pluginId
+ in: path
+ required: true
+ description: Plugin id, for example `example-pack`.
+ schema:
+ type: string
+ - name: X-ProPR-Plugins
+ in: header
+ required: true
+ description: Must equal 1; browser calls require an authorized CORS preflight.
+ schema:
+ type: string
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/PluginEmptyRequest"
responses:
"200":
- description: Success. The response body is not documented yet.
+ description: The new delivery health.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/PluginHealthResponse"
"401":
$ref: "#/components/responses/LegacyUnauthorized"
+ "403":
+ $ref: "#/components/responses/LegacyForbidden"
default:
$ref: "#/components/responses/LegacyUnexpectedError"
x-propr-auth: member
- x-undocumented: true
+ x-propr-permission: instance.manage_settings
x-legacy-error: true
- /api/llm-metrics:
- get:
- operationId: getApiLlmMetrics
- summary: GET /api/llm-metrics
- description: This route is registered but not documented yet.
+ /api/instance/plugins/v1/{pluginId}/settings:
+ put:
+ operationId: updatePluginSettings
+ summary: Replace the settings, validated against the manifest's settings schema. Required settings cannot be removed while the plugin is enabled.
+ description: |-
+ Durable instance administrator with instance.manage_settings required. Mutations require application/json and X-ProPR-Plugins: 1, and are recorded in the plugin audit trail. Connection secrets are never returned.
+
+ Requires the `instance.manage_settings` instance permission.
tags:
- - Observability
+ - Configuration
security:
- sessionCookie: []
- bearerAuth: []
+ parameters:
+ - name: pluginId
+ in: path
+ required: true
+ description: Plugin id, for example `example-pack`.
+ schema:
+ type: string
+ - name: X-ProPR-Plugins
+ in: header
+ required: true
+ description: Must equal 1; browser calls require an authorized CORS preflight.
+ schema:
+ type: string
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/PluginSettingsRequest"
responses:
"200":
- description: Success. The response body is not documented yet.
+ description: The plugin's new state.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/PluginResponse"
"401":
$ref: "#/components/responses/LegacyUnauthorized"
+ "403":
+ $ref: "#/components/responses/LegacyForbidden"
default:
$ref: "#/components/responses/LegacyUnexpectedError"
x-propr-auth: member
- x-undocumented: true
+ x-propr-permission: instance.manage_settings
x-legacy-error: true
- /api/llm-metrics/{correlationId}:
- get:
- operationId: getApiLlmMetricsCorrelationId
- summary: GET /api/llm-metrics/{correlationId}
+ /api/instance/plugins/v1/{pluginId}/uninstall:
+ post:
+ operationId: uninstallPlugin
+ summary: "Uninstall: deletes the installation, its settings, its connections and its managed extension library items. Refused (409, naming them) while an automation adds a managed item explicitly. The audit trail stays."
description: |-
- This route is registered but not documented yet.
+ Durable instance administrator with instance.manage_settings required. Mutations require application/json and X-ProPR-Plugins: 1, and are recorded in the plugin audit trail. Connection secrets are never returned.
- When the task, session or correlation id belongs to a goal, only the goal owner can use this route (others get `404`), and only for reads: other methods answer `409` and the goal controls must be used instead.
+ Requires the `instance.manage_settings` instance permission.
tags:
- - Observability
+ - Configuration
security:
- sessionCookie: []
- bearerAuth: []
parameters:
- - name: correlationId
+ - name: pluginId
in: path
required: true
+ description: Plugin id, for example `example-pack`.
+ schema:
+ type: string
+ - name: X-ProPR-Plugins
+ in: header
+ required: true
+ description: Must equal 1; browser calls require an authorized CORS preflight.
schema:
type: string
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/PluginUninstallRequest"
responses:
"200":
- description: Success. The response body is not documented yet.
- "401":
- $ref: "#/components/responses/LegacyUnauthorized"
- "404":
- description: Not found, or the task belongs to another user's goal.
+ description: The plugin's new state.
content:
application/json:
schema:
- $ref: "#/components/schemas/LegacyError"
+ $ref: "#/components/schemas/PluginResponse"
+ "401":
+ $ref: "#/components/responses/LegacyUnauthorized"
+ "403":
+ $ref: "#/components/responses/LegacyForbidden"
default:
$ref: "#/components/responses/LegacyUnexpectedError"
x-propr-auth: member
- x-undocumented: true
+ x-propr-permission: instance.manage_settings
x-legacy-error: true
- /api/mcp:
+ /api/instance/storage/v1/capabilities:
get:
- operationId: getApiMcp
- summary: GET /api/mcp
- description: This route is registered but not documented yet.
+ operationId: getInstanceStorageCapabilities
+ summary: Negotiate native instance storage capabilities and process generation.
+ description: Requires the `instance.manage_settings` instance permission.
tags:
- - MCP
+ - System
security:
- - mcpOAuth:
- - read
+ - sessionCookie: []
+ - bearerAuth: []
responses:
"200":
- description: Success. The response body is not documented yet.
+ description: Instance administrator capabilities; no Connect subscription required.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/InstanceStorageCapabilities"
"401":
$ref: "#/components/responses/LegacyUnauthorized"
+ "403":
+ $ref: "#/components/responses/LegacyForbidden"
default:
$ref: "#/components/responses/LegacyUnexpectedError"
- x-propr-auth: mcp
- x-undocumented: true
+ x-propr-auth: member
+ x-propr-permission: instance.manage_settings
x-legacy-error: true
+ /api/instance/storage/v1/cleanup/execute:
post:
- operationId: mcpRequest
- summary: MCP Streamable HTTP endpoint
- description: JSON-RPC requests of the Model Context Protocol. Each tool checks the token's scopes. Returns `404` while MCP is disabled. See the MCP guide (docs/features/mcp) for the tool catalogue.
- tags:
- - MCP
- security:
- - mcpOAuth:
- - read
+ operationId: executeInstanceStorageCleanup
+ summary: Consume a principal-bound confirmation and revalidate before deleting any candidate.
+ description: |-
+ Version 1 contract: docs/architecture/instance-storage-v1. Instance administrator required. Mutations require application/json and X-ProPR-Storage: 1. Offline/unknown usage is never zero.
+
+ Requires the `instance.manage_settings` instance permission.
+ tags:
+ - System
+ security:
+ - sessionCookie: []
+ - bearerAuth: []
+ parameters:
+ - name: X-ProPR-Storage
+ in: header
+ required: true
+ description: Must equal 1; browser calls require an authorized CORS preflight.
+ schema:
+ type: string
requestBody:
required: true
- description: A JSON-RPC 2.0 request or notification.
content:
application/json:
schema:
- $ref: "#/components/schemas/JsonObject"
+ $ref: "#/components/schemas/InstanceStorageExecuteRequest"
responses:
"200":
- description: JSON-RPC response.
- content:
- application/json:
- schema:
- $ref: "#/components/schemas/JsonObject"
- "202":
- description: Notification accepted.
- "401":
- $ref: "#/components/responses/LegacyUnauthorized"
- "404":
- description: MCP is disabled on this instance.
+ description: Versioned instance storage response.
content:
application/json:
schema:
- $ref: "#/components/schemas/LegacyError"
- default:
- $ref: "#/components/responses/LegacyUnexpectedError"
- x-propr-auth: mcp
- x-legacy-error: true
- delete:
- operationId: deleteApiMcp
- summary: DELETE /api/mcp
- description: This route is registered but not documented yet.
- tags:
- - MCP
- security:
- - mcpOAuth:
- - read
- responses:
- "200":
- description: Success. The response body is not documented yet.
+ $ref: "#/components/schemas/InstanceStorageResult"
"401":
$ref: "#/components/responses/LegacyUnauthorized"
+ "403":
+ $ref: "#/components/responses/LegacyForbidden"
default:
$ref: "#/components/responses/LegacyUnexpectedError"
- x-propr-auth: mcp
- x-undocumented: true
+ x-propr-auth: member
+ x-propr-permission: instance.manage_settings
x-legacy-error: true
- /api/metrics:
+ /api/instance/storage/v1/cleanup/operations/{operationId}:
get:
- operationId: getApiMetrics
- summary: GET /api/metrics
- description: This route is registered but not documented yet.
+ operationId: getInstanceStorageCleanupResult
+ summary: Read the initiating principal’s durable cleanup result.
+ description: |-
+ Version 1 contract: docs/architecture/instance-storage-v1. Instance administrator required. Mutations require application/json and X-ProPR-Storage: 1. Offline/unknown usage is never zero.
+
+ Requires the `instance.manage_settings` instance permission.
tags:
- - Observability
+ - System
security:
- sessionCookie: []
- bearerAuth: []
+ parameters:
+ - name: operationId
+ in: path
+ required: true
+ schema:
+ type: string
+ - name: instanceId
+ in: query
+ required: true
+ schema:
+ type: string
responses:
"200":
- description: Success. The response body is not documented yet.
+ description: Versioned instance storage response.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/InstanceStorageResult"
"401":
$ref: "#/components/responses/LegacyUnauthorized"
+ "403":
+ $ref: "#/components/responses/LegacyForbidden"
default:
$ref: "#/components/responses/LegacyUnexpectedError"
+ x-propr-query-type: InstanceStorageResultQuery
x-propr-auth: member
- x-undocumented: true
+ x-propr-permission: instance.manage_settings
x-legacy-error: true
- /api/notifications:
- get:
- operationId: getApiNotifications
- summary: GET /api/notifications
- description: This route is registered but not documented yet.
+ /api/instance/storage/v1/cleanup/preview:
+ post:
+ operationId: previewInstanceStorageCleanup
+ summary: Preview an explicit instance/category selection; worktrees includes only proven generated payloads; terminal task history requires explicit taskIds.
+ description: |-
+ Version 1 contract: docs/architecture/instance-storage-v1. Instance administrator required. Mutations require application/json and X-ProPR-Storage: 1. Offline/unknown usage is never zero.
+
+ Requires the `instance.manage_settings` instance permission.
tags:
- - Notifications
+ - System
security:
- sessionCookie: []
- bearerAuth: []
+ parameters:
+ - name: X-ProPR-Storage
+ in: header
+ required: true
+ description: Must equal 1; browser calls require an authorized CORS preflight.
+ schema:
+ type: string
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/InstanceStoragePreviewRequest"
responses:
"200":
- description: Success. The response body is not documented yet.
+ description: Versioned instance storage response.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/InstanceStoragePreview"
"401":
$ref: "#/components/responses/LegacyUnauthorized"
+ "403":
+ $ref: "#/components/responses/LegacyForbidden"
default:
$ref: "#/components/responses/LegacyUnexpectedError"
x-propr-auth: member
- x-undocumented: true
+ x-propr-permission: instance.manage_settings
x-legacy-error: true
- /api/notifications/capabilities:
- get:
- operationId: getApiNotificationsCapabilities
- summary: GET /api/notifications/capabilities
- description: This route is registered but not documented yet.
+ /api/instance/storage/v1/exports:
+ post:
+ operationId: startInstanceDatabaseExport
+ summary: Start a private asynchronous customer database snapshot.
+ description: Requires the `instance.manage_settings` instance permission.
tags:
- - Notifications
+ - System
security:
- sessionCookie: []
- bearerAuth: []
+ parameters:
+ - name: X-ProPR-Storage
+ in: header
+ required: true
+ description: Must equal 1.
+ schema:
+ type: string
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/InstanceDatabaseExportRequest"
responses:
- "200":
- description: Success. The response body is not documented yet.
+ "202":
+ description: Actor-bound expiring export job.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/InstanceDatabaseExport"
"401":
$ref: "#/components/responses/LegacyUnauthorized"
+ "403":
+ $ref: "#/components/responses/LegacyForbidden"
default:
$ref: "#/components/responses/LegacyUnexpectedError"
x-propr-auth: member
- x-undocumented: true
+ x-propr-permission: instance.manage_settings
x-legacy-error: true
- /api/notifications/config:
+ /api/instance/storage/v1/exports/{operationId}:
get:
- operationId: getApiNotificationsConfig
- summary: GET /api/notifications/config
- description: This route is registered but not documented yet.
+ operationId: getInstanceDatabaseExport
+ summary: Read the initiating administrator’s export status.
+ description: Requires the `instance.manage_settings` instance permission.
tags:
- - Notifications
+ - System
security:
- sessionCookie: []
- bearerAuth: []
+ parameters:
+ - name: operationId
+ in: path
+ required: true
+ schema:
+ type: string
+ - name: instanceId
+ in: query
+ required: true
+ schema:
+ type: string
+ - name: generation
+ in: query
+ required: true
+ schema:
+ type: string
+ format: uuid
+ pattern: ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$
responses:
"200":
- description: Success. The response body is not documented yet.
+ description: Export state, digest, size and schema version.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/InstanceDatabaseExport"
"401":
$ref: "#/components/responses/LegacyUnauthorized"
+ "403":
+ $ref: "#/components/responses/LegacyForbidden"
default:
$ref: "#/components/responses/LegacyUnexpectedError"
+ x-propr-query-type: InstanceDatabaseExportQuery
x-propr-auth: member
- x-undocumented: true
+ x-propr-permission: instance.manage_settings
x-legacy-error: true
- /api/notifications/dismiss-all:
+ /api/instance/storage/v1/exports/{operationId}/cancel:
post:
- operationId: postApiNotificationsDismissAll
- summary: POST /api/notifications/dismiss-all
- description: This route is registered but not documented yet.
+ operationId: cancelInstanceDatabaseExport
+ summary: Abort and discard an administrator’s private export.
+ description: Requires the `instance.manage_settings` instance permission.
tags:
- - Notifications
+ - System
security:
- sessionCookie: []
- bearerAuth: []
+ parameters:
+ - name: operationId
+ in: path
+ required: true
+ schema:
+ type: string
+ - name: X-ProPR-Storage
+ in: header
+ required: true
+ description: Must equal 1.
+ schema:
+ type: string
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/InstanceDatabaseExportRequest"
responses:
"200":
- description: Success. The response body is not documented yet.
+ description: Export discarded.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/JsonObject"
"401":
$ref: "#/components/responses/LegacyUnauthorized"
+ "403":
+ $ref: "#/components/responses/LegacyForbidden"
default:
$ref: "#/components/responses/LegacyUnexpectedError"
x-propr-auth: member
- x-undocumented: true
+ x-propr-permission: instance.manage_settings
x-legacy-error: true
- /api/notifications/preferences:
- get:
- operationId: getApiNotificationsPreferences
- summary: GET /api/notifications/preferences
- description: This route is registered but not documented yet.
+ /api/instance/storage/v1/exports/{operationId}/download:
+ post:
+ operationId: downloadInstanceDatabaseExport
+ summary: Redeem an export once using current instance-admin authority.
+ description: Requires the `instance.manage_settings` instance permission.
tags:
- - Notifications
+ - System
security:
- sessionCookie: []
- bearerAuth: []
+ parameters:
+ - name: operationId
+ in: path
+ required: true
+ schema:
+ type: string
+ - name: X-ProPR-Storage
+ in: header
+ required: true
+ description: Must equal 1.
+ schema:
+ type: string
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/InstanceDatabaseExportRequest"
responses:
"200":
- description: Success. The response body is not documented yet.
+ description: Integrity-checked SQLite snapshot; attachment, no-store.
+ content:
+ application/vnd.sqlite3:
+ schema:
+ type: string
"401":
$ref: "#/components/responses/LegacyUnauthorized"
+ "403":
+ $ref: "#/components/responses/LegacyForbidden"
default:
$ref: "#/components/responses/LegacyUnexpectedError"
x-propr-auth: member
- x-undocumented: true
+ x-propr-permission: instance.manage_settings
x-legacy-error: true
- patch:
- operationId: patchApiNotificationsPreferences
- summary: PATCH /api/notifications/preferences
- description: This route is registered but not documented yet.
+ /api/instance/storage/v1/policy:
+ get:
+ operationId: getInstanceStoragePolicy
+ summary: Get opt-in repository cleanup policy (disabled, 24 idle hours by default).
+ description: |-
+ Version 1 contract: docs/architecture/instance-storage-v1. Instance administrator required. Mutations require application/json and X-ProPR-Storage: 1. Offline/unknown usage is never zero.
+
+ Requires the `instance.manage_settings` instance permission.
tags:
- - Notifications
+ - System
security:
- sessionCookie: []
- bearerAuth: []
responses:
"200":
- description: Success. The response body is not documented yet.
+ description: Versioned instance storage response.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/InstanceStoragePolicy"
"401":
$ref: "#/components/responses/LegacyUnauthorized"
+ "403":
+ $ref: "#/components/responses/LegacyForbidden"
default:
$ref: "#/components/responses/LegacyUnexpectedError"
x-propr-auth: member
- x-undocumented: true
+ x-propr-permission: instance.manage_settings
x-legacy-error: true
- /api/notifications/push-subscriptions:
- get:
- operationId: getApiNotificationsPushSubscriptions
- summary: GET /api/notifications/push-subscriptions
- description: This route is registered but not documented yet.
+ put:
+ operationId: updateInstanceStoragePolicy
+ summary: Persist the explicitly selected instance cleanup policy.
+ description: |-
+ Version 1 contract: docs/architecture/instance-storage-v1. Instance administrator required. Mutations require application/json and X-ProPR-Storage: 1. Offline/unknown usage is never zero.
+
+ Requires the `instance.manage_settings` instance permission.
tags:
- - Notifications
+ - System
security:
- sessionCookie: []
- bearerAuth: []
+ parameters:
+ - name: X-ProPR-Storage
+ in: header
+ required: true
+ description: Must equal 1; browser calls require an authorized CORS preflight.
+ schema:
+ type: string
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/InstanceStoragePolicyRequest"
responses:
"200":
- description: Success. The response body is not documented yet.
+ description: Versioned instance storage response.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/InstanceStoragePolicy"
"401":
$ref: "#/components/responses/LegacyUnauthorized"
+ "403":
+ $ref: "#/components/responses/LegacyForbidden"
default:
$ref: "#/components/responses/LegacyUnexpectedError"
x-propr-auth: member
- x-undocumented: true
+ x-propr-permission: instance.manage_settings
x-legacy-error: true
- post:
- operationId: postApiNotificationsPushSubscriptions
- summary: POST /api/notifications/push-subscriptions
- description: This route is registered but not documented yet.
+ /api/instance/storage/v1/repositories:
+ get:
+ operationId: getInstanceRepositoryInventory
+ summary: Read a bounded partial repository storage inventory with protection reasons.
+ description: Requires the `instance.manage_settings` instance permission.
tags:
- - Notifications
+ - System
security:
- sessionCookie: []
- bearerAuth: []
responses:
"200":
- description: Success. The response body is not documented yet.
+ description: Safe identifiers and sizes; no host paths or contents.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/InstanceRepositoryInventory"
"401":
$ref: "#/components/responses/LegacyUnauthorized"
+ "403":
+ $ref: "#/components/responses/LegacyForbidden"
default:
$ref: "#/components/responses/LegacyUnexpectedError"
x-propr-auth: member
- x-undocumented: true
+ x-propr-permission: instance.manage_settings
x-legacy-error: true
- delete:
- operationId: deleteApiNotificationsPushSubscriptions
- summary: DELETE /api/notifications/push-subscriptions
- description: This route is registered but not documented yet.
+ /api/instance/storage/v1/usage:
+ get:
+ operationId: getInstanceStorageUsage
+ summary: Get bounded asynchronous per-instance storage observations.
+ description: |-
+ Version 1 contract: docs/architecture/instance-storage-v1. Instance administrator required. Mutations require application/json and X-ProPR-Storage: 1. Offline/unknown usage is never zero.
+
+ Requires the `instance.manage_settings` instance permission.
tags:
- - Notifications
+ - System
security:
- sessionCookie: []
- bearerAuth: []
responses:
"200":
- description: Success. The response body is not documented yet.
+ description: Versioned instance storage response.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/InstanceStorageUsage"
"401":
$ref: "#/components/responses/LegacyUnauthorized"
+ "403":
+ $ref: "#/components/responses/LegacyForbidden"
default:
$ref: "#/components/responses/LegacyUnexpectedError"
x-propr-auth: member
- x-undocumented: true
+ x-propr-permission: instance.manage_settings
x-legacy-error: true
- /api/notifications/push-subscriptions/{subscriptionId}:
- delete:
- operationId: deleteApiNotificationsPushSubscriptionsSubscriptionId
- summary: DELETE /api/notifications/push-subscriptions/{subscriptionId}
+ /api/internal/agent-runs/{runId}/mcp-grants:
+ post:
+ operationId: postApiInternalAgentRunsRunIdMcpGrants
+ summary: POST /api/internal/agent-runs/{runId}/mcp-grants
description: This route is registered but not documented yet.
tags:
- - Notifications
- security:
- - sessionCookie: []
- - bearerAuth: []
+ - Automations
+ security: []
parameters:
- - name: subscriptionId
+ - name: runId
in: path
required: true
schema:
@@ -5742,47 +6058,21 @@ paths:
responses:
"200":
description: Success. The response body is not documented yet.
- "401":
- $ref: "#/components/responses/LegacyUnauthorized"
default:
$ref: "#/components/responses/LegacyUnexpectedError"
- x-propr-auth: member
+ x-propr-auth: public
x-undocumented: true
x-legacy-error: true
- /api/notifications/unread-count:
- get:
- operationId: getNotificationUnreadCount
- summary: Count unread inbox notifications
- tags:
- - Notifications
- security:
- - sessionCookie: []
- - bearerAuth: []
- responses:
- "200":
- description: Unread notifications of the caller.
- content:
- application/json:
- schema:
- $ref: "#/components/schemas/NotificationUnreadCount"
- "401":
- $ref: "#/components/responses/LegacyUnauthorized"
- default:
- $ref: "#/components/responses/LegacyUnexpectedError"
- x-propr-auth: member
- x-legacy-error: true
- /api/notifications/{id}/dismiss:
+ /api/internal/agent-runs/{runId}/mcp-grants/revoke:
post:
- operationId: postApiNotificationsIdDismiss
- summary: POST /api/notifications/{id}/dismiss
+ operationId: postApiInternalAgentRunsRunIdMcpGrantsRevoke
+ summary: POST /api/internal/agent-runs/{runId}/mcp-grants/revoke
description: This route is registered but not documented yet.
tags:
- - Notifications
- security:
- - sessionCookie: []
- - bearerAuth: []
+ - Automations
+ security: []
parameters:
- - name: id
+ - name: runId
in: path
required: true
schema:
@@ -5790,86 +6080,69 @@ paths:
responses:
"200":
description: Success. The response body is not documented yet.
- "401":
- $ref: "#/components/responses/LegacyUnauthorized"
default:
$ref: "#/components/responses/LegacyUnexpectedError"
- x-propr-auth: member
+ x-propr-auth: public
x-undocumented: true
x-legacy-error: true
- /api/notifications/{id}/read:
- post:
- operationId: postApiNotificationsIdRead
- summary: POST /api/notifications/{id}/read
+ /api/internal/hosted/bootstrap:
+ get:
+ operationId: getApiInternalHostedBootstrap
+ summary: GET /api/internal/hosted/bootstrap
description: This route is registered but not documented yet.
tags:
- - Notifications
+ - System
security:
- - sessionCookie: []
- - bearerAuth: []
- parameters:
- - name: id
- in: path
- required: true
- schema:
- type: string
+ - fleetSecret: []
responses:
"200":
description: Success. The response body is not documented yet.
- "401":
- $ref: "#/components/responses/LegacyUnauthorized"
default:
$ref: "#/components/responses/LegacyUnexpectedError"
- x-propr-auth: member
+ x-propr-auth: fleetSecret
x-undocumented: true
x-legacy-error: true
- /api/planner/abort:
- post:
- operationId: postApiPlannerAbort
- summary: POST /api/planner/abort
+ /api/internal/hosted/queue:
+ get:
+ operationId: getApiInternalHostedQueue
+ summary: GET /api/internal/hosted/queue
description: This route is registered but not documented yet.
tags:
- - Planner
+ - System
security:
- - sessionCookie: []
- - bearerAuth: []
+ - fleetSecret: []
responses:
"200":
description: Success. The response body is not documented yet.
- "401":
- $ref: "#/components/responses/LegacyUnauthorized"
default:
$ref: "#/components/responses/LegacyUnexpectedError"
- x-propr-auth: member
+ x-propr-auth: fleetSecret
x-undocumented: true
x-legacy-error: true
- /api/planner/abort-refinement:
- post:
- operationId: postApiPlannerAbortRefinement
- summary: POST /api/planner/abort-refinement
+ /api/internal/hosted/status:
+ get:
+ operationId: getApiInternalHostedStatus
+ summary: GET /api/internal/hosted/status
description: This route is registered but not documented yet.
tags:
- - Planner
+ - System
security:
- - sessionCookie: []
- - bearerAuth: []
+ - fleetSecret: []
responses:
"200":
description: Success. The response body is not documented yet.
- "401":
- $ref: "#/components/responses/LegacyUnauthorized"
default:
$ref: "#/components/responses/LegacyUnexpectedError"
- x-propr-auth: member
+ x-propr-auth: fleetSecret
x-undocumented: true
x-legacy-error: true
- /api/planner/context/stats:
- post:
- operationId: postApiPlannerContextStats
- summary: POST /api/planner/context/stats
+ /api/llm-logs:
+ get:
+ operationId: getApiLlmLogs
+ summary: GET /api/llm-logs
description: This route is registered but not documented yet.
tags:
- - Planner
+ - Observability
security:
- sessionCookie: []
- bearerAuth: []
@@ -5883,13 +6156,13 @@ paths:
x-propr-auth: member
x-undocumented: true
x-legacy-error: true
- /api/planner/drafts:
+ /api/llm-metrics:
get:
- operationId: getApiPlannerDrafts
- summary: GET /api/planner/drafts
+ operationId: getApiLlmMetrics
+ summary: GET /api/llm-metrics
description: This route is registered but not documented yet.
tags:
- - Planner
+ - Observability
security:
- sessionCookie: []
- bearerAuth: []
@@ -5903,32 +6176,124 @@ paths:
x-propr-auth: member
x-undocumented: true
x-legacy-error: true
- post:
- operationId: postApiPlannerDrafts
- summary: POST /api/planner/drafts
- description: This route is registered but not documented yet.
+ /api/llm-metrics/{correlationId}:
+ get:
+ operationId: getApiLlmMetricsCorrelationId
+ summary: GET /api/llm-metrics/{correlationId}
+ description: |-
+ This route is registered but not documented yet.
+
+ When the task, session or correlation id belongs to a goal, only the goal owner can use this route (others get `404`), and only for reads: other methods answer `409` and the goal controls must be used instead.
tags:
- - Planner
+ - Observability
security:
- sessionCookie: []
- bearerAuth: []
+ parameters:
+ - name: correlationId
+ in: path
+ required: true
+ schema:
+ type: string
responses:
"200":
description: Success. The response body is not documented yet.
"401":
$ref: "#/components/responses/LegacyUnauthorized"
+ "404":
+ description: Not found, or the task belongs to another user's goal.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/LegacyError"
default:
$ref: "#/components/responses/LegacyUnexpectedError"
x-propr-auth: member
x-undocumented: true
x-legacy-error: true
- /api/planner/drafts/repositories:
+ /api/mcp:
get:
- operationId: getApiPlannerDraftsRepositories
- summary: GET /api/planner/drafts/repositories
+ operationId: getApiMcp
+ summary: GET /api/mcp
description: This route is registered but not documented yet.
tags:
- - Planner
+ - MCP
+ security:
+ - mcpOAuth:
+ - read
+ responses:
+ "200":
+ description: Success. The response body is not documented yet.
+ "401":
+ $ref: "#/components/responses/LegacyUnauthorized"
+ default:
+ $ref: "#/components/responses/LegacyUnexpectedError"
+ x-propr-auth: mcp
+ x-undocumented: true
+ x-legacy-error: true
+ post:
+ operationId: mcpRequest
+ summary: MCP Streamable HTTP endpoint
+ description: JSON-RPC requests of the Model Context Protocol. Each tool checks the token's scopes. Returns `404` while MCP is disabled. See the MCP guide (docs/features/mcp) for the tool catalogue.
+ tags:
+ - MCP
+ security:
+ - mcpOAuth:
+ - read
+ requestBody:
+ required: true
+ description: A JSON-RPC 2.0 request or notification.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/JsonObject"
+ responses:
+ "200":
+ description: JSON-RPC response.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/JsonObject"
+ "202":
+ description: Notification accepted.
+ "401":
+ $ref: "#/components/responses/LegacyUnauthorized"
+ "404":
+ description: MCP is disabled on this instance.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/LegacyError"
+ default:
+ $ref: "#/components/responses/LegacyUnexpectedError"
+ x-propr-auth: mcp
+ x-legacy-error: true
+ delete:
+ operationId: deleteApiMcp
+ summary: DELETE /api/mcp
+ description: This route is registered but not documented yet.
+ tags:
+ - MCP
+ security:
+ - mcpOAuth:
+ - read
+ responses:
+ "200":
+ description: Success. The response body is not documented yet.
+ "401":
+ $ref: "#/components/responses/LegacyUnauthorized"
+ default:
+ $ref: "#/components/responses/LegacyUnexpectedError"
+ x-propr-auth: mcp
+ x-undocumented: true
+ x-legacy-error: true
+ /api/metrics:
+ get:
+ operationId: getApiMetrics
+ summary: GET /api/metrics
+ description: This route is registered but not documented yet.
+ tags:
+ - Observability
security:
- sessionCookie: []
- bearerAuth: []
@@ -5942,22 +6307,16 @@ paths:
x-propr-auth: member
x-undocumented: true
x-legacy-error: true
- /api/planner/drafts/{id}:
+ /api/notifications:
get:
- operationId: getApiPlannerDraftsId
- summary: GET /api/planner/drafts/{id}
+ operationId: getApiNotifications
+ summary: GET /api/notifications
description: This route is registered but not documented yet.
tags:
- - Planner
+ - Notifications
security:
- sessionCookie: []
- bearerAuth: []
- parameters:
- - name: id
- in: path
- required: true
- schema:
- type: string
responses:
"200":
description: Success. The response body is not documented yet.
@@ -5968,21 +6327,16 @@ paths:
x-propr-auth: member
x-undocumented: true
x-legacy-error: true
- put:
- operationId: putApiPlannerDraftsId
- summary: PUT /api/planner/drafts/{id}
+ /api/notifications/capabilities:
+ get:
+ operationId: getApiNotificationsCapabilities
+ summary: GET /api/notifications/capabilities
description: This route is registered but not documented yet.
tags:
- - Planner
+ - Notifications
security:
- sessionCookie: []
- bearerAuth: []
- parameters:
- - name: id
- in: path
- required: true
- schema:
- type: string
responses:
"200":
description: Success. The response body is not documented yet.
@@ -5993,21 +6347,16 @@ paths:
x-propr-auth: member
x-undocumented: true
x-legacy-error: true
- delete:
- operationId: deleteApiPlannerDraftsId
- summary: DELETE /api/planner/drafts/{id}
+ /api/notifications/config:
+ get:
+ operationId: getApiNotificationsConfig
+ summary: GET /api/notifications/config
description: This route is registered but not documented yet.
tags:
- - Planner
+ - Notifications
security:
- sessionCookie: []
- bearerAuth: []
- parameters:
- - name: id
- in: path
- required: true
- schema:
- type: string
responses:
"200":
description: Success. The response body is not documented yet.
@@ -6018,22 +6367,16 @@ paths:
x-propr-auth: member
x-undocumented: true
x-legacy-error: true
- /api/planner/drafts/{id}/attachments:
+ /api/notifications/dismiss-all:
post:
- operationId: postApiPlannerDraftsIdAttachments
- summary: POST /api/planner/drafts/{id}/attachments
+ operationId: postApiNotificationsDismissAll
+ summary: POST /api/notifications/dismiss-all
description: This route is registered but not documented yet.
tags:
- - Planner
+ - Notifications
security:
- sessionCookie: []
- bearerAuth: []
- parameters:
- - name: id
- in: path
- required: true
- schema:
- type: string
responses:
"200":
description: Success. The response body is not documented yet.
@@ -6044,27 +6387,16 @@ paths:
x-propr-auth: member
x-undocumented: true
x-legacy-error: true
- /api/planner/drafts/{id}/attachments/{attachmentId}:
+ /api/notifications/preferences:
get:
- operationId: getApiPlannerDraftsIdAttachmentsAttachmentId
- summary: GET /api/planner/drafts/{id}/attachments/{attachmentId}
+ operationId: getApiNotificationsPreferences
+ summary: GET /api/notifications/preferences
description: This route is registered but not documented yet.
tags:
- - Planner
+ - Notifications
security:
- sessionCookie: []
- bearerAuth: []
- parameters:
- - name: id
- in: path
- required: true
- schema:
- type: string
- - name: attachmentId
- in: path
- required: true
- schema:
- type: string
responses:
"200":
description: Success. The response body is not documented yet.
@@ -6075,26 +6407,15 @@ paths:
x-propr-auth: member
x-undocumented: true
x-legacy-error: true
- delete:
- operationId: deleteApiPlannerDraftsIdAttachmentsAttachmentId
- summary: DELETE /api/planner/drafts/{id}/attachments/{attachmentId}
+ patch:
+ operationId: patchApiNotificationsPreferences
+ summary: PATCH /api/notifications/preferences
description: This route is registered but not documented yet.
tags:
- - Planner
+ - Notifications
security:
- sessionCookie: []
- bearerAuth: []
- parameters:
- - name: id
- in: path
- required: true
- schema:
- type: string
- - name: attachmentId
- in: path
- required: true
- schema:
- type: string
responses:
"200":
description: Success. The response body is not documented yet.
@@ -6105,22 +6426,16 @@ paths:
x-propr-auth: member
x-undocumented: true
x-legacy-error: true
- /api/planner/drafts/{id}/execution-queue:
+ /api/notifications/push-subscriptions:
get:
- operationId: getApiPlannerDraftsIdExecutionQueue
- summary: GET /api/planner/drafts/{id}/execution-queue
+ operationId: getApiNotificationsPushSubscriptions
+ summary: GET /api/notifications/push-subscriptions
description: This route is registered but not documented yet.
tags:
- - Planner
+ - Notifications
security:
- sessionCookie: []
- bearerAuth: []
- parameters:
- - name: id
- in: path
- required: true
- schema:
- type: string
responses:
"200":
description: Success. The response body is not documented yet.
@@ -6132,20 +6447,14 @@ paths:
x-undocumented: true
x-legacy-error: true
post:
- operationId: postApiPlannerDraftsIdExecutionQueue
- summary: POST /api/planner/drafts/{id}/execution-queue
+ operationId: postApiNotificationsPushSubscriptions
+ summary: POST /api/notifications/push-subscriptions
description: This route is registered but not documented yet.
tags:
- - Planner
+ - Notifications
security:
- sessionCookie: []
- bearerAuth: []
- parameters:
- - name: id
- in: path
- required: true
- schema:
- type: string
responses:
"200":
description: Success. The response body is not documented yet.
@@ -6156,190 +6465,80 @@ paths:
x-propr-auth: member
x-undocumented: true
x-legacy-error: true
- /api/planner/drafts/{id}/execution-queue/blocked-events:
- get:
- operationId: listPlanExecutionBlockedEvents
- summary: List the blocked events of a plan's epic execution
- description: Every time an issue of the execution became blocked (with its reason kind and text), and every resolution of such a blocker, oldest first. A repeated observation of the same blocked state is recorded once. Page with `cursor`; poll with the last `nextCursor` to read only new events.
+ delete:
+ operationId: deleteApiNotificationsPushSubscriptions
+ summary: DELETE /api/notifications/push-subscriptions
+ description: This route is registered but not documented yet.
tags:
- - Planner
+ - Notifications
security:
- sessionCookie: []
- bearerAuth: []
- parameters:
- - name: id
- in: path
- required: true
- description: The plan (draft) id.
- schema:
- type: string
- - name: executionId
- in: query
- required: false
- description: Defaults to the plan's current execution.
- schema:
- type: string
- - name: cursor
- in: query
- required: false
- description: "`nextCursor` of an earlier page; omit to read from the first event."
- schema:
- type: string
- - name: limit
- in: query
- required: false
- description: Page size; defaults to 50.
- schema:
- type: integer
- minimum: 1
- maximum: 200
responses:
"200":
- description: A page of events.
- content:
- application/json:
- schema:
- $ref: "#/components/schemas/EpicQueueBlockedEventPage"
- "400":
- description: The cursor or limit is invalid.
- content:
- application/json:
- schema:
- $ref: "#/components/schemas/LegacyError"
+ description: Success. The response body is not documented yet.
"401":
$ref: "#/components/responses/LegacyUnauthorized"
default:
$ref: "#/components/responses/LegacyUnexpectedError"
- x-propr-query-type: EpicQueueBlockedEventsQuery
x-propr-auth: member
+ x-undocumented: true
x-legacy-error: true
- /api/planner/drafts/{id}/execution-queue/issues/{issueNumber}/retry:
- post:
- operationId: retryPlanExecutionIssue
- summary: Retry a blocked issue of an epic execution
- description: "Clears the blocker and dispatches the issue again under the queue's normal guards (a paused plan holds it; a graph starts it once ready within its limit). An issue closed without merge is reopened first; one whose run reported a blocker is put back to pending. A wait for a human merge or a label handoff keeps its claim and is re-evaluated. Works for issues started through MCP `implement_plan`. Idempotent: repeating it answers `already_retried`."
+ /api/notifications/push-subscriptions/{subscriptionId}:
+ delete:
+ operationId: deleteApiNotificationsPushSubscriptionsSubscriptionId
+ summary: DELETE /api/notifications/push-subscriptions/{subscriptionId}
+ description: This route is registered but not documented yet.
tags:
- - Planner
+ - Notifications
security:
- sessionCookie: []
- bearerAuth: []
parameters:
- - name: id
- in: path
- required: true
- description: The plan (draft) id.
- schema:
- type: string
- - name: issueNumber
+ - name: subscriptionId
in: path
required: true
- description: GitHub issue number of an issue in the execution.
schema:
type: string
- requestBody:
- required: true
- content:
- application/json:
- schema:
- $ref: "#/components/schemas/EpicQueueRetryRequest"
responses:
"200":
- description: The blocker is cleared and the issue is dispatched under the normal guards.
- content:
- application/json:
- schema:
- $ref: "#/components/schemas/EpicQueueActionResult"
- "400":
- description: The body or issue number is invalid.
- content:
- application/json:
- schema:
- $ref: "#/components/schemas/LegacyError"
+ description: Success. The response body is not documented yet.
"401":
$ref: "#/components/responses/LegacyUnauthorized"
- "404":
- description: The issue is not part of the execution.
- content:
- application/json:
- schema:
- $ref: "#/components/schemas/EpicQueueActionError"
- "409":
- description: "Refused: a stale execution (`STALE_EXECUTION`), an issue in flight (`IN_FLIGHT`) or already done (`ALREADY_DONE`), a changed state (`STATE_CHANGED`), or, for retry, an issue that is not blocked (`NOT_BLOCKED`) or waits for setup (`NOT_RETRYABLE`)."
- content:
- application/json:
- schema:
- $ref: "#/components/schemas/EpicQueueActionError"
default:
$ref: "#/components/responses/LegacyUnexpectedError"
x-propr-auth: member
+ x-undocumented: true
x-legacy-error: true
- /api/planner/drafts/{id}/execution-queue/issues/{issueNumber}/skip:
- post:
- operationId: skipPlanExecutionIssue
- summary: Skip a pending or blocked issue of an epic execution
- description: "The queue treats the issue as finished for scheduling: a sequential execution moves on to the next issue; in a graph execution its dependents wait on the skipped prerequisite unless `satisfiesDependents` is true. The GitHub issue is never closed or edited. Idempotent: skipping a skipped issue answers `already_skipped`."
+ /api/notifications/unread-count:
+ get:
+ operationId: getNotificationUnreadCount
+ summary: Count unread inbox notifications
tags:
- - Planner
+ - Notifications
security:
- sessionCookie: []
- bearerAuth: []
- parameters:
- - name: id
- in: path
- required: true
- description: The plan (draft) id.
- schema:
- type: string
- - name: issueNumber
- in: path
- required: true
- description: GitHub issue number of an issue in the execution.
- schema:
- type: string
- requestBody:
- required: true
- content:
- application/json:
- schema:
- $ref: "#/components/schemas/EpicQueueSkipRequest"
responses:
"200":
- description: The issue is skipped.
- content:
- application/json:
- schema:
- $ref: "#/components/schemas/EpicQueueActionResult"
- "400":
- description: The body or issue number is invalid.
+ description: Unread notifications of the caller.
content:
application/json:
schema:
- $ref: "#/components/schemas/LegacyError"
+ $ref: "#/components/schemas/NotificationUnreadCount"
"401":
$ref: "#/components/responses/LegacyUnauthorized"
- "404":
- description: The issue is not part of the execution.
- content:
- application/json:
- schema:
- $ref: "#/components/schemas/EpicQueueActionError"
- "409":
- description: "Refused: a stale execution (`STALE_EXECUTION`), an issue in flight (`IN_FLIGHT`) or already done (`ALREADY_DONE`), a changed state (`STATE_CHANGED`), or, for retry, an issue that is not blocked (`NOT_BLOCKED`) or waits for setup (`NOT_RETRYABLE`)."
- content:
- application/json:
- schema:
- $ref: "#/components/schemas/EpicQueueActionError"
default:
$ref: "#/components/responses/LegacyUnexpectedError"
x-propr-auth: member
x-legacy-error: true
- /api/planner/drafts/{id}/execution-settings:
- patch:
- operationId: patchApiPlannerDraftsIdExecutionSettings
- summary: PATCH /api/planner/drafts/{id}/execution-settings
+ /api/notifications/{id}/dismiss:
+ post:
+ operationId: postApiNotificationsIdDismiss
+ summary: POST /api/notifications/{id}/dismiss
description: This route is registered but not documented yet.
tags:
- - Planner
+ - Notifications
security:
- sessionCookie: []
- bearerAuth: []
@@ -6359,13 +6558,13 @@ paths:
x-propr-auth: member
x-undocumented: true
x-legacy-error: true
- /api/planner/drafts/{id}/issues:
- get:
- operationId: getApiPlannerDraftsIdIssues
- summary: GET /api/planner/drafts/{id}/issues
+ /api/notifications/{id}/read:
+ post:
+ operationId: postApiNotificationsIdRead
+ summary: POST /api/notifications/{id}/read
description: This route is registered but not documented yet.
tags:
- - Planner
+ - Notifications
security:
- sessionCookie: []
- bearerAuth: []
@@ -6385,27 +6584,16 @@ paths:
x-propr-auth: member
x-undocumented: true
x-legacy-error: true
- /api/planner/drafts/{id}/issues/{issueNumber}:
- patch:
- operationId: patchApiPlannerDraftsIdIssuesIssueNumber
- summary: PATCH /api/planner/drafts/{id}/issues/{issueNumber}
+ /api/planner/abort:
+ post:
+ operationId: postApiPlannerAbort
+ summary: POST /api/planner/abort
description: This route is registered but not documented yet.
tags:
- Planner
security:
- sessionCookie: []
- bearerAuth: []
- parameters:
- - name: id
- in: path
- required: true
- schema:
- type: string
- - name: issueNumber
- in: path
- required: true
- schema:
- type: string
responses:
"200":
description: Success. The response body is not documented yet.
@@ -6416,27 +6604,16 @@ paths:
x-propr-auth: member
x-undocumented: true
x-legacy-error: true
- /api/planner/drafts/{id}/issues/{issueNumber}/implement:
+ /api/planner/abort-refinement:
post:
- operationId: postApiPlannerDraftsIdIssuesIssueNumberImplement
- summary: POST /api/planner/drafts/{id}/issues/{issueNumber}/implement
+ operationId: postApiPlannerAbortRefinement
+ summary: POST /api/planner/abort-refinement
description: This route is registered but not documented yet.
tags:
- Planner
security:
- sessionCookie: []
- bearerAuth: []
- parameters:
- - name: id
- in: path
- required: true
- schema:
- type: string
- - name: issueNumber
- in: path
- required: true
- schema:
- type: string
responses:
"200":
description: Success. The response body is not documented yet.
@@ -6447,22 +6624,16 @@ paths:
x-propr-auth: member
x-undocumented: true
x-legacy-error: true
- /api/planner/drafts/{id}/pause:
+ /api/planner/context/stats:
post:
- operationId: postApiPlannerDraftsIdPause
- summary: POST /api/planner/drafts/{id}/pause
+ operationId: postApiPlannerContextStats
+ summary: POST /api/planner/context/stats
description: This route is registered but not documented yet.
tags:
- Planner
security:
- sessionCookie: []
- bearerAuth: []
- parameters:
- - name: id
- in: path
- required: true
- schema:
- type: string
responses:
"200":
description: Success. The response body is not documented yet.
@@ -6473,22 +6644,16 @@ paths:
x-propr-auth: member
x-undocumented: true
x-legacy-error: true
- /api/planner/drafts/{id}/repository-info:
+ /api/planner/drafts:
get:
- operationId: getApiPlannerDraftsIdRepositoryInfo
- summary: GET /api/planner/drafts/{id}/repository-info
+ operationId: getApiPlannerDrafts
+ summary: GET /api/planner/drafts
description: This route is registered but not documented yet.
tags:
- Planner
security:
- sessionCookie: []
- bearerAuth: []
- parameters:
- - name: id
- in: path
- required: true
- schema:
- type: string
responses:
"200":
description: Success. The response body is not documented yet.
@@ -6499,22 +6664,15 @@ paths:
x-propr-auth: member
x-undocumented: true
x-legacy-error: true
- /api/planner/drafts/{id}/reset-to-setup:
post:
- operationId: postApiPlannerDraftsIdResetToSetup
- summary: POST /api/planner/drafts/{id}/reset-to-setup
+ operationId: postApiPlannerDrafts
+ summary: POST /api/planner/drafts
description: This route is registered but not documented yet.
tags:
- Planner
security:
- sessionCookie: []
- bearerAuth: []
- parameters:
- - name: id
- in: path
- required: true
- schema:
- type: string
responses:
"200":
description: Success. The response body is not documented yet.
@@ -6525,22 +6683,16 @@ paths:
x-propr-auth: member
x-undocumented: true
x-legacy-error: true
- /api/planner/drafts/{id}/resume:
- post:
- operationId: postApiPlannerDraftsIdResume
- summary: POST /api/planner/drafts/{id}/resume
+ /api/planner/drafts/repositories:
+ get:
+ operationId: getApiPlannerDraftsRepositories
+ summary: GET /api/planner/drafts/repositories
description: This route is registered but not documented yet.
tags:
- Planner
security:
- sessionCookie: []
- bearerAuth: []
- parameters:
- - name: id
- in: path
- required: true
- schema:
- type: string
responses:
"200":
description: Success. The response body is not documented yet.
@@ -6551,10 +6703,10 @@ paths:
x-propr-auth: member
x-undocumented: true
x-legacy-error: true
- /api/planner/drafts/{id}/revise:
- post:
- operationId: postApiPlannerDraftsIdRevise
- summary: POST /api/planner/drafts/{id}/revise
+ /api/planner/drafts/{id}:
+ get:
+ operationId: getApiPlannerDraftsId
+ summary: GET /api/planner/drafts/{id}
description: This route is registered but not documented yet.
tags:
- Planner
@@ -6577,10 +6729,9 @@ paths:
x-propr-auth: member
x-undocumented: true
x-legacy-error: true
- /api/planner/drafts/{id}/revisions:
- get:
- operationId: getApiPlannerDraftsIdRevisions
- summary: GET /api/planner/drafts/{id}/revisions
+ put:
+ operationId: putApiPlannerDraftsId
+ summary: PUT /api/planner/drafts/{id}
description: This route is registered but not documented yet.
tags:
- Planner
@@ -6603,10 +6754,9 @@ paths:
x-propr-auth: member
x-undocumented: true
x-legacy-error: true
- /api/planner/drafts/{id}/revisions/{revisionId}:
- get:
- operationId: getApiPlannerDraftsIdRevisionsRevisionId
- summary: GET /api/planner/drafts/{id}/revisions/{revisionId}
+ delete:
+ operationId: deleteApiPlannerDraftsId
+ summary: DELETE /api/planner/drafts/{id}
description: This route is registered but not documented yet.
tags:
- Planner
@@ -6619,11 +6769,6 @@ paths:
required: true
schema:
type: string
- - name: revisionId
- in: path
- required: true
- schema:
- type: string
responses:
"200":
description: Success. The response body is not documented yet.
@@ -6634,10 +6779,10 @@ paths:
x-propr-auth: member
x-undocumented: true
x-legacy-error: true
- /api/planner/drafts/{id}/revisions/{revisionId}/restore:
+ /api/planner/drafts/{id}/attachments:
post:
- operationId: postApiPlannerDraftsIdRevisionsRevisionIdRestore
- summary: POST /api/planner/drafts/{id}/revisions/{revisionId}/restore
+ operationId: postApiPlannerDraftsIdAttachments
+ summary: POST /api/planner/drafts/{id}/attachments
description: This route is registered but not documented yet.
tags:
- Planner
@@ -6650,11 +6795,6 @@ paths:
required: true
schema:
type: string
- - name: revisionId
- in: path
- required: true
- schema:
- type: string
responses:
"200":
description: Success. The response body is not documented yet.
@@ -6665,16 +6805,27 @@ paths:
x-propr-auth: member
x-undocumented: true
x-legacy-error: true
- /api/planner/finalize:
- post:
- operationId: postApiPlannerFinalize
- summary: POST /api/planner/finalize
+ /api/planner/drafts/{id}/attachments/{attachmentId}:
+ get:
+ operationId: getApiPlannerDraftsIdAttachmentsAttachmentId
+ summary: GET /api/planner/drafts/{id}/attachments/{attachmentId}
description: This route is registered but not documented yet.
tags:
- Planner
security:
- sessionCookie: []
- bearerAuth: []
+ parameters:
+ - name: id
+ in: path
+ required: true
+ schema:
+ type: string
+ - name: attachmentId
+ in: path
+ required: true
+ schema:
+ type: string
responses:
"200":
description: Success. The response body is not documented yet.
@@ -6685,16 +6836,26 @@ paths:
x-propr-auth: member
x-undocumented: true
x-legacy-error: true
- /api/planner/generate:
- post:
- operationId: postApiPlannerGenerate
- summary: POST /api/planner/generate
+ delete:
+ operationId: deleteApiPlannerDraftsIdAttachmentsAttachmentId
+ summary: DELETE /api/planner/drafts/{id}/attachments/{attachmentId}
description: This route is registered but not documented yet.
tags:
- Planner
security:
- sessionCookie: []
- bearerAuth: []
+ parameters:
+ - name: id
+ in: path
+ required: true
+ schema:
+ type: string
+ - name: attachmentId
+ in: path
+ required: true
+ schema:
+ type: string
responses:
"200":
description: Success. The response body is not documented yet.
@@ -6705,16 +6866,22 @@ paths:
x-propr-auth: member
x-undocumented: true
x-legacy-error: true
- /api/planner/preview:
- post:
- operationId: postApiPlannerPreview
- summary: POST /api/planner/preview
+ /api/planner/drafts/{id}/execution-queue:
+ get:
+ operationId: getApiPlannerDraftsIdExecutionQueue
+ summary: GET /api/planner/drafts/{id}/execution-queue
description: This route is registered but not documented yet.
tags:
- Planner
security:
- sessionCookie: []
- bearerAuth: []
+ parameters:
+ - name: id
+ in: path
+ required: true
+ schema:
+ type: string
responses:
"200":
description: Success. The response body is not documented yet.
@@ -6725,16 +6892,21 @@ paths:
x-propr-auth: member
x-undocumented: true
x-legacy-error: true
- /api/planner/preview/context:
post:
- operationId: postApiPlannerPreviewContext
- summary: POST /api/planner/preview/context
+ operationId: postApiPlannerDraftsIdExecutionQueue
+ summary: POST /api/planner/drafts/{id}/execution-queue
description: This route is registered but not documented yet.
tags:
- Planner
security:
- sessionCookie: []
- bearerAuth: []
+ parameters:
+ - name: id
+ in: path
+ required: true
+ schema:
+ type: string
responses:
"200":
description: Success. The response body is not documented yet.
@@ -6745,134 +6917,195 @@ paths:
x-propr-auth: member
x-undocumented: true
x-legacy-error: true
- /api/planner/refine:
- post:
- operationId: postApiPlannerRefine
- summary: POST /api/planner/refine
- description: This route is registered but not documented yet.
+ /api/planner/drafts/{id}/execution-queue/blocked-events:
+ get:
+ operationId: listPlanExecutionBlockedEvents
+ summary: List the blocked events of a plan's epic execution
+ description: Every time an issue of the execution became blocked (with its reason kind and text), and every resolution of such a blocker, oldest first. A repeated observation of the same blocked state is recorded once. Page with `cursor`; poll with the last `nextCursor` to read only new events.
tags:
- Planner
security:
- sessionCookie: []
- bearerAuth: []
+ parameters:
+ - name: id
+ in: path
+ required: true
+ description: The plan (draft) id.
+ schema:
+ type: string
+ - name: executionId
+ in: query
+ required: false
+ description: Defaults to the plan's current execution.
+ schema:
+ type: string
+ - name: cursor
+ in: query
+ required: false
+ description: "`nextCursor` of an earlier page; omit to read from the first event."
+ schema:
+ type: string
+ - name: limit
+ in: query
+ required: false
+ description: Page size; defaults to 50.
+ schema:
+ type: integer
+ minimum: 1
+ maximum: 200
responses:
"200":
- description: Success. The response body is not documented yet.
+ description: A page of events.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/EpicQueueBlockedEventPage"
+ "400":
+ description: The cursor or limit is invalid.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/LegacyError"
"401":
$ref: "#/components/responses/LegacyUnauthorized"
default:
$ref: "#/components/responses/LegacyUnexpectedError"
+ x-propr-query-type: EpicQueueBlockedEventsQuery
x-propr-auth: member
- x-undocumented: true
x-legacy-error: true
- /api/planner/relevance:
+ /api/planner/drafts/{id}/execution-queue/issues/{issueNumber}/retry:
post:
- operationId: postApiPlannerRelevance
- summary: POST /api/planner/relevance
- description: This route is registered but not documented yet.
+ operationId: retryPlanExecutionIssue
+ summary: Retry a blocked issue of an epic execution
+ description: "Clears the blocker and dispatches the issue again under the queue's normal guards (a paused plan holds it; a graph starts it once ready within its limit). An issue closed without merge is reopened first; one whose run reported a blocker is put back to pending. A wait for a human merge or a label handoff keeps its claim and is re-evaluated. Works for issues started through MCP `implement_plan`. Idempotent: repeating it answers `already_retried`."
tags:
- Planner
security:
- sessionCookie: []
- bearerAuth: []
+ parameters:
+ - name: id
+ in: path
+ required: true
+ description: The plan (draft) id.
+ schema:
+ type: string
+ - name: issueNumber
+ in: path
+ required: true
+ description: GitHub issue number of an issue in the execution.
+ schema:
+ type: string
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/EpicQueueRetryRequest"
responses:
"200":
- description: Success. The response body is not documented yet.
+ description: The blocker is cleared and the issue is dispatched under the normal guards.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/EpicQueueActionResult"
+ "400":
+ description: The body or issue number is invalid.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/LegacyError"
"401":
$ref: "#/components/responses/LegacyUnauthorized"
+ "404":
+ description: The issue is not part of the execution.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/EpicQueueActionError"
+ "409":
+ description: "Refused: a stale execution (`STALE_EXECUTION`), an issue in flight (`IN_FLIGHT`) or already done (`ALREADY_DONE`), a changed state (`STATE_CHANGED`), or, for retry, an issue that is not blocked (`NOT_BLOCKED`) or waits for setup (`NOT_RETRYABLE`)."
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/EpicQueueActionError"
default:
$ref: "#/components/responses/LegacyUnexpectedError"
x-propr-auth: member
- x-undocumented: true
x-legacy-error: true
- /api/planner/validate-context-repository:
+ /api/planner/drafts/{id}/execution-queue/issues/{issueNumber}/skip:
post:
- operationId: postApiPlannerValidateContextRepository
- summary: POST /api/planner/validate-context-repository
- description: This route is registered but not documented yet.
+ operationId: skipPlanExecutionIssue
+ summary: Skip a pending or blocked issue of an epic execution
+ description: "The queue treats the issue as finished for scheduling: a sequential execution moves on to the next issue; in a graph execution its dependents wait on the skipped prerequisite unless `satisfiesDependents` is true. The GitHub issue is never closed or edited. Idempotent: skipping a skipped issue answers `already_skipped`."
tags:
- Planner
security:
- sessionCookie: []
- bearerAuth: []
- responses:
- "200":
- description: Success. The response body is not documented yet.
- "401":
- $ref: "#/components/responses/LegacyUnauthorized"
- default:
- $ref: "#/components/responses/LegacyUnexpectedError"
- x-propr-auth: member
- x-undocumented: true
- x-legacy-error: true
- /api/preview-media/comments/{owner}/{repo}/{number}/{assetId}:
- get:
- operationId: getApiPreviewMediaCommentsOwnerRepoNumberAssetId
- summary: GET /api/preview-media/comments/{owner}/{repo}/{number}/{assetId}
- description: This route is registered but not documented yet.
- tags:
- - Repositories
- security:
- - sessionCookie: []
- - bearerAuth: []
parameters:
- - name: owner
- in: path
- required: true
- schema:
- type: string
- - name: repo
- in: path
- required: true
- schema:
- type: string
- - name: number
+ - name: id
in: path
required: true
+ description: The plan (draft) id.
schema:
type: string
- - name: assetId
+ - name: issueNumber
in: path
required: true
+ description: GitHub issue number of an issue in the execution.
schema:
type: string
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/EpicQueueSkipRequest"
responses:
"200":
- description: Success. The response body is not documented yet.
- "401":
- $ref: "#/components/responses/LegacyUnauthorized"
- default:
- $ref: "#/components/responses/LegacyUnexpectedError"
- x-propr-auth: member
- x-undocumented: true
+ description: The issue is skipped.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/EpicQueueActionResult"
+ "400":
+ description: The body or issue number is invalid.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/LegacyError"
+ "401":
+ $ref: "#/components/responses/LegacyUnauthorized"
+ "404":
+ description: The issue is not part of the execution.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/EpicQueueActionError"
+ "409":
+ description: "Refused: a stale execution (`STALE_EXECUTION`), an issue in flight (`IN_FLIGHT`) or already done (`ALREADY_DONE`), a changed state (`STATE_CHANGED`), or, for retry, an issue that is not blocked (`NOT_BLOCKED`) or waits for setup (`NOT_RETRYABLE`)."
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/EpicQueueActionError"
+ default:
+ $ref: "#/components/responses/LegacyUnexpectedError"
+ x-propr-auth: member
x-legacy-error: true
- /api/preview-media/pulls/{owner}/{repo}/{number}/{assetId}:
- get:
- operationId: getApiPreviewMediaPullsOwnerRepoNumberAssetId
- summary: GET /api/preview-media/pulls/{owner}/{repo}/{number}/{assetId}
+ /api/planner/drafts/{id}/execution-settings:
+ patch:
+ operationId: patchApiPlannerDraftsIdExecutionSettings
+ summary: PATCH /api/planner/drafts/{id}/execution-settings
description: This route is registered but not documented yet.
tags:
- - Repositories
+ - Planner
security:
- sessionCookie: []
- bearerAuth: []
parameters:
- - name: owner
- in: path
- required: true
- schema:
- type: string
- - name: repo
- in: path
- required: true
- schema:
- type: string
- - name: number
- in: path
- required: true
- schema:
- type: string
- - name: assetId
+ - name: id
in: path
required: true
schema:
@@ -6887,72 +7120,49 @@ paths:
x-propr-auth: member
x-undocumented: true
x-legacy-error: true
- /api/pull-requests/{number}/readiness:
+ /api/planner/drafts/{id}/issues:
get:
- operationId: getPullRequestReadiness
- summary: Get the readiness verdict of a pull request
- description: "Read-only: one verdict (`ready`, `waiting`, `blocked`, `merged`, `closed`, `unknown`) with a coded reason per gate, evaluated with the ProPR GitHub App identity from the same signals auto-merge uses (check runs, legacy commit statuses, non-blocking checks, mergeability, reviews, the latest ProPR AI review, Ultrafix state, active ProPR work and, when auto-merge is requested, the auto-merge policy). It never writes to GitHub or Redis, never updates the branch, never merges and never arms or withdraws auto-merge. The caller needs read access to the repository on GitHub (the check MCP `get_pull_request_readiness` applies); the App identity is used only after it is proven. See the gate and reason-code tables in the API reference."
+ operationId: getApiPlannerDraftsIdIssues
+ summary: GET /api/planner/drafts/{id}/issues
+ description: This route is registered but not documented yet.
tags:
- - Tasks
+ - Planner
security:
- sessionCookie: []
- bearerAuth: []
parameters:
- - name: number
+ - name: id
in: path
required: true
- description: Pull request number.
- schema:
- type: string
- - name: repository
- in: query
- required: true
- description: "`owner/repo` of the pull request."
schema:
type: string
responses:
"200":
- description: The readiness verdict.
- content:
- application/json:
- schema:
- $ref: "#/components/schemas/PullRequestReadiness"
- "400":
- description: "`INVALID_INPUT`: the number or repository is malformed."
- content:
- application/json:
- schema:
- $ref: "#/components/schemas/ErrorEnvelope"
+ description: Success. The response body is not documented yet.
"401":
- $ref: "#/components/responses/EnvelopeUnauthorized"
- "404":
- description: "`REPOSITORY_NOT_ACCESSIBLE`: the caller cannot read the repository on GitHub, so nothing is evaluated; or `PULL_REQUEST_NOT_FOUND`: the pull request does not exist or the GitHub App cannot see it."
- content:
- application/json:
- schema:
- $ref: "#/components/schemas/ErrorEnvelope"
- "502":
- description: "`GITHUB_UNAVAILABLE`: GitHub did not return the pull request, or could not be reached to check repository access; the read is safe to retry."
- content:
- application/json:
- schema:
- $ref: "#/components/schemas/ErrorEnvelope"
+ $ref: "#/components/responses/LegacyUnauthorized"
default:
- $ref: "#/components/responses/EnvelopeUnexpectedError"
- x-propr-query-type: PullRequestReadinessQuery
+ $ref: "#/components/responses/LegacyUnexpectedError"
x-propr-auth: member
- /api/pull-requests/{number}/scores:
- get:
- operationId: getApiPullRequestsNumberScores
- summary: GET /api/pull-requests/{number}/scores
+ x-undocumented: true
+ x-legacy-error: true
+ /api/planner/drafts/{id}/issues/{issueNumber}:
+ patch:
+ operationId: patchApiPlannerDraftsIdIssuesIssueNumber
+ summary: PATCH /api/planner/drafts/{id}/issues/{issueNumber}
description: This route is registered but not documented yet.
tags:
- - Tasks
+ - Planner
security:
- sessionCookie: []
- bearerAuth: []
parameters:
- - name: number
+ - name: id
+ in: path
+ required: true
+ schema:
+ type: string
+ - name: issueNumber
in: path
required: true
schema:
@@ -6967,16 +7177,27 @@ paths:
x-propr-auth: member
x-undocumented: true
x-legacy-error: true
- /api/queue/stats:
- get:
- operationId: getApiQueueStats
- summary: GET /api/queue/stats
+ /api/planner/drafts/{id}/issues/{issueNumber}/implement:
+ post:
+ operationId: postApiPlannerDraftsIdIssuesIssueNumberImplement
+ summary: POST /api/planner/drafts/{id}/issues/{issueNumber}/implement
description: This route is registered but not documented yet.
tags:
- - Observability
+ - Planner
security:
- sessionCookie: []
- bearerAuth: []
+ parameters:
+ - name: id
+ in: path
+ required: true
+ schema:
+ type: string
+ - name: issueNumber
+ in: path
+ required: true
+ schema:
+ type: string
responses:
"200":
description: Success. The response body is not documented yet.
@@ -6987,16 +7208,22 @@ paths:
x-propr-auth: member
x-undocumented: true
x-legacy-error: true
- /api/repos/chat:
+ /api/planner/drafts/{id}/pause:
post:
- operationId: postApiReposChat
- summary: POST /api/repos/chat
+ operationId: postApiPlannerDraftsIdPause
+ summary: POST /api/planner/drafts/{id}/pause
description: This route is registered but not documented yet.
tags:
- - Repositories
+ - Planner
security:
- sessionCookie: []
- bearerAuth: []
+ parameters:
+ - name: id
+ in: path
+ required: true
+ schema:
+ type: string
responses:
"200":
description: Success. The response body is not documented yet.
@@ -7007,16 +7234,22 @@ paths:
x-propr-auth: member
x-undocumented: true
x-legacy-error: true
- /api/repos/chat/messages:
+ /api/planner/drafts/{id}/repository-info:
get:
- operationId: getApiReposChatMessages
- summary: GET /api/repos/chat/messages
+ operationId: getApiPlannerDraftsIdRepositoryInfo
+ summary: GET /api/planner/drafts/{id}/repository-info
description: This route is registered but not documented yet.
tags:
- - Repositories
+ - Planner
security:
- sessionCookie: []
- bearerAuth: []
+ parameters:
+ - name: id
+ in: path
+ required: true
+ schema:
+ type: string
responses:
"200":
description: Success. The response body is not documented yet.
@@ -7027,15 +7260,22 @@ paths:
x-propr-auth: member
x-undocumented: true
x-legacy-error: true
+ /api/planner/drafts/{id}/reset-to-setup:
post:
- operationId: postApiReposChatMessages
- summary: POST /api/repos/chat/messages
+ operationId: postApiPlannerDraftsIdResetToSetup
+ summary: POST /api/planner/drafts/{id}/reset-to-setup
description: This route is registered but not documented yet.
tags:
- - Repositories
+ - Planner
security:
- sessionCookie: []
- bearerAuth: []
+ parameters:
+ - name: id
+ in: path
+ required: true
+ schema:
+ type: string
responses:
"200":
description: Success. The response body is not documented yet.
@@ -7046,15 +7286,22 @@ paths:
x-propr-auth: member
x-undocumented: true
x-legacy-error: true
- delete:
- operationId: deleteApiReposChatMessages
- summary: DELETE /api/repos/chat/messages
+ /api/planner/drafts/{id}/resume:
+ post:
+ operationId: postApiPlannerDraftsIdResume
+ summary: POST /api/planner/drafts/{id}/resume
description: This route is registered but not documented yet.
tags:
- - Repositories
+ - Planner
security:
- sessionCookie: []
- bearerAuth: []
+ parameters:
+ - name: id
+ in: path
+ required: true
+ schema:
+ type: string
responses:
"200":
description: Success. The response body is not documented yet.
@@ -7065,18 +7312,18 @@ paths:
x-propr-auth: member
x-undocumented: true
x-legacy-error: true
- /api/repos/chat/messages/{messageId}:
- delete:
- operationId: deleteApiReposChatMessagesMessageId
- summary: DELETE /api/repos/chat/messages/{messageId}
+ /api/planner/drafts/{id}/revise:
+ post:
+ operationId: postApiPlannerDraftsIdRevise
+ summary: POST /api/planner/drafts/{id}/revise
description: This route is registered but not documented yet.
tags:
- - Repositories
+ - Planner
security:
- sessionCookie: []
- bearerAuth: []
parameters:
- - name: messageId
+ - name: id
in: path
required: true
schema:
@@ -7091,16 +7338,22 @@ paths:
x-propr-auth: member
x-undocumented: true
x-legacy-error: true
- /api/repos/improvements:
- post:
- operationId: postApiReposImprovements
- summary: POST /api/repos/improvements
+ /api/planner/drafts/{id}/revisions:
+ get:
+ operationId: getApiPlannerDraftsIdRevisions
+ summary: GET /api/planner/drafts/{id}/revisions
description: This route is registered but not documented yet.
tags:
- - Repositories
+ - Planner
security:
- sessionCookie: []
- bearerAuth: []
+ parameters:
+ - name: id
+ in: path
+ required: true
+ schema:
+ type: string
responses:
"200":
description: Success. The response body is not documented yet.
@@ -7111,16 +7364,27 @@ paths:
x-propr-auth: member
x-undocumented: true
x-legacy-error: true
- /api/repos/media:
+ /api/planner/drafts/{id}/revisions/{revisionId}:
get:
- operationId: getApiReposMedia
- summary: GET /api/repos/media
+ operationId: getApiPlannerDraftsIdRevisionsRevisionId
+ summary: GET /api/planner/drafts/{id}/revisions/{revisionId}
description: This route is registered but not documented yet.
tags:
- - Repositories
+ - Planner
security:
- sessionCookie: []
- bearerAuth: []
+ parameters:
+ - name: id
+ in: path
+ required: true
+ schema:
+ type: string
+ - name: revisionId
+ in: path
+ required: true
+ schema:
+ type: string
responses:
"200":
description: Success. The response body is not documented yet.
@@ -7131,16 +7395,27 @@ paths:
x-propr-auth: member
x-undocumented: true
x-legacy-error: true
- /api/repos/todos:
- get:
- operationId: getApiReposTodos
- summary: GET /api/repos/todos
+ /api/planner/drafts/{id}/revisions/{revisionId}/restore:
+ post:
+ operationId: postApiPlannerDraftsIdRevisionsRevisionIdRestore
+ summary: POST /api/planner/drafts/{id}/revisions/{revisionId}/restore
description: This route is registered but not documented yet.
tags:
- - Repositories
+ - Planner
security:
- sessionCookie: []
- bearerAuth: []
+ parameters:
+ - name: id
+ in: path
+ required: true
+ schema:
+ type: string
+ - name: revisionId
+ in: path
+ required: true
+ schema:
+ type: string
responses:
"200":
description: Success. The response body is not documented yet.
@@ -7151,12 +7426,13 @@ paths:
x-propr-auth: member
x-undocumented: true
x-legacy-error: true
+ /api/planner/finalize:
post:
- operationId: postApiReposTodos
- summary: POST /api/repos/todos
+ operationId: postApiPlannerFinalize
+ summary: POST /api/planner/finalize
description: This route is registered but not documented yet.
tags:
- - Repositories
+ - Planner
security:
- sessionCookie: []
- bearerAuth: []
@@ -7170,13 +7446,13 @@ paths:
x-propr-auth: member
x-undocumented: true
x-legacy-error: true
- /api/repos/todos/categories:
- get:
- operationId: getApiReposTodosCategories
- summary: GET /api/repos/todos/categories
+ /api/planner/generate:
+ post:
+ operationId: postApiPlannerGenerate
+ summary: POST /api/planner/generate
description: This route is registered but not documented yet.
tags:
- - Repositories
+ - Planner
security:
- sessionCookie: []
- bearerAuth: []
@@ -7190,12 +7466,13 @@ paths:
x-propr-auth: member
x-undocumented: true
x-legacy-error: true
+ /api/planner/preview:
post:
- operationId: postApiReposTodosCategories
- summary: POST /api/repos/todos/categories
+ operationId: postApiPlannerPreview
+ summary: POST /api/planner/preview
description: This route is registered but not documented yet.
tags:
- - Repositories
+ - Planner
security:
- sessionCookie: []
- bearerAuth: []
@@ -7209,13 +7486,13 @@ paths:
x-propr-auth: member
x-undocumented: true
x-legacy-error: true
- /api/repos/todos/categories/reorder:
+ /api/planner/preview/context:
post:
- operationId: postApiReposTodosCategoriesReorder
- summary: POST /api/repos/todos/categories/reorder
+ operationId: postApiPlannerPreviewContext
+ summary: POST /api/planner/preview/context
description: This route is registered but not documented yet.
tags:
- - Repositories
+ - Planner
security:
- sessionCookie: []
- bearerAuth: []
@@ -7229,22 +7506,16 @@ paths:
x-propr-auth: member
x-undocumented: true
x-legacy-error: true
- /api/repos/todos/categories/{categoryId}:
- put:
- operationId: putApiReposTodosCategoriesCategoryId
- summary: PUT /api/repos/todos/categories/{categoryId}
+ /api/planner/refine:
+ post:
+ operationId: postApiPlannerRefine
+ summary: POST /api/planner/refine
description: This route is registered but not documented yet.
tags:
- - Repositories
+ - Planner
security:
- sessionCookie: []
- bearerAuth: []
- parameters:
- - name: categoryId
- in: path
- required: true
- schema:
- type: string
responses:
"200":
description: Success. The response body is not documented yet.
@@ -7255,21 +7526,16 @@ paths:
x-propr-auth: member
x-undocumented: true
x-legacy-error: true
- delete:
- operationId: deleteApiReposTodosCategoriesCategoryId
- summary: DELETE /api/repos/todos/categories/{categoryId}
+ /api/planner/relevance:
+ post:
+ operationId: postApiPlannerRelevance
+ summary: POST /api/planner/relevance
description: This route is registered but not documented yet.
tags:
- - Repositories
+ - Planner
security:
- sessionCookie: []
- bearerAuth: []
- parameters:
- - name: categoryId
- in: path
- required: true
- schema:
- type: string
responses:
"200":
description: Success. The response body is not documented yet.
@@ -7280,13 +7546,13 @@ paths:
x-propr-auth: member
x-undocumented: true
x-legacy-error: true
- /api/repos/todos/reorder:
+ /api/planner/validate-context-repository:
post:
- operationId: postApiReposTodosReorder
- summary: POST /api/repos/todos/reorder
+ operationId: postApiPlannerValidateContextRepository
+ summary: POST /api/planner/validate-context-repository
description: This route is registered but not documented yet.
tags:
- - Repositories
+ - Planner
security:
- sessionCookie: []
- bearerAuth: []
@@ -7300,10 +7566,10 @@ paths:
x-propr-auth: member
x-undocumented: true
x-legacy-error: true
- /api/repos/todos/{todoId}:
+ /api/preview-media/comments/{owner}/{repo}/{number}/{assetId}:
get:
- operationId: getApiReposTodosTodoId
- summary: GET /api/repos/todos/{todoId}
+ operationId: getApiPreviewMediaCommentsOwnerRepoNumberAssetId
+ summary: GET /api/preview-media/comments/{owner}/{repo}/{number}/{assetId}
description: This route is registered but not documented yet.
tags:
- Repositories
@@ -7311,57 +7577,22 @@ paths:
- sessionCookie: []
- bearerAuth: []
parameters:
- - name: todoId
+ - name: owner
in: path
required: true
schema:
type: string
- responses:
- "200":
- description: Success. The response body is not documented yet.
- "401":
- $ref: "#/components/responses/LegacyUnauthorized"
- default:
- $ref: "#/components/responses/LegacyUnexpectedError"
- x-propr-auth: member
- x-undocumented: true
- x-legacy-error: true
- put:
- operationId: putApiReposTodosTodoId
- summary: PUT /api/repos/todos/{todoId}
- description: This route is registered but not documented yet.
- tags:
- - Repositories
- security:
- - sessionCookie: []
- - bearerAuth: []
- parameters:
- - name: todoId
+ - name: repo
in: path
required: true
schema:
type: string
- responses:
- "200":
- description: Success. The response body is not documented yet.
- "401":
- $ref: "#/components/responses/LegacyUnauthorized"
- default:
- $ref: "#/components/responses/LegacyUnexpectedError"
- x-propr-auth: member
- x-undocumented: true
- x-legacy-error: true
- delete:
- operationId: deleteApiReposTodosTodoId
- summary: DELETE /api/repos/todos/{todoId}
- description: This route is registered but not documented yet.
- tags:
- - Repositories
- security:
- - sessionCookie: []
- - bearerAuth: []
- parameters:
- - name: todoId
+ - name: number
+ in: path
+ required: true
+ schema:
+ type: string
+ - name: assetId
in: path
required: true
schema:
@@ -7376,16 +7607,37 @@ paths:
x-propr-auth: member
x-undocumented: true
x-legacy-error: true
- /api/repositories/indexing-status:
+ /api/preview-media/pulls/{owner}/{repo}/{number}/{assetId}:
get:
- operationId: getApiRepositoriesIndexingStatus
- summary: GET /api/repositories/indexing-status
+ operationId: getApiPreviewMediaPullsOwnerRepoNumberAssetId
+ summary: GET /api/preview-media/pulls/{owner}/{repo}/{number}/{assetId}
description: This route is registered but not documented yet.
tags:
- Repositories
security:
- sessionCookie: []
- bearerAuth: []
+ parameters:
+ - name: owner
+ in: path
+ required: true
+ schema:
+ type: string
+ - name: repo
+ in: path
+ required: true
+ schema:
+ type: string
+ - name: number
+ in: path
+ required: true
+ schema:
+ type: string
+ - name: assetId
+ in: path
+ required: true
+ schema:
+ type: string
responses:
"200":
description: Success. The response body is not documented yet.
@@ -7396,82 +7648,38 @@ paths:
x-propr-auth: member
x-undocumented: true
x-legacy-error: true
- /api/repository-events:
+ /api/pull-requests/{number}/readiness:
get:
- operationId: listRepositoryEvents
- summary: List repository change events after a cursor
- description: "Reads the durable change-event journal of one repository (pull request, epic queue and plan issue changes) after `afterCursor`, in journal order, optionally filtered by domain, type, pull request and plan. Delivery is at-least-once: resume with the cursor of the last event you processed. A call without a cursor starts at the current end of the journal and returns the current cursor with no history, unless `fromStart=true`. The caller needs read access to the repository on GitHub (the check MCP `list_pull_requests` applies); events of other repositories are never returned."
+ operationId: getPullRequestReadiness
+ summary: Get the readiness verdict of a pull request
+ description: "Read-only: one verdict (`ready`, `waiting`, `blocked`, `merged`, `closed`, `unknown`) with a coded reason per gate, evaluated with the ProPR GitHub App identity from the same signals auto-merge uses (check runs, legacy commit statuses, non-blocking checks, mergeability, reviews, the latest ProPR AI review, Ultrafix state, active ProPR work and, when auto-merge is requested, the auto-merge policy). It never writes to GitHub or Redis, never updates the branch, never merges and never arms or withdraws auto-merge. The caller needs read access to the repository on GitHub (the check MCP `get_pull_request_readiness` applies); the App identity is used only after it is proven. See the gate and reason-code tables in the API reference."
tags:
- - Repositories
+ - Tasks
security:
- sessionCookie: []
- bearerAuth: []
parameters:
- - name: repository
- in: query
+ - name: number
+ in: path
required: true
- description: "`owner/repo`. The caller must be able to read it on GitHub, and it must be configured and enabled on this instance."
- schema:
- type: string
- - name: afterCursor
- in: query
- required: false
- description: Opaque cursor from an earlier response for this repository. Only events after it are returned.
- schema:
- type: string
- maxLength: 512
- - name: fromStart
- in: query
- required: false
- description: "Without `afterCursor`: `true` starts at the oldest retained event. By default a call without a cursor starts at the current end of the journal and returns no history."
- schema:
- type: string
- enum:
- - "true"
- - "false"
- - name: domains
- in: query
- required: false
- description: "Comma-separated domains: `pull_request`, `epic_queue`, `plan_issue`."
- schema:
- type: string
- - name: types
- in: query
- required: false
- description: "Comma-separated event types: `opened`, `head_changed`, `review_scored`, `ultrafix_started`, `ultrafix_ended`, `auto_merge_decision`, `merged`, `closed`, `started`, `advanced`, `blocked`, `completed`, `status_changed`."
+ description: Pull request number.
schema:
type: string
- - name: pullRequest
- in: query
- required: false
- description: Only events of this pull request number.
- schema:
- type: integer
- minimum: 1
- - name: planId
+ - name: repository
in: query
- required: false
- description: Only events of this plan.
+ required: true
+ description: "`owner/repo` of the pull request."
schema:
type: string
- maxLength: 255
- - name: limit
- in: query
- required: false
- description: Most events returned; defaults to 50.
- schema:
- type: integer
- minimum: 1
- maximum: 100
responses:
"200":
- description: Matching events and the cursor to resume from.
+ description: The readiness verdict.
content:
application/json:
schema:
- $ref: "#/components/schemas/RepositoryEventList"
+ $ref: "#/components/schemas/PullRequestReadiness"
"400":
- description: "`INVALID_INPUT` (a malformed parameter), `CURSOR_INVALID` (the cursor is malformed or was not issued here) or `CURSOR_WRONG_REPOSITORY` (the cursor belongs to another repository). The `hint` says how to recover."
+ description: "`INVALID_INPUT`: the number or repository is malformed."
content:
application/json:
schema:
@@ -7479,252 +7687,94 @@ paths:
"401":
$ref: "#/components/responses/EnvelopeUnauthorized"
"404":
- description: "`REPOSITORY_NOT_ACCESSIBLE`: the repository is not configured and enabled here, or the caller cannot read it on GitHub."
+ description: "`REPOSITORY_NOT_ACCESSIBLE`: the caller cannot read the repository on GitHub, so nothing is evaluated; or `PULL_REQUEST_NOT_FOUND`: the pull request does not exist or the GitHub App cannot see it."
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
- "410":
- description: "`CURSOR_EXPIRED`: the cursor is older than the retained journal, so events after it may have been pruned. This is never reported as an empty result."
+ "502":
+ description: "`GITHUB_UNAVAILABLE`: GitHub did not return the pull request, or could not be reached to check repository access; the read is safe to retry."
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
default:
$ref: "#/components/responses/EnvelopeUnexpectedError"
- x-propr-query-type: RepositoryEventsQuery
+ x-propr-query-type: PullRequestReadinessQuery
x-propr-auth: member
- /api/repository-events/wait:
+ /api/pull-requests/{number}/scores:
get:
- operationId: waitRepositoryEvents
- summary: Wait for repository change events after a cursor
- description: "Blocks for at most `timeoutSeconds` (default 15, max 30) until a matching event exists after the cursor, then returns it like the list read. Returns at once when matching events already exist. A timeout is an ordinary `200` with `outcome: timeout` and the starting cursor unchanged. Disconnecting only releases the waiter. Concurrent waits per owner are limited on each API process, counted together with MCP `wait_repository_events`."
+ operationId: getApiPullRequestsNumberScores
+ summary: GET /api/pull-requests/{number}/scores
+ description: This route is registered but not documented yet.
tags:
- - Repositories
+ - Tasks
security:
- sessionCookie: []
- bearerAuth: []
parameters:
- - name: repository
- in: query
- required: true
- description: "`owner/repo`. The caller must be able to read it on GitHub, and it must be configured and enabled on this instance."
- schema:
- type: string
- - name: afterCursor
- in: query
- required: false
- description: Opaque cursor from an earlier response for this repository. Only events after it are returned.
- schema:
- type: string
- maxLength: 512
- - name: fromStart
- in: query
- required: false
- description: "Without `afterCursor`: `true` starts at the oldest retained event. By default a call without a cursor starts at the current end of the journal and returns no history."
- schema:
- type: string
- enum:
- - "true"
- - "false"
- - name: domains
- in: query
- required: false
- description: "Comma-separated domains: `pull_request`, `epic_queue`, `plan_issue`."
- schema:
- type: string
- - name: types
- in: query
- required: false
- description: "Comma-separated event types: `opened`, `head_changed`, `review_scored`, `ultrafix_started`, `ultrafix_ended`, `auto_merge_decision`, `merged`, `closed`, `started`, `advanced`, `blocked`, `completed`, `status_changed`."
- schema:
- type: string
- - name: pullRequest
- in: query
- required: false
- description: Only events of this pull request number.
- schema:
- type: integer
- minimum: 1
- - name: planId
- in: query
- required: false
- description: Only events of this plan.
+ - name: number
+ in: path
+ required: true
schema:
type: string
- maxLength: 255
- - name: limit
- in: query
- required: false
- description: Most events returned; defaults to 50.
- schema:
- type: integer
- minimum: 1
- maximum: 100
- - name: timeoutSeconds
- in: query
- required: false
- description: Seconds to block when no matching event exists yet; defaults to 15.
- schema:
- type: number
- minimum: 0
- maximum: 30
responses:
"200":
- description: Matching events, or a timeout with the unchanged cursor.
- content:
- application/json:
- schema:
- $ref: "#/components/schemas/RepositoryEventWait"
- "400":
- description: "`INVALID_INPUT`, `CURSOR_INVALID` or `CURSOR_WRONG_REPOSITORY`."
- content:
- application/json:
- schema:
- $ref: "#/components/schemas/ErrorEnvelope"
+ description: Success. The response body is not documented yet.
"401":
- $ref: "#/components/responses/EnvelopeUnauthorized"
- "404":
- description: "`REPOSITORY_NOT_ACCESSIBLE`: the repository is not configured and enabled here, or the caller cannot read it on GitHub."
- content:
- application/json:
- schema:
- $ref: "#/components/schemas/ErrorEnvelope"
- "410":
- description: "`CURSOR_EXPIRED`: the cursor is older than the retained journal."
- content:
- application/json:
- schema:
- $ref: "#/components/schemas/ErrorEnvelope"
- "429":
- description: "`WAIT_LIMIT`: too many repository event waits are open for this owner on this server."
- content:
- application/json:
- schema:
- $ref: "#/components/schemas/ErrorEnvelope"
+ $ref: "#/components/responses/LegacyUnauthorized"
default:
- $ref: "#/components/responses/EnvelopeUnexpectedError"
- x-propr-query-type: RepositoryEventsWaitQuery
+ $ref: "#/components/responses/LegacyUnexpectedError"
x-propr-auth: member
- /api/stats/cost:
+ x-undocumented: true
+ x-legacy-error: true
+ /api/queue/stats:
get:
- operationId: getCostExport
- summary: Export LLM cost grouped by day, repository, agent, model, effort, execution type or outcome
- description: One row per group of agent executions (`llm_executions`) started in the period. Repository comes from the execution's task; agent and execution type from the matching LLM log call. Executions without a task have no repository and are left out when filtering by repository.
+ operationId: getApiQueueStats
+ summary: GET /api/queue/stats
+ description: This route is registered but not documented yet.
tags:
- Observability
security:
- sessionCookie: []
- bearerAuth: []
- parameters:
- - name: period
- in: query
- required: false
- description: Bounds executions by when they started; omitted means all time.
- schema:
- type: string
- enum:
- - 24h
- - 7d
- - 30d
- - 90d
- - 1y
- - all
- - name: repository
- in: query
- required: false
- description: "`owner/repo`, or `all` (default)."
- schema:
- type: string
- - name: group_by
- in: query
- required: false
- description: Comma-separated list of `day`, `repository`, `agent`, `model`, `reasoning_level`, `execution_type`, `outcome`; defaults to `day`.
- schema:
- type: string
responses:
"200":
- description: The grouped rows and their totals.
- content:
- application/json:
- schema:
- $ref: "#/components/schemas/CostExport"
- "400":
- description: "`period`, `repository` or `group_by` is invalid."
- content:
- application/json:
- schema:
- $ref: "#/components/schemas/LegacyError"
+ description: Success. The response body is not documented yet.
"401":
$ref: "#/components/responses/LegacyUnauthorized"
default:
$ref: "#/components/responses/LegacyUnexpectedError"
- x-propr-query-type: CostExportQuery
x-propr-auth: member
+ x-undocumented: true
x-legacy-error: true
- /api/stats/cost.csv:
- get:
- operationId: getCostExportCsv
- summary: Export LLM cost as CSV
- description: "One row per group of agent executions (`llm_executions`) started in the period. Repository comes from the execution's task; agent and execution type from the matching LLM log call. Executions without a task have no repository and are left out when filtering by repository. A header row, then one row per group: the grouped dimensions in column order, then executions, tasks, input_tokens, output_tokens, cache_creation_input_tokens, cache_read_input_tokens, cost_usd. Unknown values are empty cells."
+ /api/repos/chat:
+ post:
+ operationId: postApiReposChat
+ summary: POST /api/repos/chat
+ description: This route is registered but not documented yet.
tags:
- - Observability
+ - Repositories
security:
- sessionCookie: []
- bearerAuth: []
- parameters:
- - name: period
- in: query
- required: false
- description: Bounds executions by when they started; omitted means all time.
- schema:
- type: string
- enum:
- - 24h
- - 7d
- - 30d
- - 90d
- - 1y
- - all
- - name: repository
- in: query
- required: false
- description: "`owner/repo`, or `all` (default)."
- schema:
- type: string
- - name: group_by
- in: query
- required: false
- description: Comma-separated list of `day`, `repository`, `agent`, `model`, `reasoning_level`, `execution_type`, `outcome`; defaults to `day`.
- schema:
- type: string
responses:
"200":
- description: RFC 4180 CSV attachment.
- content:
- text/csv:
- schema:
- type: string
- "400":
- description: "`period`, `repository` or `group_by` is invalid."
- content:
- application/json:
- schema:
- $ref: "#/components/schemas/LegacyError"
+ description: Success. The response body is not documented yet.
"401":
$ref: "#/components/responses/LegacyUnauthorized"
default:
$ref: "#/components/responses/LegacyUnexpectedError"
- x-propr-query-type: CostExportQuery
x-propr-auth: member
+ x-undocumented: true
x-legacy-error: true
- /api/stats/dashboard:
+ /api/repos/chat/messages:
get:
- operationId: getApiStatsDashboard
- summary: GET /api/stats/dashboard
+ operationId: getApiReposChatMessages
+ summary: GET /api/repos/chat/messages
description: This route is registered but not documented yet.
tags:
- - Observability
+ - Repositories
security:
- sessionCookie: []
- bearerAuth: []
@@ -7738,13 +7788,12 @@ paths:
x-propr-auth: member
x-undocumented: true
x-legacy-error: true
- /api/stats/generating-plans:
- get:
- operationId: getApiStatsGeneratingPlans
- summary: GET /api/stats/generating-plans
+ post:
+ operationId: postApiReposChatMessages
+ summary: POST /api/repos/chat/messages
description: This route is registered but not documented yet.
tags:
- - Observability
+ - Repositories
security:
- sessionCookie: []
- bearerAuth: []
@@ -7758,13 +7807,12 @@ paths:
x-propr-auth: member
x-undocumented: true
x-legacy-error: true
- /api/stats/overview:
- get:
- operationId: getApiStatsOverview
- summary: GET /api/stats/overview
+ delete:
+ operationId: deleteApiReposChatMessages
+ summary: DELETE /api/repos/chat/messages
description: This route is registered but not documented yet.
tags:
- - Observability
+ - Repositories
security:
- sessionCookie: []
- bearerAuth: []
@@ -7778,16 +7826,22 @@ paths:
x-propr-auth: member
x-undocumented: true
x-legacy-error: true
- /api/stats/repositories:
- get:
- operationId: getApiStatsRepositories
- summary: GET /api/stats/repositories
+ /api/repos/chat/messages/{messageId}:
+ delete:
+ operationId: deleteApiReposChatMessagesMessageId
+ summary: DELETE /api/repos/chat/messages/{messageId}
description: This route is registered but not documented yet.
tags:
- - Observability
+ - Repositories
security:
- sessionCookie: []
- bearerAuth: []
+ parameters:
+ - name: messageId
+ in: path
+ required: true
+ schema:
+ type: string
responses:
"200":
description: Success. The response body is not documented yet.
@@ -7798,13 +7852,13 @@ paths:
x-propr-auth: member
x-undocumented: true
x-legacy-error: true
- /api/stats/review-scores:
- get:
- operationId: getApiStatsReviewScores
- summary: GET /api/stats/review-scores
+ /api/repos/improvements:
+ post:
+ operationId: postApiReposImprovements
+ summary: POST /api/repos/improvements
description: This route is registered but not documented yet.
tags:
- - Observability
+ - Repositories
security:
- sessionCookie: []
- bearerAuth: []
@@ -7818,13 +7872,13 @@ paths:
x-propr-auth: member
x-undocumented: true
x-legacy-error: true
- /api/stats/review-scores.csv:
+ /api/repos/media:
get:
- operationId: getApiStatsReviewScoresCsv
- summary: GET /api/stats/review-scores.csv
+ operationId: getApiReposMedia
+ summary: GET /api/repos/media
description: This route is registered but not documented yet.
tags:
- - Observability
+ - Repositories
security:
- sessionCookie: []
- bearerAuth: []
@@ -7838,13 +7892,13 @@ paths:
x-propr-auth: member
x-undocumented: true
x-legacy-error: true
- /api/stats/tasks:
+ /api/repos/todos:
get:
- operationId: getApiStatsTasks
- summary: GET /api/stats/tasks
+ operationId: getApiReposTodos
+ summary: GET /api/repos/todos
description: This route is registered but not documented yet.
tags:
- - Observability
+ - Repositories
security:
- sessionCookie: []
- bearerAuth: []
@@ -7858,50 +7912,35 @@ paths:
x-propr-auth: member
x-undocumented: true
x-legacy-error: true
- /api/status:
- get:
- operationId: getSystemStatus
- summary: Get the system health snapshot
- description: Redis, queue, worker and agent health, the same snapshot the dashboard header shows.
+ post:
+ operationId: postApiReposTodos
+ summary: POST /api/repos/todos
+ description: This route is registered but not documented yet.
tags:
- - System
+ - Repositories
security:
- sessionCookie: []
- bearerAuth: []
responses:
"200":
- description: The health snapshot.
- content:
- application/json:
- schema:
- $ref: "#/components/schemas/JsonObject"
+ description: Success. The response body is not documented yet.
"401":
$ref: "#/components/responses/LegacyUnauthorized"
default:
$ref: "#/components/responses/LegacyUnexpectedError"
x-propr-auth: member
+ x-undocumented: true
x-legacy-error: true
- /api/summaries/{owner}/{repo}/status:
+ /api/repos/todos/categories:
get:
- operationId: getApiSummariesOwnerRepoStatus
- summary: GET /api/summaries/{owner}/{repo}/status
+ operationId: getApiReposTodosCategories
+ summary: GET /api/repos/todos/categories
description: This route is registered but not documented yet.
tags:
- Repositories
security:
- sessionCookie: []
- bearerAuth: []
- parameters:
- - name: owner
- in: path
- required: true
- schema:
- type: string
- - name: repo
- in: path
- required: true
- schema:
- type: string
responses:
"200":
description: Success. The response body is not documented yet.
@@ -7912,33 +7951,15 @@ paths:
x-propr-auth: member
x-undocumented: true
x-legacy-error: true
- /api/summaries/{owner}/{repo}/summary/{path}:
- get:
- operationId: getApiSummariesOwnerRepoSummaryPath
- summary: GET /api/summaries/{owner}/{repo}/summary/{path}
+ post:
+ operationId: postApiReposTodosCategories
+ summary: POST /api/repos/todos/categories
description: This route is registered but not documented yet.
tags:
- Repositories
security:
- sessionCookie: []
- bearerAuth: []
- parameters:
- - name: owner
- in: path
- required: true
- schema:
- type: string
- - name: repo
- in: path
- required: true
- schema:
- type: string
- - name: path
- in: path
- required: true
- description: Remaining path; may contain `/`.
- schema:
- type: string
responses:
"200":
description: Success. The response body is not documented yet.
@@ -7949,27 +7970,16 @@ paths:
x-propr-auth: member
x-undocumented: true
x-legacy-error: true
- /api/summaries/{owner}/{repo}/tree:
- get:
- operationId: getApiSummariesOwnerRepoTree
- summary: GET /api/summaries/{owner}/{repo}/tree
+ /api/repos/todos/categories/reorder:
+ post:
+ operationId: postApiReposTodosCategoriesReorder
+ summary: POST /api/repos/todos/categories/reorder
description: This route is registered but not documented yet.
tags:
- Repositories
security:
- sessionCookie: []
- bearerAuth: []
- parameters:
- - name: owner
- in: path
- required: true
- schema:
- type: string
- - name: repo
- in: path
- required: true
- schema:
- type: string
responses:
"200":
description: Success. The response body is not documented yet.
@@ -7980,10 +7990,10 @@ paths:
x-propr-auth: member
x-undocumented: true
x-legacy-error: true
- /api/summaries/{owner}/{repo}/tree/{path}:
- get:
- operationId: getApiSummariesOwnerRepoTreePath
- summary: GET /api/summaries/{owner}/{repo}/tree/{path}
+ /api/repos/todos/categories/{categoryId}:
+ put:
+ operationId: putApiReposTodosCategoriesCategoryId
+ summary: PUT /api/repos/todos/categories/{categoryId}
description: This route is registered but not documented yet.
tags:
- Repositories
@@ -7991,20 +8001,9 @@ paths:
- sessionCookie: []
- bearerAuth: []
parameters:
- - name: owner
- in: path
- required: true
- schema:
- type: string
- - name: repo
- in: path
- required: true
- schema:
- type: string
- - name: path
+ - name: categoryId
in: path
required: true
- description: Remaining path; may contain `/`.
schema:
type: string
responses:
@@ -8017,838 +8016,596 @@ paths:
x-propr-auth: member
x-undocumented: true
x-legacy-error: true
- /api/task-submission-extensions:
- get:
- operationId: getTaskSubmissionExtensions
- summary: Preview the extensions a task would use
- description: Names of the extension library MCP servers and skills that would apply automatically to a task in this repository for the chosen agent. Never includes URLs, credentials or item limits. Labels on the issue (`disable-mcps`, `disable-skills`, `disable-extensions`) can remove them at launch.
+ delete:
+ operationId: deleteApiReposTodosCategoriesCategoryId
+ summary: DELETE /api/repos/todos/categories/{categoryId}
+ description: This route is registered but not documented yet.
tags:
- - Task submissions
+ - Repositories
security:
- sessionCookie: []
- bearerAuth: []
parameters:
- - name: repository
- in: query
+ - name: categoryId
+ in: path
required: true
- description: "`owner/name` of an enabled repository you can push to."
- schema:
- type: string
- pattern: ^[\w.-]+\/[\w.-]+$
- - name: agentAlias
- in: query
- required: false
- description: Agent the task would use. Omit for the instance default.
- schema:
- type: string
- - name: model
- in: query
- required: false
schema:
type: string
responses:
"200":
- description: The items that would apply.
- content:
- application/json:
- schema:
- $ref: "#/components/schemas/TaskSubmissionExtensions"
- "400":
- description: Missing repository, or the agent or model cannot be used.
- content:
- application/json:
- schema:
- $ref: "#/components/schemas/LegacyError"
+ description: Success. The response body is not documented yet.
"401":
$ref: "#/components/responses/LegacyUnauthorized"
- "403":
- description: You lack write access to the repository.
- content:
- application/json:
- schema:
- $ref: "#/components/schemas/LegacyError"
default:
$ref: "#/components/responses/LegacyUnexpectedError"
- x-propr-query-type: TaskSubmissionExtensionsQuery
x-propr-auth: member
+ x-undocumented: true
x-legacy-error: true
- /api/task-submissions:
+ /api/repos/todos/reorder:
post:
- operationId: createTaskSubmission
- summary: Submit a task
- description: |-
- Opens a GitHub issue in the repository with the instruction, applies the routing labels and enqueues the implementation run.
-
- Submissions are idempotent per user and `Idempotency-Key`: repeating a request with the same key and content returns the existing submission and resumes it if it stopped part way. Reusing a key with different content is rejected with `409`.
-
- Send JSON, or `multipart/form-data` with the JSON in a `payload` field and up to 10 `files` attachments.
+ operationId: postApiReposTodosReorder
+ summary: POST /api/repos/todos/reorder
+ description: This route is registered but not documented yet.
tags:
- - Task submissions
+ - Repositories
security:
- sessionCookie: []
- bearerAuth: []
- parameters:
- - name: Idempotency-Key
- in: header
- required: true
- description: Client-chosen identity of this submission, for example a UUID.
- schema:
- type: string
- maxLength: 255
- requestBody:
- required: true
- content:
- application/json:
- schema:
- $ref: "#/components/schemas/TaskSubmissionRequest"
- multipart/form-data:
- schema:
- type: object
- required:
- - payload
- properties:
- payload:
- type: string
- description: The `TaskSubmissionRequest` as a JSON string.
- contentMediaType: application/json
- files:
- type: array
- maxItems: 10
- items:
- type: string
- contentMediaType: application/octet-stream
responses:
"200":
- description: The issue was created and the run is queued.
- content:
- application/json:
- schema:
- $ref: "#/components/schemas/TaskSubmission"
- "202":
- description: The submission is in progress or stopped part way (`failed`); poll or retry it.
- content:
- application/json:
- schema:
- $ref: "#/components/schemas/TaskSubmission"
- "400":
- description: Missing key, invalid body, or the repository, agent or model cannot be used.
- content:
- application/json:
- schema:
- $ref: "#/components/schemas/LegacyError"
+ description: Success. The response body is not documented yet.
"401":
$ref: "#/components/responses/LegacyUnauthorized"
- "403":
- description: You lack write access to the repository, or the instance is in demo mode.
- content:
- application/json:
- schema:
- $ref: "#/components/schemas/LegacyError"
- "409":
- description: The key was already used with different content.
- content:
- application/json:
- schema:
- $ref: "#/components/schemas/LegacyError"
default:
$ref: "#/components/responses/LegacyUnexpectedError"
x-propr-auth: member
+ x-undocumented: true
x-legacy-error: true
- /api/task-submissions/{key}:
+ /api/repos/todos/{todoId}:
get:
- operationId: getTaskSubmission
- summary: Get a task submission
+ operationId: getApiReposTodosTodoId
+ summary: GET /api/repos/todos/{todoId}
+ description: This route is registered but not documented yet.
tags:
- - Task submissions
+ - Repositories
security:
- sessionCookie: []
- bearerAuth: []
parameters:
- - name: key
+ - name: todoId
in: path
required: true
- description: The `Idempotency-Key` the submission was created with.
schema:
type: string
responses:
"200":
- description: The submission.
- content:
- application/json:
- schema:
- $ref: "#/components/schemas/TaskSubmission"
+ description: Success. The response body is not documented yet.
"401":
$ref: "#/components/responses/LegacyUnauthorized"
- "404":
- description: No submission of yours has this key.
- content:
- application/json:
- schema:
- $ref: "#/components/schemas/LegacyError"
default:
$ref: "#/components/responses/LegacyUnexpectedError"
x-propr-auth: member
+ x-undocumented: true
x-legacy-error: true
- /api/task-submissions/{key}/retry:
- post:
- operationId: retryTaskSubmission
- summary: Resume a task submission that stopped part way
+ put:
+ operationId: putApiReposTodosTodoId
+ summary: PUT /api/repos/todos/{todoId}
+ description: This route is registered but not documented yet.
tags:
- - Task submissions
+ - Repositories
security:
- sessionCookie: []
- bearerAuth: []
parameters:
- - name: key
+ - name: todoId
in: path
required: true
- description: The `Idempotency-Key` the submission was created with.
schema:
type: string
responses:
"200":
- description: The resumed submission.
- content:
- application/json:
- schema:
- $ref: "#/components/schemas/TaskSubmission"
+ description: Success. The response body is not documented yet.
"401":
$ref: "#/components/responses/LegacyUnauthorized"
- "404":
- description: No submission of yours has this key.
- content:
- application/json:
- schema:
- $ref: "#/components/schemas/LegacyError"
default:
$ref: "#/components/responses/LegacyUnexpectedError"
x-propr-auth: member
+ x-undocumented: true
x-legacy-error: true
- /api/task/{taskId}:
delete:
- operationId: deleteTaskLegacyPath
- summary: Delete a task run (legacy path)
- description: |-
- Same as `DELETE /api/tasks/{taskId}`.
-
- When the task, session or correlation id belongs to a goal, only the goal owner can use this route (others get `404`), and only for reads: other methods answer `409` and the goal controls must be used instead.
+ operationId: deleteApiReposTodosTodoId
+ summary: DELETE /api/repos/todos/{todoId}
+ description: This route is registered but not documented yet.
tags:
- - Tasks
- deprecated: true
+ - Repositories
security:
- sessionCookie: []
- bearerAuth: []
parameters:
- - name: taskId
+ - name: todoId
in: path
required: true
- description: Task (run) identifier, as returned in `TaskSummary.id`.
- schema:
- type: string
- - name: force
- in: query
- required: false
- description: Delete even when the task is still active.
schema:
type: string
- enum:
- - "true"
- - "false"
responses:
- "204":
- description: Deleted.
+ "200":
+ description: Success. The response body is not documented yet.
"401":
$ref: "#/components/responses/LegacyUnauthorized"
- "404":
- description: Task not found.
- content:
- application/json:
- schema:
- $ref: "#/components/schemas/LegacyError"
- "409":
- description: The task belongs to a goal; use the goal controls.
- content:
- application/json:
- schema:
- $ref: "#/components/schemas/LegacyError"
default:
$ref: "#/components/responses/LegacyUnexpectedError"
- x-propr-query-type: DeleteTaskQuery
x-propr-auth: member
+ x-undocumented: true
x-legacy-error: true
- /api/task/{taskId}/assignable-users:
+ /api/repositories/indexing-status:
get:
- operationId: listTaskAssignableUsers
- summary: List users that can be assigned to a task
- description: |-
- The assignable users of the task's repository, at most 300, sorted by login. Cached for 60 seconds per repository.
-
- When the task, session or correlation id belongs to a goal, only the goal owner can use this route (others get `404`), and only for reads: other methods answer `409` and the goal controls must be used instead.
+ operationId: getApiRepositoriesIndexingStatus
+ summary: GET /api/repositories/indexing-status
+ description: This route is registered but not documented yet.
tags:
- - Tasks
+ - Repositories
security:
- sessionCookie: []
- bearerAuth: []
- parameters:
- - name: taskId
- in: path
- required: true
- description: Task (run) identifier, as returned in `TaskSummary.id`.
- schema:
- type: string
responses:
"200":
- description: The assignable users.
- content:
- application/json:
- schema:
- $ref: "#/components/schemas/AssignableUsers"
- "400":
- description: The task id is invalid.
- content:
- application/json:
- schema:
- $ref: "#/components/schemas/LegacyError"
+ description: Success. The response body is not documented yet.
"401":
$ref: "#/components/responses/LegacyUnauthorized"
- "404":
- description: Task not found.
- content:
- application/json:
- schema:
- $ref: "#/components/schemas/LegacyError"
- "409":
- description: The task has no GitHub issue or pull request to assign.
- content:
- application/json:
- schema:
- $ref: "#/components/schemas/LegacyError"
- "502":
- description: GitHub could not be reached.
- content:
- application/json:
- schema:
- $ref: "#/components/schemas/LegacyError"
default:
$ref: "#/components/responses/LegacyUnexpectedError"
x-propr-auth: member
+ x-undocumented: true
x-legacy-error: true
- /api/task/{taskId}/assignees:
+ /api/repository-events:
get:
- operationId: getTaskAssignees
- summary: Get the assignees of a task
- description: |-
- Reads the assignees of the task's GitHub issue or pull request from GitHub and stores them. With `refresh=false` the stored set is returned without calling GitHub. When GitHub cannot be read the stored set is returned with `synced: false`.
-
- When the task, session or correlation id belongs to a goal, only the goal owner can use this route (others get `404`), and only for reads: other methods answer `409` and the goal controls must be used instead.
+ operationId: listRepositoryEvents
+ summary: List repository change events after a cursor
+ description: "Reads the durable change-event journal of one repository (pull request, epic queue, plan issue and task changes) after `afterCursor`, in journal order, optionally filtered by domain, type, pull request and plan. Delivery is at-least-once: resume with the cursor of the last event you processed. A call without a cursor starts at the current end of the journal and returns the current cursor with no history, unless `fromStart=true`. The caller needs read access to the repository on GitHub (the check MCP `list_pull_requests` applies); events of other repositories are never returned."
tags:
- - Tasks
+ - Repositories
security:
- sessionCookie: []
- bearerAuth: []
parameters:
- - name: taskId
- in: path
+ - name: repository
+ in: query
required: true
- description: Task (run) identifier, as returned in `TaskSummary.id`.
+ description: "`owner/repo`. The caller must be able to read it on GitHub, and it must be configured and enabled on this instance."
schema:
type: string
- - name: refresh
+ - name: afterCursor
in: query
required: false
- description: "`false` serves the stored set without calling GitHub."
+ description: Opaque cursor from an earlier response for this repository. Only events after it are returned.
+ schema:
+ type: string
+ maxLength: 512
+ - name: fromStart
+ in: query
+ required: false
+ description: "Without `afterCursor`: `true` starts at the oldest retained event. By default a call without a cursor starts at the current end of the journal and returns no history."
schema:
type: string
enum:
- "true"
- "false"
+ - name: domains
+ in: query
+ required: false
+ description: "Comma-separated domains: `pull_request`, `epic_queue`, `plan_issue`, `task`."
+ schema:
+ type: string
+ - name: types
+ in: query
+ required: false
+ description: "Comma-separated event types: `opened`, `head_changed`, `review_scored`, `ultrafix_started`, `ultrafix_ended`, `auto_merge_decision`, `merged`, `closed`, `started`, `advanced`, `blocked`, `completed`, `status_changed`, `queued`, `failed`, `cancelled`."
+ schema:
+ type: string
+ - name: pullRequest
+ in: query
+ required: false
+ description: Only events of this pull request number.
+ schema:
+ type: integer
+ minimum: 1
+ - name: planId
+ in: query
+ required: false
+ description: Only events of this plan.
+ schema:
+ type: string
+ maxLength: 255
+ - name: limit
+ in: query
+ required: false
+ description: Most events returned; defaults to 50.
+ schema:
+ type: integer
+ minimum: 1
+ maximum: 100
responses:
"200":
- description: The task's assignees.
+ description: Matching events and the cursor to resume from.
content:
application/json:
schema:
- $ref: "#/components/schemas/TaskAssignees"
+ $ref: "#/components/schemas/RepositoryEventList"
"400":
- description: The task id is invalid.
+ description: "`INVALID_INPUT` (a malformed parameter), `CURSOR_INVALID` (the cursor is malformed or was not issued here) or `CURSOR_WRONG_REPOSITORY` (the cursor belongs to another repository). The `hint` says how to recover."
content:
application/json:
schema:
- $ref: "#/components/schemas/LegacyError"
+ $ref: "#/components/schemas/ErrorEnvelope"
"401":
- $ref: "#/components/responses/LegacyUnauthorized"
+ $ref: "#/components/responses/EnvelopeUnauthorized"
"404":
- description: Task not found.
+ description: "`REPOSITORY_NOT_ACCESSIBLE`: the repository is not configured and enabled here, or the caller cannot read it on GitHub."
content:
application/json:
schema:
- $ref: "#/components/schemas/LegacyError"
- "409":
- description: The task has no GitHub issue or pull request to assign.
+ $ref: "#/components/schemas/ErrorEnvelope"
+ "410":
+ description: "`CURSOR_EXPIRED`: the cursor is older than the retained journal, so events after it may have been pruned. This is never reported as an empty result."
content:
application/json:
schema:
- $ref: "#/components/schemas/LegacyError"
+ $ref: "#/components/schemas/ErrorEnvelope"
default:
- $ref: "#/components/responses/LegacyUnexpectedError"
- x-propr-query-type: TaskAssigneesQuery
+ $ref: "#/components/responses/EnvelopeUnexpectedError"
+ x-propr-query-type: RepositoryEventsQuery
x-propr-auth: member
- x-legacy-error: true
- put:
- operationId: setTaskAssignees
- summary: Change the assignees of a task
- description: |-
- Assigns the task's GitHub issue or pull request on GitHub and returns the set GitHub confirmed. Requires write access to the repository on GitHub.
-
- When the task, session or correlation id belongs to a goal, only the goal owner can use this route (others get `404`), and only for reads: other methods answer `409` and the goal controls must be used instead.
+ /api/repository-events/wait:
+ get:
+ operationId: waitRepositoryEvents
+ summary: Wait for repository change events after a cursor
+ description: "Blocks for at most `timeoutSeconds` (default 15, max 30) until a matching event exists after the cursor, then returns it like the list read. Returns at once when matching events already exist. A timeout is an ordinary `200` with `outcome: timeout` and the starting cursor unchanged. Disconnecting only releases the waiter. Concurrent waits per owner are limited on each API process, counted together with MCP `wait_repository_events`."
tags:
- - Tasks
+ - Repositories
security:
- sessionCookie: []
- bearerAuth: []
parameters:
- - name: taskId
- in: path
+ - name: repository
+ in: query
required: true
- description: Task (run) identifier, as returned in `TaskSummary.id`.
+ description: "`owner/repo`. The caller must be able to read it on GitHub, and it must be configured and enabled on this instance."
schema:
type: string
- requestBody:
- required: true
- content:
- application/json:
- schema:
- $ref: "#/components/schemas/TaskAssigneesUpdate"
- responses:
- "200":
- description: The assignment GitHub confirmed.
- content:
- application/json:
- schema:
- $ref: "#/components/schemas/TaskAssigneesResult"
- "400":
- description: The body is malformed or names an unknown GitHub login.
- content:
- application/json:
- schema:
- $ref: "#/components/schemas/LegacyError"
- "401":
- $ref: "#/components/responses/LegacyUnauthorized"
- "403":
- description: You lack write access to the repository on GitHub.
- content:
- application/json:
- schema:
- $ref: "#/components/schemas/LegacyError"
- "404":
- description: Task not found, or the repository is not accessible.
+ - name: afterCursor
+ in: query
+ required: false
+ description: Opaque cursor from an earlier response for this repository. Only events after it are returned.
+ schema:
+ type: string
+ maxLength: 512
+ - name: fromStart
+ in: query
+ required: false
+ description: "Without `afterCursor`: `true` starts at the oldest retained event. By default a call without a cursor starts at the current end of the journal and returns no history."
+ schema:
+ type: string
+ enum:
+ - "true"
+ - "false"
+ - name: domains
+ in: query
+ required: false
+ description: "Comma-separated domains: `pull_request`, `epic_queue`, `plan_issue`, `task`."
+ schema:
+ type: string
+ - name: types
+ in: query
+ required: false
+ description: "Comma-separated event types: `opened`, `head_changed`, `review_scored`, `ultrafix_started`, `ultrafix_ended`, `auto_merge_decision`, `merged`, `closed`, `started`, `advanced`, `blocked`, `completed`, `status_changed`, `queued`, `failed`, `cancelled`."
+ schema:
+ type: string
+ - name: pullRequest
+ in: query
+ required: false
+ description: Only events of this pull request number.
+ schema:
+ type: integer
+ minimum: 1
+ - name: planId
+ in: query
+ required: false
+ description: Only events of this plan.
+ schema:
+ type: string
+ maxLength: 255
+ - name: limit
+ in: query
+ required: false
+ description: Most events returned; defaults to 50.
+ schema:
+ type: integer
+ minimum: 1
+ maximum: 100
+ - name: timeoutSeconds
+ in: query
+ required: false
+ description: Seconds to block when no matching event exists yet; defaults to 15.
+ schema:
+ type: number
+ minimum: 0
+ maximum: 30
+ responses:
+ "200":
+ description: Matching events, or a timeout with the unchanged cursor.
content:
application/json:
schema:
- $ref: "#/components/schemas/LegacyError"
- "409":
- description: The task has no GitHub issue or pull request to assign.
+ $ref: "#/components/schemas/RepositoryEventWait"
+ "400":
+ description: "`INVALID_INPUT`, `CURSOR_INVALID` or `CURSOR_WRONG_REPOSITORY`."
content:
application/json:
schema:
- $ref: "#/components/schemas/LegacyError"
- "422":
- description: GitHub rejected the assignment, or did not assign some requested users (listed in `rejected`).
+ $ref: "#/components/schemas/ErrorEnvelope"
+ "401":
+ $ref: "#/components/responses/EnvelopeUnauthorized"
+ "404":
+ description: "`REPOSITORY_NOT_ACCESSIBLE`: the repository is not configured and enabled here, or the caller cannot read it on GitHub."
content:
application/json:
schema:
- $ref: "#/components/schemas/LegacyError"
- "502":
- description: GitHub could not be reached.
+ $ref: "#/components/schemas/ErrorEnvelope"
+ "410":
+ description: "`CURSOR_EXPIRED`: the cursor is older than the retained journal."
content:
application/json:
schema:
- $ref: "#/components/schemas/LegacyError"
+ $ref: "#/components/schemas/ErrorEnvelope"
+ "429":
+ description: "`WAIT_LIMIT`: too many repository event waits are open for this owner on this server."
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/ErrorEnvelope"
default:
- $ref: "#/components/responses/LegacyUnexpectedError"
+ $ref: "#/components/responses/EnvelopeUnexpectedError"
+ x-propr-query-type: RepositoryEventsWaitQuery
x-propr-auth: member
- x-legacy-error: true
- /api/task/{taskId}/cancel:
- post:
- operationId: cancelTask
- summary: Cancel a task (alias of stop)
- description: "When the task, session or correlation id belongs to a goal, only the goal owner can use this route (others get `404`), and only for reads: other methods answer `409` and the goal controls must be used instead."
+ /api/stats/cost:
+ get:
+ operationId: getCostExport
+ summary: Export LLM cost grouped by day, repository, agent, model, effort, execution type or outcome
+ description: One row per group of agent executions (`llm_executions`) started in the period. Repository comes from the execution's task; agent and execution type from the matching LLM log call. Executions without a task have no repository and are left out when filtering by repository.
tags:
- - Tasks
+ - Observability
security:
- sessionCookie: []
- bearerAuth: []
parameters:
- - name: taskId
- in: path
- required: true
- description: Task (run) identifier, as returned in `TaskSummary.id`.
+ - name: period
+ in: query
+ required: false
+ description: Bounds executions by when they started; omitted means all time.
+ schema:
+ type: string
+ enum:
+ - 24h
+ - 7d
+ - 30d
+ - 90d
+ - 1y
+ - all
+ - name: repository
+ in: query
+ required: false
+ description: "`owner/repo`, or `all` (default)."
+ schema:
+ type: string
+ - name: group_by
+ in: query
+ required: false
+ description: Comma-separated list of `day`, `repository`, `agent`, `model`, `reasoning_level`, `execution_type`, `outcome`; defaults to `day`.
schema:
type: string
responses:
"200":
- description: The task was stopped or was already finished.
- content:
- application/json:
- schema:
- $ref: "#/components/schemas/JsonObject"
- "401":
- $ref: "#/components/responses/LegacyUnauthorized"
- "404":
- description: Not found, or the task belongs to another user's goal.
+ description: The grouped rows and their totals.
content:
application/json:
schema:
- $ref: "#/components/schemas/LegacyError"
- "409":
- description: The task belongs to a goal; use the goal controls.
+ $ref: "#/components/schemas/CostExport"
+ "400":
+ description: "`period`, `repository` or `group_by` is invalid."
content:
application/json:
schema:
$ref: "#/components/schemas/LegacyError"
+ "401":
+ $ref: "#/components/responses/LegacyUnauthorized"
default:
$ref: "#/components/responses/LegacyUnexpectedError"
+ x-propr-query-type: CostExportQuery
x-propr-auth: member
x-legacy-error: true
- /api/task/{taskId}/docker-info:
+ /api/stats/cost.csv:
get:
- operationId: getApiTaskTaskIdDockerInfo
- summary: GET /api/task/{taskId}/docker-info
- description: |-
- This route is registered but not documented yet.
-
- When the task, session or correlation id belongs to a goal, only the goal owner can use this route (others get `404`), and only for reads: other methods answer `409` and the goal controls must be used instead.
+ operationId: getCostExportCsv
+ summary: Export LLM cost as CSV
+ description: "One row per group of agent executions (`llm_executions`) started in the period. Repository comes from the execution's task; agent and execution type from the matching LLM log call. Executions without a task have no repository and are left out when filtering by repository. A header row, then one row per group: the grouped dimensions in column order, then executions, tasks, input_tokens, output_tokens, cache_creation_input_tokens, cache_read_input_tokens, cost_usd. Unknown values are empty cells."
tags:
- - Tasks
+ - Observability
security:
- sessionCookie: []
- bearerAuth: []
parameters:
- - name: taskId
- in: path
- required: true
+ - name: period
+ in: query
+ required: false
+ description: Bounds executions by when they started; omitted means all time.
+ schema:
+ type: string
+ enum:
+ - 24h
+ - 7d
+ - 30d
+ - 90d
+ - 1y
+ - all
+ - name: repository
+ in: query
+ required: false
+ description: "`owner/repo`, or `all` (default)."
+ schema:
+ type: string
+ - name: group_by
+ in: query
+ required: false
+ description: Comma-separated list of `day`, `repository`, `agent`, `model`, `reasoning_level`, `execution_type`, `outcome`; defaults to `day`.
schema:
type: string
responses:
"200":
- description: Success. The response body is not documented yet.
- "401":
- $ref: "#/components/responses/LegacyUnauthorized"
- "404":
- description: Not found, or the task belongs to another user's goal.
+ description: RFC 4180 CSV attachment.
+ content:
+ text/csv:
+ schema:
+ type: string
+ "400":
+ description: "`period`, `repository` or `group_by` is invalid."
content:
application/json:
schema:
$ref: "#/components/schemas/LegacyError"
+ "401":
+ $ref: "#/components/responses/LegacyUnauthorized"
default:
$ref: "#/components/responses/LegacyUnexpectedError"
+ x-propr-query-type: CostExportQuery
x-propr-auth: member
- x-undocumented: true
x-legacy-error: true
- /api/task/{taskId}/docker-logs:
+ /api/stats/dashboard:
get:
- operationId: getApiTaskTaskIdDockerLogs
- summary: GET /api/task/{taskId}/docker-logs
- description: |-
- This route is registered but not documented yet.
-
- When the task, session or correlation id belongs to a goal, only the goal owner can use this route (others get `404`), and only for reads: other methods answer `409` and the goal controls must be used instead.
+ operationId: getApiStatsDashboard
+ summary: GET /api/stats/dashboard
+ description: This route is registered but not documented yet.
tags:
- - Tasks
+ - Observability
security:
- sessionCookie: []
- bearerAuth: []
- parameters:
- - name: taskId
- in: path
- required: true
- schema:
- type: string
responses:
"200":
description: Success. The response body is not documented yet.
"401":
$ref: "#/components/responses/LegacyUnauthorized"
- "404":
- description: Not found, or the task belongs to another user's goal.
- content:
- application/json:
- schema:
- $ref: "#/components/schemas/LegacyError"
default:
$ref: "#/components/responses/LegacyUnexpectedError"
x-propr-auth: member
x-undocumented: true
x-legacy-error: true
- /api/task/{taskId}/file-changes:
+ /api/stats/generating-plans:
get:
- operationId: getTaskFileChanges
- summary: List the files a task changed
- description: "When the task, session or correlation id belongs to a goal, only the goal owner can use this route (others get `404`), and only for reads: other methods answer `409` and the goal controls must be used instead."
+ operationId: getApiStatsGeneratingPlans
+ summary: GET /api/stats/generating-plans
+ description: This route is registered but not documented yet.
tags:
- - Tasks
+ - Observability
security:
- sessionCookie: []
- bearerAuth: []
- parameters:
- - name: taskId
- in: path
- required: true
- description: Task (run) identifier, as returned in `TaskSummary.id`.
- schema:
- type: string
responses:
"200":
- description: Changed files with their diffs.
- content:
- application/json:
- schema:
- $ref: "#/components/schemas/JsonObject"
+ description: Success. The response body is not documented yet.
"401":
$ref: "#/components/responses/LegacyUnauthorized"
- "404":
- description: Not found, or the task belongs to another user's goal.
- content:
- application/json:
- schema:
- $ref: "#/components/schemas/LegacyError"
default:
$ref: "#/components/responses/LegacyUnexpectedError"
x-propr-auth: member
+ x-undocumented: true
x-legacy-error: true
- /api/task/{taskId}/history:
+ /api/stats/overview:
get:
- operationId: getTaskHistory
- summary: Get a task with its lifecycle events
- description: |-
- Returns the task details and every recorded state change, oldest first, from the database, falling back to live worker state for runs that have not been persisted yet.
-
- When the task, session or correlation id belongs to a goal, only the goal owner can use this route (others get `404`), and only for reads: other methods answer `409` and the goal controls must be used instead.
+ operationId: getApiStatsOverview
+ summary: GET /api/stats/overview
+ description: This route is registered but not documented yet.
tags:
- - Tasks
+ - Observability
security:
- sessionCookie: []
- bearerAuth: []
- parameters:
- - name: taskId
- in: path
- required: true
- description: Task (run) identifier, as returned in `TaskSummary.id`.
- schema:
- type: string
responses:
"200":
- description: The task and its events.
- content:
- application/json:
- schema:
- $ref: "#/components/schemas/TaskHistory"
+ description: Success. The response body is not documented yet.
"401":
$ref: "#/components/responses/LegacyUnauthorized"
- "404":
- description: Not found, or the task belongs to another user's goal.
- content:
- application/json:
- schema:
- $ref: "#/components/schemas/LegacyError"
default:
$ref: "#/components/responses/LegacyUnexpectedError"
x-propr-auth: member
+ x-undocumented: true
x-legacy-error: true
- /api/task/{taskId}/live-details:
+ /api/stats/repositories:
get:
- operationId: getTaskLiveDetails
- summary: Get live agent output of a running task
- description: "When the task, session or correlation id belongs to a goal, only the goal owner can use this route (others get `404`), and only for reads: other methods answer `409` and the goal controls must be used instead."
+ operationId: getApiStatsRepositories
+ summary: GET /api/stats/repositories
+ description: This route is registered but not documented yet.
tags:
- - Tasks
+ - Observability
security:
- sessionCookie: []
- bearerAuth: []
- parameters:
- - name: taskId
- in: path
- required: true
- description: Task (run) identifier, as returned in `TaskSummary.id`.
- schema:
- type: string
responses:
"200":
- description: Parsed agent conversation and execution progress.
- content:
- application/json:
- schema:
- $ref: "#/components/schemas/JsonObject"
+ description: Success. The response body is not documented yet.
"401":
$ref: "#/components/responses/LegacyUnauthorized"
- "404":
- description: Not found, or the task belongs to another user's goal.
- content:
- application/json:
- schema:
- $ref: "#/components/schemas/LegacyError"
default:
$ref: "#/components/responses/LegacyUnexpectedError"
x-propr-auth: member
+ x-undocumented: true
x-legacy-error: true
- /api/task/{taskId}/stop:
- post:
- operationId: stopTask
- summary: Stop a running task
- description: "When the task, session or correlation id belongs to a goal, only the goal owner can use this route (others get `404`), and only for reads: other methods answer `409` and the goal controls must be used instead."
+ /api/stats/review-scores:
+ get:
+ operationId: getApiStatsReviewScores
+ summary: GET /api/stats/review-scores
+ description: This route is registered but not documented yet.
tags:
- - Tasks
+ - Observability
security:
- sessionCookie: []
- bearerAuth: []
- parameters:
- - name: taskId
- in: path
- required: true
- description: Task (run) identifier, as returned in `TaskSummary.id`.
- schema:
- type: string
responses:
"200":
- description: The task was stopped or was already finished.
- content:
- application/json:
- schema:
- $ref: "#/components/schemas/JsonObject"
+ description: Success. The response body is not documented yet.
"401":
$ref: "#/components/responses/LegacyUnauthorized"
- "404":
- description: Not found, or the task belongs to another user's goal.
- content:
- application/json:
- schema:
- $ref: "#/components/schemas/LegacyError"
- "409":
- description: The task belongs to a goal; use the goal controls.
- content:
- application/json:
- schema:
- $ref: "#/components/schemas/LegacyError"
default:
$ref: "#/components/responses/LegacyUnexpectedError"
x-propr-auth: member
+ x-undocumented: true
x-legacy-error: true
- /api/tasks:
+ /api/stats/review-scores.csv:
get:
- operationId: listTasks
- summary: List task runs
- description: Newest first. Filter by status, repository or search text, and page with `limit` and `offset`. With `groupBy=task`, paging counts tasks (the pull request or issue a run belongs to) and a page returns every matching run of its tasks.
+ operationId: getApiStatsReviewScoresCsv
+ summary: GET /api/stats/review-scores.csv
+ description: This route is registered but not documented yet.
tags:
- - Tasks
+ - Observability
security:
- sessionCookie: []
- bearerAuth: []
- parameters:
- - name: status
- in: query
- required: false
- description: "`all` (default), `active`, `waiting`, `attention`, or a worker state."
- schema:
- type: string
- - name: repository
- in: query
- required: false
- description: "`owner/name`, or `all` (default)."
- schema:
- type: string
- - name: search
- in: query
- required: false
- schema:
- type: string
- maxLength: 500
- - name: limit
- in: query
- required: false
- description: Page size; defaults to 50.
- schema:
- type: integer
- minimum: 1
- maximum: 1000
- - name: offset
- in: query
- required: false
- schema:
- type: integer
- minimum: 0
- maximum: 1000000
- - name: forReview
- in: query
- required: false
- description: Only runs whose pull request awaits review.
- schema:
- type: string
- enum:
- - "true"
- - "false"
- - name: excludeMerged
- in: query
- required: false
- schema:
- type: string
- enum:
- - "true"
- - "false"
- - name: groupBy
- in: query
- required: false
- description: Page by task instead of by run.
- schema:
- type: string
- const: task
- - name: task
- in: query
- required: false
- description: "With `groupBy=task`: only the task this run belongs to."
- schema:
- type: string
- - name: assignee
- in: query
- required: false
- description: "`all` (default), `me` (the signed-in user), `unassigned`, or comma-separated GitHub logins."
- schema:
- type: string
- - name: syncAssignees
- in: query
- required: false
- description: "With `task`: refresh that run's assignees from GitHub first."
- schema:
- type: string
- enum:
- - "true"
- - "false"
responses:
"200":
- description: A page of task runs.
- content:
- application/json:
- schema:
- $ref: "#/components/schemas/TaskPage"
- "400":
- description: A filter or paging parameter is invalid.
- content:
- application/json:
- schema:
- $ref: "#/components/schemas/LegacyError"
+ description: Success. The response body is not documented yet.
"401":
$ref: "#/components/responses/LegacyUnauthorized"
default:
$ref: "#/components/responses/LegacyUnexpectedError"
- x-propr-query-type: ListTasksQuery
x-propr-auth: member
+ x-undocumented: true
x-legacy-error: true
- /api/tasks/revert:
- post:
- operationId: postApiTasksRevert
- summary: POST /api/tasks/revert
+ /api/stats/tasks:
+ get:
+ operationId: getApiStatsTasks
+ summary: GET /api/stats/tasks
description: This route is registered but not documented yet.
tags:
- - Tasks
+ - Observability
security:
- sessionCookie: []
- bearerAuth: []
@@ -8862,841 +8619,1845 @@ paths:
x-propr-auth: member
x-undocumented: true
x-legacy-error: true
- /api/tasks/revert-preview:
+ /api/status:
get:
- operationId: getApiTasksRevertPreview
- summary: GET /api/tasks/revert-preview
- description: This route is registered but not documented yet.
+ operationId: getSystemStatus
+ summary: Get the system health snapshot
+ description: Redis, queue, worker and agent health, the same snapshot the dashboard header shows.
tags:
- - Tasks
+ - System
security:
- sessionCookie: []
- bearerAuth: []
responses:
"200":
- description: Success. The response body is not documented yet.
+ description: The health snapshot.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/JsonObject"
"401":
$ref: "#/components/responses/LegacyUnauthorized"
default:
$ref: "#/components/responses/LegacyUnexpectedError"
x-propr-auth: member
- x-undocumented: true
x-legacy-error: true
- /api/tasks/{taskId}:
- delete:
- operationId: deleteTask
- summary: Delete a task run and its history
- description: "When the task, session or correlation id belongs to a goal, only the goal owner can use this route (others get `404`), and only for reads: other methods answer `409` and the goal controls must be used instead."
+ /api/summaries/{owner}/{repo}/status:
+ get:
+ operationId: getApiSummariesOwnerRepoStatus
+ summary: GET /api/summaries/{owner}/{repo}/status
+ description: This route is registered but not documented yet.
tags:
- - Tasks
+ - Repositories
security:
- sessionCookie: []
- bearerAuth: []
parameters:
- - name: taskId
+ - name: owner
in: path
required: true
- description: Task (run) identifier, as returned in `TaskSummary.id`.
schema:
type: string
- - name: force
- in: query
- required: false
- description: Delete even when the task is still active.
+ - name: repo
+ in: path
+ required: true
schema:
type: string
- enum:
- - "true"
- - "false"
responses:
- "204":
- description: Deleted.
- "400":
- description: The task is still active; retry with `force=true`.
- content:
- application/json:
- schema:
- $ref: "#/components/schemas/LegacyError"
+ "200":
+ description: Success. The response body is not documented yet.
"401":
$ref: "#/components/responses/LegacyUnauthorized"
- "404":
- description: Task not found.
- content:
- application/json:
- schema:
- $ref: "#/components/schemas/LegacyError"
- "409":
- description: The task belongs to a goal; use the goal controls.
- content:
- application/json:
- schema:
- $ref: "#/components/schemas/LegacyError"
default:
$ref: "#/components/responses/LegacyUnexpectedError"
- x-propr-query-type: DeleteTaskQuery
x-propr-auth: member
+ x-undocumented: true
x-legacy-error: true
- /api/tasks/{taskId}/followup:
- post:
- operationId: postTaskFollowup
- summary: Post a follow-up instruction on a task
- description: |-
- Posts the comment on the task's pull request and queues a follow-up run for it. A failed implementation that has no pull request is retried on its issue instead: the comment is posted there and a new issue run is queued.
-
- When the task, session or correlation id belongs to a goal, only the goal owner can use this route (others get `404`), and only for reads: other methods answer `409` and the goal controls must be used instead.
+ /api/summaries/{owner}/{repo}/summary/{path}:
+ get:
+ operationId: getApiSummariesOwnerRepoSummaryPath
+ summary: GET /api/summaries/{owner}/{repo}/summary/{path}
+ description: This route is registered but not documented yet.
tags:
- - Tasks
+ - Repositories
security:
- sessionCookie: []
- bearerAuth: []
parameters:
- - name: taskId
+ - name: owner
in: path
required: true
- description: Task (run) identifier, as returned in `TaskSummary.id`.
schema:
type: string
- requestBody:
- required: true
- content:
- application/json:
- schema:
- $ref: "#/components/schemas/TaskFollowupRequest"
+ - name: repo
+ in: path
+ required: true
+ schema:
+ type: string
+ - name: path
+ in: path
+ required: true
+ description: Remaining path; may contain `/`.
+ schema:
+ type: string
responses:
- "202":
- description: The comment was posted; the outcome distinguishes queued, batched, handled, refused, failed and unknown submissions. An issue retry refuses the comment, without posting it, while it restarts an earlier retry whose start was never confirmed; `restartedRequest` reports that restart.
- content:
- application/json:
- schema:
- $ref: "#/components/schemas/TaskFollowupResult"
- "400":
- description: The comment body or target is invalid, or a pull request command was sent for a task without a pull request.
- content:
- application/json:
- schema:
- $ref: "#/components/schemas/LegacyError"
+ "200":
+ description: Success. The response body is not documented yet.
"401":
$ref: "#/components/responses/LegacyUnauthorized"
- "404":
- description: Task not found.
- content:
- application/json:
- schema:
- $ref: "#/components/schemas/LegacyError"
- "409":
- description: "The issue cannot be retried: the run has not failed, the issue is closed or done, a run is already queued, or an earlier retry may still start."
- content:
- application/json:
- schema:
- $ref: "#/components/schemas/LegacyError"
- "502":
- description: The issue's label history could not be read from GitHub, so the retry could not be tied to its trigger and nothing was posted.
- content:
- application/json:
- schema:
- $ref: "#/components/schemas/LegacyError"
- "503":
- description: The issue retry could not be recorded, so nothing was posted.
- content:
- application/json:
- schema:
- $ref: "#/components/schemas/LegacyError"
default:
$ref: "#/components/responses/LegacyUnexpectedError"
x-propr-auth: member
+ x-undocumented: true
x-legacy-error: true
- /api/tasks/{taskId}/followup-runs:
+ /api/summaries/{owner}/{repo}/tree:
get:
- operationId: getTaskFollowupRuns
- summary: Read the runs an issue follow-up started
- description: |-
- Read-only. Returns the other runs of the task's repository and issue whose job carried the follow-up's own comment for the signed-in user, so the page that sent it can open the new run. Empty while the run has not started; it never posts or queues anything.
-
- When the task, session or correlation id belongs to a goal, only the goal owner can use this route (others get `404`), and only for reads: other methods answer `409` and the goal controls must be used instead.
+ operationId: getApiSummariesOwnerRepoTree
+ summary: GET /api/summaries/{owner}/{repo}/tree
+ description: This route is registered but not documented yet.
tags:
- - Tasks
+ - Repositories
security:
- sessionCookie: []
- bearerAuth: []
parameters:
- - name: taskId
+ - name: owner
in: path
required: true
- description: Task (run) identifier, as returned in `TaskSummary.id`.
schema:
type: string
- - name: commentId
- in: query
+ - name: repo
+ in: path
required: true
- description: The `commentId` the follow-up's receipt returned.
schema:
type: string
- pattern: ^[1-9]\d{0,15}$
responses:
"200":
- description: The runs found, possibly none yet.
- content:
- application/json:
- schema:
- $ref: "#/components/schemas/TaskFollowupRuns"
- "400":
- description: The task ID or comment ID is invalid.
- content:
- application/json:
- schema:
- $ref: "#/components/schemas/LegacyError"
+ description: Success. The response body is not documented yet.
"401":
- description: Authentication required.
- content:
- application/json:
- schema:
- $ref: "#/components/schemas/LegacyError"
- "404":
- description: Task not found.
- content:
- application/json:
- schema:
- $ref: "#/components/schemas/LegacyError"
+ $ref: "#/components/responses/LegacyUnauthorized"
default:
$ref: "#/components/responses/LegacyUnexpectedError"
- x-propr-query-type: TaskFollowupRunsQuery
x-propr-auth: member
+ x-undocumented: true
x-legacy-error: true
- /api/tasks/{taskId}/review:
- post:
- operationId: postTaskReview
- summary: Request an AI review for a task pull request
- description: |-
- Checks the signed-in user’s repository access and PR assignment, dispatches /review through command handling, and returns the same receipt on repeated requests for the source run.
-
- When the task, session or correlation id belongs to a goal, only the goal owner can use this route (others get `404`), and only for reads: other methods answer `409` and the goal controls must be used instead.
+ /api/summaries/{owner}/{repo}/tree/{path}:
+ get:
+ operationId: getApiSummariesOwnerRepoTreePath
+ summary: GET /api/summaries/{owner}/{repo}/tree/{path}
+ description: This route is registered but not documented yet.
tags:
- - Tasks
+ - Repositories
security:
- sessionCookie: []
- bearerAuth: []
parameters:
- - name: taskId
+ - name: owner
in: path
required: true
- description: Task (run) identifier, as returned in `TaskSummary.id`.
+ schema:
+ type: string
+ - name: repo
+ in: path
+ required: true
+ schema:
+ type: string
+ - name: path
+ in: path
+ required: true
+ description: Remaining path; may contain `/`.
schema:
type: string
responses:
- "202":
- description: Review submission receipt. Only queued or batched is accepted; unknown requires reconciliation.
+ "200":
+ description: Success. The response body is not documented yet.
+ "401":
+ $ref: "#/components/responses/LegacyUnauthorized"
+ default:
+ $ref: "#/components/responses/LegacyUnexpectedError"
+ x-propr-auth: member
+ x-undocumented: true
+ x-legacy-error: true
+ /api/task-submission-extensions:
+ get:
+ operationId: getTaskSubmissionExtensions
+ summary: Preview the extensions a task would use
+ description: Names of the extension library MCP servers and skills that would apply automatically to a task in this repository for the chosen agent. Never includes URLs, credentials or item limits. Labels on the issue (`disable-mcps`, `disable-skills`, `disable-extensions`) can remove them at launch.
+ tags:
+ - Task submissions
+ security:
+ - sessionCookie: []
+ - bearerAuth: []
+ parameters:
+ - name: repository
+ in: query
+ required: true
+ description: "`owner/name` of an enabled repository you can push to."
+ schema:
+ type: string
+ pattern: ^[\w.-]+\/[\w.-]+$
+ - name: agentAlias
+ in: query
+ required: false
+ description: Agent the task would use. Omit for the instance default.
+ schema:
+ type: string
+ - name: model
+ in: query
+ required: false
+ schema:
+ type: string
+ responses:
+ "200":
+ description: The items that would apply.
content:
application/json:
schema:
- $ref: "#/components/schemas/TaskReviewResult"
+ $ref: "#/components/schemas/TaskSubmissionExtensions"
"400":
- description: Invalid task or missing pull request.
+ description: Missing repository, or the agent or model cannot be used.
content:
application/json:
schema:
$ref: "#/components/schemas/LegacyError"
"401":
- description: Authentication required.
- content:
- application/json:
- schema:
- $ref: "#/components/schemas/LegacyError"
+ $ref: "#/components/responses/LegacyUnauthorized"
"403":
- description: GitHub authorization or PR assignment required.
- content:
- application/json:
- schema:
- $ref: "#/components/schemas/LegacyError"
- "404":
- description: Task or repository not accessible.
- content:
- application/json:
- schema:
- $ref: "#/components/schemas/LegacyError"
- "409":
- description: Pull request is closed.
- content:
- application/json:
- schema:
- $ref: "#/components/schemas/LegacyError"
- "503":
- description: Authorization could not be verified.
+ description: You lack write access to the repository.
content:
application/json:
schema:
$ref: "#/components/schemas/LegacyError"
default:
$ref: "#/components/responses/LegacyUnexpectedError"
+ x-propr-query-type: TaskSubmissionExtensionsQuery
x-propr-auth: member
x-legacy-error: true
- /api/usage-tips:
- get:
- operationId: getApiUsageTips
- summary: GET /api/usage-tips
- description: This route is registered but not documented yet.
- tags:
- - Observability
- security:
- - sessionCookie: []
- - bearerAuth: []
- responses:
- "200":
- description: Success. The response body is not documented yet.
- "401":
- $ref: "#/components/responses/LegacyUnauthorized"
- default:
- $ref: "#/components/responses/LegacyUnexpectedError"
- x-propr-auth: member
- x-undocumented: true
- x-legacy-error: true
- /api/usage-tips/dismiss:
+ /api/task-submissions:
post:
- operationId: postApiUsageTipsDismiss
- summary: POST /api/usage-tips/dismiss
- description: This route is registered but not documented yet.
+ operationId: createTaskSubmission
+ summary: Submit a task
+ description: |-
+ Opens a GitHub issue in the repository with the instruction, applies the routing labels and enqueues the implementation run.
+
+ Submissions are idempotent per user and `Idempotency-Key`: repeating a request with the same key and content returns the existing submission and resumes it if it stopped part way. Reusing a key with different content is rejected with `409`.
+
+ Send JSON, or `multipart/form-data` with the JSON in a `payload` field and up to 10 `files` attachments.
tags:
- - Observability
+ - Task submissions
security:
- sessionCookie: []
- bearerAuth: []
+ parameters:
+ - name: Idempotency-Key
+ in: header
+ required: true
+ description: Client-chosen identity of this submission, for example a UUID.
+ schema:
+ type: string
+ maxLength: 255
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/TaskSubmissionRequest"
+ multipart/form-data:
+ schema:
+ type: object
+ required:
+ - payload
+ properties:
+ payload:
+ type: string
+ description: The `TaskSubmissionRequest` as a JSON string.
+ contentMediaType: application/json
+ files:
+ type: array
+ maxItems: 10
+ items:
+ type: string
+ contentMediaType: application/octet-stream
responses:
"200":
- description: Success. The response body is not documented yet.
+ description: The issue was created and the run is queued.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/TaskSubmission"
+ "202":
+ description: The submission is in progress or stopped part way (`failed`); poll or retry it.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/TaskSubmission"
+ "400":
+ description: Missing key, invalid body, or the repository, agent or model cannot be used.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/LegacyError"
"401":
$ref: "#/components/responses/LegacyUnauthorized"
+ "403":
+ description: You lack write access to the repository, or the instance is in demo mode.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/LegacyError"
+ "409":
+ description: The key was already used with different content.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/LegacyError"
default:
$ref: "#/components/responses/LegacyUnexpectedError"
x-propr-auth: member
- x-undocumented: true
x-legacy-error: true
- /api/user/repo-preferences:
+ /api/task-submissions/{key}:
get:
- operationId: getApiUserRepoPreferences
- summary: GET /api/user/repo-preferences
- description: This route is registered but not documented yet.
+ operationId: getTaskSubmission
+ summary: Get a task submission
tags:
- - Repositories
+ - Task submissions
security:
- sessionCookie: []
- bearerAuth: []
+ parameters:
+ - name: key
+ in: path
+ required: true
+ description: The `Idempotency-Key` the submission was created with.
+ schema:
+ type: string
responses:
"200":
- description: Success. The response body is not documented yet.
+ description: The submission.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/TaskSubmission"
"401":
$ref: "#/components/responses/LegacyUnauthorized"
+ "404":
+ description: No submission of yours has this key.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/LegacyError"
default:
$ref: "#/components/responses/LegacyUnexpectedError"
x-propr-auth: member
- x-undocumented: true
x-legacy-error: true
+ /api/task-submissions/{key}/retry:
post:
- operationId: postApiUserRepoPreferences
- summary: POST /api/user/repo-preferences
- description: This route is registered but not documented yet.
+ operationId: retryTaskSubmission
+ summary: Resume a task submission that stopped part way
tags:
- - Repositories
+ - Task submissions
security:
- sessionCookie: []
- bearerAuth: []
+ parameters:
+ - name: key
+ in: path
+ required: true
+ description: The `Idempotency-Key` the submission was created with.
+ schema:
+ type: string
responses:
"200":
- description: Success. The response body is not documented yet.
+ description: The resumed submission.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/TaskSubmission"
"401":
$ref: "#/components/responses/LegacyUnauthorized"
+ "404":
+ description: No submission of yours has this key.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/LegacyError"
default:
$ref: "#/components/responses/LegacyUnexpectedError"
x-propr-auth: member
- x-undocumented: true
x-legacy-error: true
- /api/voice/briefing:
- get:
- operationId: getApiVoiceBriefing
- summary: GET /api/voice/briefing
- description: This route is registered but not documented yet.
+ /api/task/{taskId}:
+ delete:
+ operationId: deleteTaskLegacyPath
+ summary: Delete a task run (legacy path)
+ description: |-
+ Same as `DELETE /api/tasks/{taskId}`.
+
+ When the task, session or correlation id belongs to a goal, only the goal owner can use this route (others get `404`), and only for reads: other methods answer `409` and the goal controls must be used instead.
tags:
- - Voice
+ - Tasks
+ deprecated: true
security:
- sessionCookie: []
- bearerAuth: []
+ parameters:
+ - name: taskId
+ in: path
+ required: true
+ description: Task (run) identifier, as returned in `TaskSummary.id`.
+ schema:
+ type: string
+ - name: force
+ in: query
+ required: false
+ description: Delete even when the task is still active.
+ schema:
+ type: string
+ enum:
+ - "true"
+ - "false"
responses:
- "200":
- description: Success. The response body is not documented yet.
+ "204":
+ description: Deleted.
"401":
$ref: "#/components/responses/LegacyUnauthorized"
+ "404":
+ description: Task not found.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/LegacyError"
+ "409":
+ description: The task belongs to a goal; use the goal controls.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/LegacyError"
default:
$ref: "#/components/responses/LegacyUnexpectedError"
+ x-propr-query-type: DeleteTaskQuery
x-propr-auth: member
- x-undocumented: true
x-legacy-error: true
- /api/voice/capabilities:
+ /api/task/{taskId}/assignable-users:
get:
- operationId: getApiVoiceCapabilities
- summary: GET /api/voice/capabilities
- description: This route is registered but not documented yet.
+ operationId: listTaskAssignableUsers
+ summary: List users that can be assigned to a task
+ description: |-
+ The assignable users of the task's repository, at most 300, sorted by login. Cached for 60 seconds per repository.
+
+ When the task, session or correlation id belongs to a goal, only the goal owner can use this route (others get `404`), and only for reads: other methods answer `409` and the goal controls must be used instead.
tags:
- - Voice
+ - Tasks
security:
- sessionCookie: []
- bearerAuth: []
+ parameters:
+ - name: taskId
+ in: path
+ required: true
+ description: Task (run) identifier, as returned in `TaskSummary.id`.
+ schema:
+ type: string
responses:
"200":
- description: Success. The response body is not documented yet.
- "401":
- $ref: "#/components/responses/LegacyUnauthorized"
- default:
- $ref: "#/components/responses/LegacyUnexpectedError"
- x-propr-auth: member
- x-undocumented: true
- x-legacy-error: true
- /authorize:
- get:
- operationId: authorizeMcpClient
- summary: Start an MCP OAuth authorization
- description: OAuth 2.1 authorization endpoint with PKCE (`S256`). Takes `response_type=code`, `client_id`, `redirect_uri` (must match a registered URI exactly), `code_challenge`, `code_challenge_method`, and optionally `scope`, `state` and `resource` as query parameters, then redirects the browser to the consent page. Served by the MCP SDK's OAuth router at the API origin root, and only while MCP is enabled. See the MCP guide (docs/features/mcp).
- tags:
- - MCP
- security: []
- responses:
- "302":
- description: Redirect to the consent page, or to `redirect_uri` with an OAuth error.
+ description: The assignable users.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/AssignableUsers"
"400":
- description: Unknown client or a `redirect_uri` that is not registered.
+ description: The task id is invalid.
content:
application/json:
schema:
$ref: "#/components/schemas/LegacyError"
+ "401":
+ $ref: "#/components/responses/LegacyUnauthorized"
"404":
- description: MCP is disabled on this instance.
+ description: Task not found.
content:
application/json:
schema:
$ref: "#/components/schemas/LegacyError"
- default:
- $ref: "#/components/responses/LegacyUnexpectedError"
- x-propr-auth: public
- x-legacy-error: true
- post:
- operationId: authorizeMcpClientForm
- summary: Start an MCP OAuth authorization (form post)
- description: Same as `GET /authorize`, with the parameters in an `application/x-www-form-urlencoded` body. Served by the MCP SDK's OAuth router at the API origin root, and only while MCP is enabled. See the MCP guide (docs/features/mcp).
- tags:
- - MCP
- security: []
- responses:
- "302":
- description: Redirect to the consent page, or to `redirect_uri` with an OAuth error.
- "400":
- description: Unknown client or a `redirect_uri` that is not registered.
+ "409":
+ description: The task has no GitHub issue or pull request to assign.
content:
application/json:
schema:
$ref: "#/components/schemas/LegacyError"
- "404":
- description: MCP is disabled on this instance.
+ "502":
+ description: GitHub could not be reached.
content:
application/json:
schema:
$ref: "#/components/schemas/LegacyError"
default:
$ref: "#/components/responses/LegacyUnexpectedError"
- x-propr-auth: public
+ x-propr-auth: member
x-legacy-error: true
- /health:
+ /api/task/{taskId}/assignees:
get:
- operationId: getHealth
- summary: Liveness probe
+ operationId: getTaskAssignees
+ summary: Get the assignees of a task
+ description: |-
+ Reads the assignees of the task's GitHub issue or pull request from GitHub and stores them. With `refresh=false` the stored set is returned without calling GitHub. When GitHub cannot be read the stored set is returned with `synced: false`.
+
+ When the task, session or correlation id belongs to a goal, only the goal owner can use this route (others get `404`), and only for reads: other methods answer `409` and the goal controls must be used instead.
tags:
- - System
- security: []
+ - Tasks
+ security:
+ - sessionCookie: []
+ - bearerAuth: []
+ parameters:
+ - name: taskId
+ in: path
+ required: true
+ description: Task (run) identifier, as returned in `TaskSummary.id`.
+ schema:
+ type: string
+ - name: refresh
+ in: query
+ required: false
+ description: "`false` serves the stored set without calling GitHub."
+ schema:
+ type: string
+ enum:
+ - "true"
+ - "false"
responses:
"200":
- description: The API process is serving requests.
+ description: The task's assignees.
content:
application/json:
schema:
- $ref: "#/components/schemas/Health"
- default:
- $ref: "#/components/responses/LegacyUnexpectedError"
- x-propr-auth: public
- x-legacy-error: true
- /mcp/apps:
- get:
- operationId: getMcpApps
- summary: GET /mcp/apps
- description: This route is registered but not documented yet.
- tags:
- - MCP
- security:
- - sessionCookie: []
- responses:
- "200":
- description: Success. The response body is not documented yet.
- default:
- $ref: "#/components/responses/LegacyUnexpectedError"
- x-propr-auth: browserSession
- x-undocumented: true
- x-legacy-error: true
- /mcp/apps/revoke:
- post:
- operationId: postMcpAppsRevoke
- summary: POST /mcp/apps/revoke
- description: This route is registered but not documented yet.
- tags:
- - MCP
- security:
- - sessionCookie: []
- responses:
- "200":
- description: Success. The response body is not documented yet.
+ $ref: "#/components/schemas/TaskAssignees"
+ "400":
+ description: The task id is invalid.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/LegacyError"
+ "401":
+ $ref: "#/components/responses/LegacyUnauthorized"
+ "404":
+ description: Task not found.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/LegacyError"
+ "409":
+ description: The task has no GitHub issue or pull request to assign.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/LegacyError"
default:
$ref: "#/components/responses/LegacyUnexpectedError"
- x-propr-auth: browserSession
- x-undocumented: true
+ x-propr-query-type: TaskAssigneesQuery
+ x-propr-auth: member
x-legacy-error: true
- /mcp/artifacts/{id}:
- get:
- operationId: getMcpArtifactsId
- summary: GET /mcp/artifacts/{id}
- description: This route is registered but not documented yet.
+ put:
+ operationId: setTaskAssignees
+ summary: Change the assignees of a task
+ description: |-
+ Assigns the task's GitHub issue or pull request on GitHub and returns the set GitHub confirmed. Requires write access to the repository on GitHub.
+
+ When the task, session or correlation id belongs to a goal, only the goal owner can use this route (others get `404`), and only for reads: other methods answer `409` and the goal controls must be used instead.
tags:
- - MCP
+ - Tasks
security:
- sessionCookie: []
+ - bearerAuth: []
parameters:
- - name: id
+ - name: taskId
in: path
required: true
+ description: Task (run) identifier, as returned in `TaskSummary.id`.
schema:
type: string
- responses:
- "200":
- description: Success. The response body is not documented yet.
- default:
- $ref: "#/components/responses/LegacyUnexpectedError"
- x-propr-auth: browserSession
- x-undocumented: true
- x-legacy-error: true
- /mcp/consent:
- get:
- operationId: getMcpConsent
- summary: GET /mcp/consent
- description: This route is registered but not documented yet.
- tags:
- - MCP
- security:
- - sessionCookie: []
- responses:
- "200":
- description: Success. The response body is not documented yet.
- default:
- $ref: "#/components/responses/LegacyUnexpectedError"
- x-propr-auth: browserSession
- x-undocumented: true
- x-legacy-error: true
- post:
- operationId: postMcpConsent
- summary: POST /mcp/consent
- description: This route is registered but not documented yet.
- tags:
- - MCP
- security:
- - sessionCookie: []
- responses:
- "200":
- description: Success. The response body is not documented yet.
- default:
- $ref: "#/components/responses/LegacyUnexpectedError"
- x-propr-auth: browserSession
- x-undocumented: true
- x-legacy-error: true
- /register:
- post:
- operationId: registerMcpClient
- summary: Register an MCP OAuth client
- description: "RFC 7591 dynamic client registration. Clients register as public clients (`token_endpoint_auth_method: none`). Served by the MCP SDK's OAuth router at the API origin root, and only while MCP is enabled. See the MCP guide (docs/features/mcp)."
- tags:
- - MCP
- security: []
requestBody:
required: true
- description: RFC 7591 client metadata, for example `client_name` and `redirect_uris`.
content:
application/json:
schema:
- $ref: "#/components/schemas/JsonObject"
+ $ref: "#/components/schemas/TaskAssigneesUpdate"
responses:
- "201":
- description: The registered client, including its `client_id`.
+ "200":
+ description: The assignment GitHub confirmed.
content:
application/json:
schema:
- $ref: "#/components/schemas/JsonObject"
+ $ref: "#/components/schemas/TaskAssigneesResult"
"400":
- description: RFC 7591 error, for example `invalid_client_metadata`.
+ description: The body is malformed or names an unknown GitHub login.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/LegacyError"
+ "401":
+ $ref: "#/components/responses/LegacyUnauthorized"
+ "403":
+ description: You lack write access to the repository on GitHub.
content:
application/json:
schema:
$ref: "#/components/schemas/LegacyError"
"404":
- description: MCP is disabled on this instance.
+ description: Task not found, or the repository is not accessible.
content:
application/json:
schema:
$ref: "#/components/schemas/LegacyError"
- default:
- $ref: "#/components/responses/LegacyUnexpectedError"
- x-propr-auth: public
- x-legacy-error: true
- /revoke:
- post:
- operationId: revokeMcpToken
- summary: Revoke an MCP access or refresh token
- description: RFC 7009 revocation. The `application/x-www-form-urlencoded` body carries `token`, optionally `token_type_hint`, and `client_id`. Served by the MCP SDK's OAuth router at the API origin root, and only while MCP is enabled. See the MCP guide (docs/features/mcp).
- tags:
- - MCP
- security: []
- responses:
- "200":
- description: The token is revoked, or was not valid. The body is `{}`.
+ "409":
+ description: The task has no GitHub issue or pull request to assign.
content:
application/json:
schema:
- $ref: "#/components/schemas/JsonObject"
- "400":
- description: RFC 7009 error.
+ $ref: "#/components/schemas/LegacyError"
+ "422":
+ description: GitHub rejected the assignment, or did not assign some requested users (listed in `rejected`).
content:
application/json:
schema:
$ref: "#/components/schemas/LegacyError"
- "404":
- description: MCP is disabled on this instance.
+ "502":
+ description: GitHub could not be reached.
content:
application/json:
schema:
$ref: "#/components/schemas/LegacyError"
default:
$ref: "#/components/responses/LegacyUnexpectedError"
- x-propr-auth: public
+ x-propr-auth: member
x-legacy-error: true
- /token:
+ /api/task/{taskId}/cancel:
post:
- operationId: issueMcpToken
- summary: Exchange an authorization code or refresh token
- description: "OAuth 2.1 token endpoint for public clients (`token_endpoint_auth_method: none`). The `application/x-www-form-urlencoded` body carries `grant_type` (`authorization_code` with `code`, `code_verifier` and `redirect_uri`, or `refresh_token` with `refresh_token`), `client_id` and optionally `resource`. Served by the MCP SDK's OAuth router at the API origin root, and only while MCP is enabled. See the MCP guide (docs/features/mcp)."
+ operationId: cancelTask
+ summary: Cancel a task (alias of stop)
+ description: "When the task, session or correlation id belongs to a goal, only the goal owner can use this route (others get `404`), and only for reads: other methods answer `409` and the goal controls must be used instead."
tags:
- - MCP
- security: []
+ - Tasks
+ security:
+ - sessionCookie: []
+ - bearerAuth: []
+ parameters:
+ - name: taskId
+ in: path
+ required: true
+ description: Task (run) identifier, as returned in `TaskSummary.id`.
+ schema:
+ type: string
responses:
"200":
- description: "RFC 6749 token response: `access_token`, `token_type`, `expires_in`, `scope` and `refresh_token`."
+ description: The task was stopped or was already finished.
content:
application/json:
schema:
$ref: "#/components/schemas/JsonObject"
- "400":
- description: RFC 6749 error, for example `invalid_grant`.
+ "401":
+ $ref: "#/components/responses/LegacyUnauthorized"
+ "404":
+ description: Not found, or the task belongs to another user's goal.
content:
application/json:
schema:
$ref: "#/components/schemas/LegacyError"
+ "409":
+ description: The task belongs to a goal; use the goal controls.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/LegacyError"
+ default:
+ $ref: "#/components/responses/LegacyUnexpectedError"
+ x-propr-auth: member
+ x-legacy-error: true
+ /api/task/{taskId}/docker-info:
+ get:
+ operationId: getApiTaskTaskIdDockerInfo
+ summary: GET /api/task/{taskId}/docker-info
+ description: |-
+ This route is registered but not documented yet.
+
+ When the task, session or correlation id belongs to a goal, only the goal owner can use this route (others get `404`), and only for reads: other methods answer `409` and the goal controls must be used instead.
+ tags:
+ - Tasks
+ security:
+ - sessionCookie: []
+ - bearerAuth: []
+ parameters:
+ - name: taskId
+ in: path
+ required: true
+ schema:
+ type: string
+ responses:
+ "200":
+ description: Success. The response body is not documented yet.
+ "401":
+ $ref: "#/components/responses/LegacyUnauthorized"
"404":
- description: MCP is disabled on this instance.
+ description: Not found, or the task belongs to another user's goal.
content:
application/json:
schema:
$ref: "#/components/schemas/LegacyError"
default:
$ref: "#/components/responses/LegacyUnexpectedError"
- x-propr-auth: public
+ x-propr-auth: member
+ x-undocumented: true
x-legacy-error: true
- /webhook:
- post:
- operationId: receiveGitHubWebhook
- summary: Receive a GitHub webhook delivery
- description: Registered only when `GITHUB_EVENT_INTAKE_MODE=direct_webhook`. Deliveries must be signed with `GH_WEBHOOK_SECRET`.
+ /api/task/{taskId}/docker-logs:
+ get:
+ operationId: getApiTaskTaskIdDockerLogs
+ summary: GET /api/task/{taskId}/docker-logs
+ description: |-
+ This route is registered but not documented yet.
+
+ When the task, session or correlation id belongs to a goal, only the goal owner can use this route (others get `404`), and only for reads: other methods answer `409` and the goal controls must be used instead.
tags:
- - Webhooks
+ - Tasks
security:
- - webhookSignature: []
+ - sessionCookie: []
+ - bearerAuth: []
parameters:
- - name: X-GitHub-Event
- in: header
+ - name: taskId
+ in: path
required: true
- description: GitHub event name.
schema:
type: string
- - name: X-GitHub-Delivery
- in: header
+ responses:
+ "200":
+ description: Success. The response body is not documented yet.
+ "401":
+ $ref: "#/components/responses/LegacyUnauthorized"
+ "404":
+ description: Not found, or the task belongs to another user's goal.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/LegacyError"
+ default:
+ $ref: "#/components/responses/LegacyUnexpectedError"
+ x-propr-auth: member
+ x-undocumented: true
+ x-legacy-error: true
+ /api/task/{taskId}/file-changes:
+ get:
+ operationId: getTaskFileChanges
+ summary: List the files a task changed
+ description: "When the task, session or correlation id belongs to a goal, only the goal owner can use this route (others get `404`), and only for reads: other methods answer `409` and the goal controls must be used instead."
+ tags:
+ - Tasks
+ security:
+ - sessionCookie: []
+ - bearerAuth: []
+ parameters:
+ - name: taskId
+ in: path
required: true
- description: GitHub delivery identifier.
+ description: Task (run) identifier, as returned in `TaskSummary.id`.
schema:
type: string
- requestBody:
- required: true
- description: The GitHub event payload.
- content:
- application/json:
- schema:
- $ref: "#/components/schemas/JsonObject"
responses:
"200":
- description: Accepted.
+ description: Changed files with their diffs.
content:
- text/plain:
+ application/json:
schema:
- type: string
+ $ref: "#/components/schemas/JsonObject"
"401":
- description: Missing or invalid signature.
+ $ref: "#/components/responses/LegacyUnauthorized"
+ "404":
+ description: Not found, or the task belongs to another user's goal.
content:
application/json:
schema:
$ref: "#/components/schemas/LegacyError"
default:
$ref: "#/components/responses/LegacyUnexpectedError"
- x-propr-auth: webhook
+ x-propr-auth: member
x-legacy-error: true
-components:
- schemas:
- AgentRunAdmissionAgent:
- type: object
- properties:
- alias:
- type: string
- description: Direct agent alias (never a synthetic alias).
- ceiling:
- anyOf:
- - type: integer
- exclusiveMinimum: 0
- - type: "null"
- description: PROPR_AGENT_RUN_ALIAS_LIMITS entry; null = global limit only.
- inUse:
- type: integer
- minimum: 0
- description: "What the ceiling is compared with: holders plus nested executions counted against it."
- holders:
- $ref: "#/components/schemas/AgentRunAdmissionStateCounts"
- nested:
- type: object
- properties:
- total:
- type: integer
- minimum: 0
- chargedUnderParent:
- type: integer
- minimum: 0
- countedAgainstCeiling:
- type: integer
- minimum: 0
- required:
- - total
- - chargedUnderParent
- - countedAgainstCeiling
- additionalProperties: false
- waiting:
- $ref: "#/components/schemas/AgentRunAdmissionWaitingCounts"
- deferred:
- type: integer
- minimum: 0
- description: Queue jobs delayed until this agent has capacity.
- tasks:
- type: array
- items:
- $ref: "#/components/schemas/AgentRunAdmissionTask"
- required:
- - alias
- - ceiling
- - inUse
- - holders
- - nested
- - waiting
- - deferred
- - tasks
- additionalProperties: false
- description: Admission state of one direct agent.
- AgentRunAdmissionConfiguration:
- type: object
- properties:
- status:
- type: string
- enum:
- - current
- - uninitialized
- - pending_generation
- - mismatch
- - missing
- description: 'current: compatible protocol; the stored limit and generation equal the configured ones. uninitialized: nothing stored yet; the first admission stores the configured ones. pending_generation: compatible protocol and the configured generation is higher; the next admission attempt fences and removes every older reservation, then switches. mismatch: incompatible protocol, same generation with another limit, a lower generation, or an unreadable stored record; every launch is refused. A higher generation resolves limit/generation mismatches only within a compatible protocol. For incompatible protocols, draining and raising the generation are insufficient: follow "Rolling restarts with mixed versions" in the agent-run capacity operations docs. Drain with the existing version, stop every execution process, confirm Redis holders and physical Docker work are empty, then reset only the admission configuration key; never clear holder records to force an upgrade. missing: nothing stored while holder records remain; every launch is refused.'
- admitting:
- type: boolean
- description: "False when admission refuses every launch with the configured limit and generation: `limit` minus `inUse` is then not usable capacity."
- stored:
- anyOf:
- - type: object
- properties:
- limit:
- type: integer
- exclusiveMinimum: 0
- generation:
- type: integer
- minimum: 0
- required:
- - limit
- - generation
- additionalProperties: false
- - type: "null"
- description: Limit and generation of the stored admission configuration; null when none (or none readable) is stored.
- required:
- - status
- - admitting
- - stored
- additionalProperties: false
- description: The stored admission configuration compared with the configured global limit and generation. The admission protocol prefix is checked; the Docker daemon identity is neither shown nor compared.
- AgentRunAdmissionReport:
- type: object
- properties:
- asOf:
- type: string
- format: date-time
- admission:
- type: object
- properties:
- enabled:
- type: boolean
- agentCeilingsConfigured:
- type: boolean
- global:
- anyOf:
- - type: object
- properties:
- limit:
- type: integer
- exclusiveMinimum: 0
- description: Global limit configured on the answering process; enforced only while `configuration.admitting`.
- limitSource:
- type: string
- enum:
- - PROPR_AGENT_RUN_LIMIT
- - hosted_entitlement
- generation:
- type: integer
- minimum: 0
- description: Admission generation configured on the answering process (PROPR_AGENT_RUN_GENERATION, default 0).
- configuration:
- $ref: "#/components/schemas/AgentRunAdmissionConfiguration"
- nestedLimit:
- type: integer
- exclusiveMinimum: 0
- nestedLimitSource:
- type: string
- enum:
- - PROPR_AGENT_RUN_NESTED_LIMIT
- - default
- inUse:
- type: integer
- minimum: 0
- description: Top-level executions; each takes one global slot.
- holders:
- $ref: "#/components/schemas/AgentRunAdmissionStateCounts"
- unattributedHolders:
+ /api/task/{taskId}/history:
+ get:
+ operationId: getTaskHistory
+ summary: Get a task with its lifecycle events
+ description: |-
+ Returns the task details and every recorded state change, oldest first, from the database, falling back to live worker state for runs that have not been persisted yet.
+
+ When the task, session or correlation id belongs to a goal, only the goal owner can use this route (others get `404`), and only for reads: other methods answer `409` and the goal controls must be used instead.
+ tags:
+ - Tasks
+ security:
+ - sessionCookie: []
+ - bearerAuth: []
+ parameters:
+ - name: taskId
+ in: path
+ required: true
+ description: Task (run) identifier, as returned in `TaskSummary.id`.
+ schema:
+ type: string
+ responses:
+ "200":
+ description: The task and its events.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/TaskHistory"
+ "401":
+ $ref: "#/components/responses/LegacyUnauthorized"
+ "404":
+ description: Not found, or the task belongs to another user's goal.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/LegacyError"
+ default:
+ $ref: "#/components/responses/LegacyUnexpectedError"
+ x-propr-auth: member
+ x-legacy-error: true
+ /api/task/{taskId}/live-details:
+ get:
+ operationId: getTaskLiveDetails
+ summary: Get live agent output of a running task
+ description: "When the task, session or correlation id belongs to a goal, only the goal owner can use this route (others get `404`), and only for reads: other methods answer `409` and the goal controls must be used instead."
+ tags:
+ - Tasks
+ security:
+ - sessionCookie: []
+ - bearerAuth: []
+ parameters:
+ - name: taskId
+ in: path
+ required: true
+ description: Task (run) identifier, as returned in `TaskSummary.id`.
+ schema:
+ type: string
+ responses:
+ "200":
+ description: Parsed agent conversation and execution progress.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/JsonObject"
+ "401":
+ $ref: "#/components/responses/LegacyUnauthorized"
+ "404":
+ description: Not found, or the task belongs to another user's goal.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/LegacyError"
+ default:
+ $ref: "#/components/responses/LegacyUnexpectedError"
+ x-propr-auth: member
+ x-legacy-error: true
+ /api/task/{taskId}/stop:
+ post:
+ operationId: stopTask
+ summary: Stop a running task
+ description: "When the task, session or correlation id belongs to a goal, only the goal owner can use this route (others get `404`), and only for reads: other methods answer `409` and the goal controls must be used instead."
+ tags:
+ - Tasks
+ security:
+ - sessionCookie: []
+ - bearerAuth: []
+ parameters:
+ - name: taskId
+ in: path
+ required: true
+ description: Task (run) identifier, as returned in `TaskSummary.id`.
+ schema:
+ type: string
+ responses:
+ "200":
+ description: The task was stopped or was already finished.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/JsonObject"
+ "401":
+ $ref: "#/components/responses/LegacyUnauthorized"
+ "404":
+ description: Not found, or the task belongs to another user's goal.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/LegacyError"
+ "409":
+ description: The task belongs to a goal; use the goal controls.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/LegacyError"
+ default:
+ $ref: "#/components/responses/LegacyUnexpectedError"
+ x-propr-auth: member
+ x-legacy-error: true
+ /api/tasks:
+ get:
+ operationId: listTasks
+ summary: List task runs
+ description: Newest first. Filter by status, repository or search text, and page with `limit` and `offset`. With `groupBy=task`, paging counts tasks (the pull request or issue a run belongs to) and a page returns every matching run of its tasks.
+ tags:
+ - Tasks
+ security:
+ - sessionCookie: []
+ - bearerAuth: []
+ parameters:
+ - name: status
+ in: query
+ required: false
+ description: "`all` (default), `active`, `waiting`, `attention`, or a worker state."
+ schema:
+ type: string
+ - name: repository
+ in: query
+ required: false
+ description: "`owner/name`, or `all` (default)."
+ schema:
+ type: string
+ - name: search
+ in: query
+ required: false
+ schema:
+ type: string
+ maxLength: 500
+ - name: limit
+ in: query
+ required: false
+ description: Page size; defaults to 50.
+ schema:
+ type: integer
+ minimum: 1
+ maximum: 1000
+ - name: offset
+ in: query
+ required: false
+ schema:
+ type: integer
+ minimum: 0
+ maximum: 1000000
+ - name: forReview
+ in: query
+ required: false
+ description: Only runs whose pull request awaits review.
+ schema:
+ type: string
+ enum:
+ - "true"
+ - "false"
+ - name: excludeMerged
+ in: query
+ required: false
+ schema:
+ type: string
+ enum:
+ - "true"
+ - "false"
+ - name: groupBy
+ in: query
+ required: false
+ description: Page by task instead of by run.
+ schema:
+ type: string
+ const: task
+ - name: task
+ in: query
+ required: false
+ description: "With `groupBy=task`: only the task this run belongs to."
+ schema:
+ type: string
+ - name: assignee
+ in: query
+ required: false
+ description: "`all` (default), `me` (the signed-in user), `unassigned`, or comma-separated GitHub logins."
+ schema:
+ type: string
+ - name: syncAssignees
+ in: query
+ required: false
+ description: "With `task`: refresh that run's assignees from GitHub first."
+ schema:
+ type: string
+ enum:
+ - "true"
+ - "false"
+ responses:
+ "200":
+ description: A page of task runs.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/TaskPage"
+ "400":
+ description: A filter or paging parameter is invalid.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/LegacyError"
+ "401":
+ $ref: "#/components/responses/LegacyUnauthorized"
+ default:
+ $ref: "#/components/responses/LegacyUnexpectedError"
+ x-propr-query-type: ListTasksQuery
+ x-propr-auth: member
+ x-legacy-error: true
+ /api/tasks/revert:
+ post:
+ operationId: postApiTasksRevert
+ summary: POST /api/tasks/revert
+ description: This route is registered but not documented yet.
+ tags:
+ - Tasks
+ security:
+ - sessionCookie: []
+ - bearerAuth: []
+ responses:
+ "200":
+ description: Success. The response body is not documented yet.
+ "401":
+ $ref: "#/components/responses/LegacyUnauthorized"
+ default:
+ $ref: "#/components/responses/LegacyUnexpectedError"
+ x-propr-auth: member
+ x-undocumented: true
+ x-legacy-error: true
+ /api/tasks/revert-preview:
+ get:
+ operationId: getApiTasksRevertPreview
+ summary: GET /api/tasks/revert-preview
+ description: This route is registered but not documented yet.
+ tags:
+ - Tasks
+ security:
+ - sessionCookie: []
+ - bearerAuth: []
+ responses:
+ "200":
+ description: Success. The response body is not documented yet.
+ "401":
+ $ref: "#/components/responses/LegacyUnauthorized"
+ default:
+ $ref: "#/components/responses/LegacyUnexpectedError"
+ x-propr-auth: member
+ x-undocumented: true
+ x-legacy-error: true
+ /api/tasks/{taskId}:
+ delete:
+ operationId: deleteTask
+ summary: Delete a task run and its history
+ description: "When the task, session or correlation id belongs to a goal, only the goal owner can use this route (others get `404`), and only for reads: other methods answer `409` and the goal controls must be used instead."
+ tags:
+ - Tasks
+ security:
+ - sessionCookie: []
+ - bearerAuth: []
+ parameters:
+ - name: taskId
+ in: path
+ required: true
+ description: Task (run) identifier, as returned in `TaskSummary.id`.
+ schema:
+ type: string
+ - name: force
+ in: query
+ required: false
+ description: Delete even when the task is still active.
+ schema:
+ type: string
+ enum:
+ - "true"
+ - "false"
+ responses:
+ "204":
+ description: Deleted.
+ "400":
+ description: The task is still active; retry with `force=true`.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/LegacyError"
+ "401":
+ $ref: "#/components/responses/LegacyUnauthorized"
+ "404":
+ description: Task not found.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/LegacyError"
+ "409":
+ description: The task belongs to a goal; use the goal controls.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/LegacyError"
+ default:
+ $ref: "#/components/responses/LegacyUnexpectedError"
+ x-propr-query-type: DeleteTaskQuery
+ x-propr-auth: member
+ x-legacy-error: true
+ /api/tasks/{taskId}/followup:
+ post:
+ operationId: postTaskFollowup
+ summary: Post a follow-up instruction on a task
+ description: |-
+ Posts the comment on the task's pull request and queues a follow-up run for it. A failed implementation that has no pull request is retried on its issue instead: the comment is posted there and a new issue run is queued.
+
+ When the task, session or correlation id belongs to a goal, only the goal owner can use this route (others get `404`), and only for reads: other methods answer `409` and the goal controls must be used instead.
+ tags:
+ - Tasks
+ security:
+ - sessionCookie: []
+ - bearerAuth: []
+ parameters:
+ - name: taskId
+ in: path
+ required: true
+ description: Task (run) identifier, as returned in `TaskSummary.id`.
+ schema:
+ type: string
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/TaskFollowupRequest"
+ responses:
+ "202":
+ description: The comment was posted; the outcome distinguishes queued, batched, handled, refused, failed and unknown submissions. An issue retry refuses the comment, without posting it, while it restarts an earlier retry whose start was never confirmed; `restartedRequest` reports that restart.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/TaskFollowupResult"
+ "400":
+ description: The comment body or target is invalid, or a pull request command was sent for a task without a pull request.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/LegacyError"
+ "401":
+ $ref: "#/components/responses/LegacyUnauthorized"
+ "404":
+ description: Task not found.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/LegacyError"
+ "409":
+ description: "The issue cannot be retried: the run has not failed, the issue is closed or done, a run is already queued, or an earlier retry may still start."
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/LegacyError"
+ "502":
+ description: The issue's label history could not be read from GitHub, so the retry could not be tied to its trigger and nothing was posted.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/LegacyError"
+ "503":
+ description: The issue retry could not be recorded, so nothing was posted.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/LegacyError"
+ default:
+ $ref: "#/components/responses/LegacyUnexpectedError"
+ x-propr-auth: member
+ x-legacy-error: true
+ /api/tasks/{taskId}/followup-runs:
+ get:
+ operationId: getTaskFollowupRuns
+ summary: Read the runs an issue follow-up started
+ description: |-
+ Read-only. Returns the other runs of the task's repository and issue whose job carried the follow-up's own comment for the signed-in user, so the page that sent it can open the new run. Empty while the run has not started; it never posts or queues anything.
+
+ When the task, session or correlation id belongs to a goal, only the goal owner can use this route (others get `404`), and only for reads: other methods answer `409` and the goal controls must be used instead.
+ tags:
+ - Tasks
+ security:
+ - sessionCookie: []
+ - bearerAuth: []
+ parameters:
+ - name: taskId
+ in: path
+ required: true
+ description: Task (run) identifier, as returned in `TaskSummary.id`.
+ schema:
+ type: string
+ - name: commentId
+ in: query
+ required: true
+ description: The `commentId` the follow-up's receipt returned.
+ schema:
+ type: string
+ pattern: ^[1-9]\d{0,15}$
+ responses:
+ "200":
+ description: The runs found, possibly none yet.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/TaskFollowupRuns"
+ "400":
+ description: The task ID or comment ID is invalid.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/LegacyError"
+ "401":
+ description: Authentication required.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/LegacyError"
+ "404":
+ description: Task not found.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/LegacyError"
+ default:
+ $ref: "#/components/responses/LegacyUnexpectedError"
+ x-propr-query-type: TaskFollowupRunsQuery
+ x-propr-auth: member
+ x-legacy-error: true
+ /api/tasks/{taskId}/review:
+ post:
+ operationId: postTaskReview
+ summary: Request an AI review for a task pull request
+ description: |-
+ Checks the signed-in user’s repository access and PR assignment, dispatches /review through command handling, and returns the same receipt on repeated requests for the source run.
+
+ When the task, session or correlation id belongs to a goal, only the goal owner can use this route (others get `404`), and only for reads: other methods answer `409` and the goal controls must be used instead.
+ tags:
+ - Tasks
+ security:
+ - sessionCookie: []
+ - bearerAuth: []
+ parameters:
+ - name: taskId
+ in: path
+ required: true
+ description: Task (run) identifier, as returned in `TaskSummary.id`.
+ schema:
+ type: string
+ responses:
+ "202":
+ description: Review submission receipt. Only queued or batched is accepted; unknown requires reconciliation.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/TaskReviewResult"
+ "400":
+ description: Invalid task or missing pull request.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/LegacyError"
+ "401":
+ description: Authentication required.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/LegacyError"
+ "403":
+ description: GitHub authorization or PR assignment required.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/LegacyError"
+ "404":
+ description: Task or repository not accessible.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/LegacyError"
+ "409":
+ description: Pull request is closed.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/LegacyError"
+ "503":
+ description: Authorization could not be verified.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/LegacyError"
+ default:
+ $ref: "#/components/responses/LegacyUnexpectedError"
+ x-propr-auth: member
+ x-legacy-error: true
+ /api/usage-tips:
+ get:
+ operationId: getApiUsageTips
+ summary: GET /api/usage-tips
+ description: This route is registered but not documented yet.
+ tags:
+ - Observability
+ security:
+ - sessionCookie: []
+ - bearerAuth: []
+ responses:
+ "200":
+ description: Success. The response body is not documented yet.
+ "401":
+ $ref: "#/components/responses/LegacyUnauthorized"
+ default:
+ $ref: "#/components/responses/LegacyUnexpectedError"
+ x-propr-auth: member
+ x-undocumented: true
+ x-legacy-error: true
+ /api/usage-tips/dismiss:
+ post:
+ operationId: postApiUsageTipsDismiss
+ summary: POST /api/usage-tips/dismiss
+ description: This route is registered but not documented yet.
+ tags:
+ - Observability
+ security:
+ - sessionCookie: []
+ - bearerAuth: []
+ responses:
+ "200":
+ description: Success. The response body is not documented yet.
+ "401":
+ $ref: "#/components/responses/LegacyUnauthorized"
+ default:
+ $ref: "#/components/responses/LegacyUnexpectedError"
+ x-propr-auth: member
+ x-undocumented: true
+ x-legacy-error: true
+ /api/user/repo-preferences:
+ get:
+ operationId: getApiUserRepoPreferences
+ summary: GET /api/user/repo-preferences
+ description: This route is registered but not documented yet.
+ tags:
+ - Repositories
+ security:
+ - sessionCookie: []
+ - bearerAuth: []
+ responses:
+ "200":
+ description: Success. The response body is not documented yet.
+ "401":
+ $ref: "#/components/responses/LegacyUnauthorized"
+ default:
+ $ref: "#/components/responses/LegacyUnexpectedError"
+ x-propr-auth: member
+ x-undocumented: true
+ x-legacy-error: true
+ post:
+ operationId: postApiUserRepoPreferences
+ summary: POST /api/user/repo-preferences
+ description: This route is registered but not documented yet.
+ tags:
+ - Repositories
+ security:
+ - sessionCookie: []
+ - bearerAuth: []
+ responses:
+ "200":
+ description: Success. The response body is not documented yet.
+ "401":
+ $ref: "#/components/responses/LegacyUnauthorized"
+ default:
+ $ref: "#/components/responses/LegacyUnexpectedError"
+ x-propr-auth: member
+ x-undocumented: true
+ x-legacy-error: true
+ /api/voice/briefing:
+ get:
+ operationId: getApiVoiceBriefing
+ summary: GET /api/voice/briefing
+ description: This route is registered but not documented yet.
+ tags:
+ - Voice
+ security:
+ - sessionCookie: []
+ - bearerAuth: []
+ responses:
+ "200":
+ description: Success. The response body is not documented yet.
+ "401":
+ $ref: "#/components/responses/LegacyUnauthorized"
+ default:
+ $ref: "#/components/responses/LegacyUnexpectedError"
+ x-propr-auth: member
+ x-undocumented: true
+ x-legacy-error: true
+ /api/voice/capabilities:
+ get:
+ operationId: getApiVoiceCapabilities
+ summary: GET /api/voice/capabilities
+ description: This route is registered but not documented yet.
+ tags:
+ - Voice
+ security:
+ - sessionCookie: []
+ - bearerAuth: []
+ responses:
+ "200":
+ description: Success. The response body is not documented yet.
+ "401":
+ $ref: "#/components/responses/LegacyUnauthorized"
+ default:
+ $ref: "#/components/responses/LegacyUnexpectedError"
+ x-propr-auth: member
+ x-undocumented: true
+ x-legacy-error: true
+ /authorize:
+ get:
+ operationId: authorizeMcpClient
+ summary: Start an MCP OAuth authorization
+ description: OAuth 2.1 authorization endpoint with PKCE (`S256`). Takes `response_type=code`, `client_id`, `redirect_uri` (must match a registered URI exactly), `code_challenge`, `code_challenge_method`, and optionally `scope`, `state` and `resource` as query parameters, then redirects the browser to the consent page. Served by the MCP SDK's OAuth router at the API origin root, and only while MCP is enabled. See the MCP guide (docs/features/mcp).
+ tags:
+ - MCP
+ security: []
+ responses:
+ "302":
+ description: Redirect to the consent page, or to `redirect_uri` with an OAuth error.
+ "400":
+ description: Unknown client or a `redirect_uri` that is not registered.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/LegacyError"
+ "404":
+ description: MCP is disabled on this instance.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/LegacyError"
+ default:
+ $ref: "#/components/responses/LegacyUnexpectedError"
+ x-propr-auth: public
+ x-legacy-error: true
+ post:
+ operationId: authorizeMcpClientForm
+ summary: Start an MCP OAuth authorization (form post)
+ description: Same as `GET /authorize`, with the parameters in an `application/x-www-form-urlencoded` body. Served by the MCP SDK's OAuth router at the API origin root, and only while MCP is enabled. See the MCP guide (docs/features/mcp).
+ tags:
+ - MCP
+ security: []
+ responses:
+ "302":
+ description: Redirect to the consent page, or to `redirect_uri` with an OAuth error.
+ "400":
+ description: Unknown client or a `redirect_uri` that is not registered.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/LegacyError"
+ "404":
+ description: MCP is disabled on this instance.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/LegacyError"
+ default:
+ $ref: "#/components/responses/LegacyUnexpectedError"
+ x-propr-auth: public
+ x-legacy-error: true
+ /health:
+ get:
+ operationId: getHealth
+ summary: Liveness probe
+ tags:
+ - System
+ security: []
+ responses:
+ "200":
+ description: The API process is serving requests.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/Health"
+ default:
+ $ref: "#/components/responses/LegacyUnexpectedError"
+ x-propr-auth: public
+ x-legacy-error: true
+ /mcp/apps:
+ get:
+ operationId: getMcpApps
+ summary: GET /mcp/apps
+ description: This route is registered but not documented yet.
+ tags:
+ - MCP
+ security:
+ - sessionCookie: []
+ responses:
+ "200":
+ description: Success. The response body is not documented yet.
+ default:
+ $ref: "#/components/responses/LegacyUnexpectedError"
+ x-propr-auth: browserSession
+ x-undocumented: true
+ x-legacy-error: true
+ /mcp/apps/revoke:
+ post:
+ operationId: postMcpAppsRevoke
+ summary: POST /mcp/apps/revoke
+ description: This route is registered but not documented yet.
+ tags:
+ - MCP
+ security:
+ - sessionCookie: []
+ responses:
+ "200":
+ description: Success. The response body is not documented yet.
+ default:
+ $ref: "#/components/responses/LegacyUnexpectedError"
+ x-propr-auth: browserSession
+ x-undocumented: true
+ x-legacy-error: true
+ /mcp/artifacts/{id}:
+ get:
+ operationId: getMcpArtifactsId
+ summary: GET /mcp/artifacts/{id}
+ description: This route is registered but not documented yet.
+ tags:
+ - MCP
+ security:
+ - sessionCookie: []
+ parameters:
+ - name: id
+ in: path
+ required: true
+ schema:
+ type: string
+ responses:
+ "200":
+ description: Success. The response body is not documented yet.
+ default:
+ $ref: "#/components/responses/LegacyUnexpectedError"
+ x-propr-auth: browserSession
+ x-undocumented: true
+ x-legacy-error: true
+ /mcp/consent:
+ get:
+ operationId: getMcpConsent
+ summary: GET /mcp/consent
+ description: This route is registered but not documented yet.
+ tags:
+ - MCP
+ security:
+ - sessionCookie: []
+ responses:
+ "200":
+ description: Success. The response body is not documented yet.
+ default:
+ $ref: "#/components/responses/LegacyUnexpectedError"
+ x-propr-auth: browserSession
+ x-undocumented: true
+ x-legacy-error: true
+ post:
+ operationId: postMcpConsent
+ summary: POST /mcp/consent
+ description: This route is registered but not documented yet.
+ tags:
+ - MCP
+ security:
+ - sessionCookie: []
+ responses:
+ "200":
+ description: Success. The response body is not documented yet.
+ default:
+ $ref: "#/components/responses/LegacyUnexpectedError"
+ x-propr-auth: browserSession
+ x-undocumented: true
+ x-legacy-error: true
+ /register:
+ post:
+ operationId: registerMcpClient
+ summary: Register an MCP OAuth client
+ description: "RFC 7591 dynamic client registration. Clients register as public clients (`token_endpoint_auth_method: none`). Served by the MCP SDK's OAuth router at the API origin root, and only while MCP is enabled. See the MCP guide (docs/features/mcp)."
+ tags:
+ - MCP
+ security: []
+ requestBody:
+ required: true
+ description: RFC 7591 client metadata, for example `client_name` and `redirect_uris`.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/JsonObject"
+ responses:
+ "201":
+ description: The registered client, including its `client_id`.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/JsonObject"
+ "400":
+ description: RFC 7591 error, for example `invalid_client_metadata`.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/LegacyError"
+ "404":
+ description: MCP is disabled on this instance.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/LegacyError"
+ default:
+ $ref: "#/components/responses/LegacyUnexpectedError"
+ x-propr-auth: public
+ x-legacy-error: true
+ /revoke:
+ post:
+ operationId: revokeMcpToken
+ summary: Revoke an MCP access or refresh token
+ description: RFC 7009 revocation. The `application/x-www-form-urlencoded` body carries `token`, optionally `token_type_hint`, and `client_id`. Served by the MCP SDK's OAuth router at the API origin root, and only while MCP is enabled. See the MCP guide (docs/features/mcp).
+ tags:
+ - MCP
+ security: []
+ responses:
+ "200":
+ description: The token is revoked, or was not valid. The body is `{}`.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/JsonObject"
+ "400":
+ description: RFC 7009 error.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/LegacyError"
+ "404":
+ description: MCP is disabled on this instance.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/LegacyError"
+ default:
+ $ref: "#/components/responses/LegacyUnexpectedError"
+ x-propr-auth: public
+ x-legacy-error: true
+ /token:
+ post:
+ operationId: issueMcpToken
+ summary: Exchange an authorization code or refresh token
+ description: "OAuth 2.1 token endpoint for public clients (`token_endpoint_auth_method: none`). The `application/x-www-form-urlencoded` body carries `grant_type` (`authorization_code` with `code`, `code_verifier` and `redirect_uri`, or `refresh_token` with `refresh_token`), `client_id` and optionally `resource`. Served by the MCP SDK's OAuth router at the API origin root, and only while MCP is enabled. See the MCP guide (docs/features/mcp)."
+ tags:
+ - MCP
+ security: []
+ responses:
+ "200":
+ description: "RFC 6749 token response: `access_token`, `token_type`, `expires_in`, `scope` and `refresh_token`."
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/JsonObject"
+ "400":
+ description: RFC 6749 error, for example `invalid_grant`.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/LegacyError"
+ "404":
+ description: MCP is disabled on this instance.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/LegacyError"
+ default:
+ $ref: "#/components/responses/LegacyUnexpectedError"
+ x-propr-auth: public
+ x-legacy-error: true
+ /webhook:
+ post:
+ operationId: receiveGitHubWebhook
+ summary: Receive a GitHub webhook delivery
+ description: Registered only when `GITHUB_EVENT_INTAKE_MODE=direct_webhook`. Deliveries must be signed with `GH_WEBHOOK_SECRET`.
+ tags:
+ - Webhooks
+ security:
+ - webhookSignature: []
+ parameters:
+ - name: X-GitHub-Event
+ in: header
+ required: true
+ description: GitHub event name.
+ schema:
+ type: string
+ - name: X-GitHub-Delivery
+ in: header
+ required: true
+ description: GitHub delivery identifier.
+ schema:
+ type: string
+ requestBody:
+ required: true
+ description: The GitHub event payload.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/JsonObject"
+ responses:
+ "200":
+ description: Accepted.
+ content:
+ text/plain:
+ schema:
+ type: string
+ "401":
+ description: Missing or invalid signature.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/LegacyError"
+ default:
+ $ref: "#/components/responses/LegacyUnexpectedError"
+ x-propr-auth: webhook
+ x-legacy-error: true
+components:
+ schemas:
+ AgentRunAdmissionAgent:
+ type: object
+ properties:
+ alias:
+ type: string
+ description: Direct agent alias (never a synthetic alias).
+ ceiling:
+ anyOf:
+ - type: integer
+ exclusiveMinimum: 0
+ - type: "null"
+ description: PROPR_AGENT_RUN_ALIAS_LIMITS entry; null = global limit only.
+ inUse:
+ type: integer
+ minimum: 0
+ description: "What the ceiling is compared with: holders plus nested executions counted against it."
+ holders:
+ $ref: "#/components/schemas/AgentRunAdmissionStateCounts"
+ nested:
+ type: object
+ properties:
+ total:
+ type: integer
+ minimum: 0
+ chargedUnderParent:
+ type: integer
+ minimum: 0
+ countedAgainstCeiling:
+ type: integer
+ minimum: 0
+ required:
+ - total
+ - chargedUnderParent
+ - countedAgainstCeiling
+ additionalProperties: false
+ waiting:
+ $ref: "#/components/schemas/AgentRunAdmissionWaitingCounts"
+ deferred:
+ type: integer
+ minimum: 0
+ description: Queue jobs delayed until this agent has capacity.
+ tasks:
+ type: array
+ items:
+ $ref: "#/components/schemas/AgentRunAdmissionTask"
+ required:
+ - alias
+ - ceiling
+ - inUse
+ - holders
+ - nested
+ - waiting
+ - deferred
+ - tasks
+ additionalProperties: false
+ description: Admission state of one direct agent.
+ AgentRunAdmissionConfiguration:
+ type: object
+ properties:
+ status:
+ type: string
+ enum:
+ - current
+ - uninitialized
+ - pending_generation
+ - mismatch
+ - missing
+ description: 'current: compatible protocol; the stored limit and generation equal the configured ones. uninitialized: nothing stored yet; the first admission stores the configured ones. pending_generation: compatible protocol and the configured generation is higher; the next admission attempt fences and removes every older reservation, then switches. mismatch: incompatible protocol, same generation with another limit, a lower generation, or an unreadable stored record; every launch is refused. A higher generation resolves limit/generation mismatches only within a compatible protocol. For incompatible protocols, draining and raising the generation are insufficient: follow "Rolling restarts with mixed versions" in the agent-run capacity operations docs. Drain with the existing version, stop every execution process, confirm Redis holders and physical Docker work are empty, then reset only the admission configuration key; never clear holder records to force an upgrade. missing: nothing stored while holder records remain; every launch is refused.'
+ admitting:
+ type: boolean
+ description: "False when admission refuses every launch with the configured limit and generation: `limit` minus `inUse` is then not usable capacity."
+ stored:
+ anyOf:
+ - type: object
+ properties:
+ limit:
+ type: integer
+ exclusiveMinimum: 0
+ generation:
+ type: integer
+ minimum: 0
+ required:
+ - limit
+ - generation
+ additionalProperties: false
+ - type: "null"
+ description: Limit and generation of the stored admission configuration; null when none (or none readable) is stored.
+ required:
+ - status
+ - admitting
+ - stored
+ additionalProperties: false
+ description: The stored admission configuration compared with the configured global limit and generation. The admission protocol prefix is checked; the Docker daemon identity is neither shown nor compared.
+ AgentRunAdmissionReport:
+ type: object
+ properties:
+ asOf:
+ type: string
+ format: date-time
+ admission:
+ type: object
+ properties:
+ enabled:
+ type: boolean
+ agentCeilingsConfigured:
+ type: boolean
+ global:
+ anyOf:
+ - type: object
+ properties:
+ limit:
+ type: integer
+ exclusiveMinimum: 0
+ description: Global limit configured on the answering process; enforced only while `configuration.admitting`.
+ limitSource:
+ type: string
+ enum:
+ - PROPR_AGENT_RUN_LIMIT
+ - hosted_entitlement
+ generation:
+ type: integer
+ minimum: 0
+ description: Admission generation configured on the answering process (PROPR_AGENT_RUN_GENERATION, default 0).
+ configuration:
+ $ref: "#/components/schemas/AgentRunAdmissionConfiguration"
+ nestedLimit:
+ type: integer
+ exclusiveMinimum: 0
+ nestedLimitSource:
+ type: string
+ enum:
+ - PROPR_AGENT_RUN_NESTED_LIMIT
+ - default
+ inUse:
+ type: integer
+ minimum: 0
+ description: Top-level executions; each takes one global slot.
+ holders:
+ $ref: "#/components/schemas/AgentRunAdmissionStateCounts"
+ unattributedHolders:
type: integer
minimum: 0
nested:
@@ -10313,61 +11074,157 @@ components:
cancelledAt:
type: string
format: date-time
- required:
- - status
- - cancelledAt
- additionalProperties: false
- description: The provisional instance token was revoked.
- DesktopPairingPending:
- type: object
- properties:
- status:
- type: string
- const: pending
+ required:
+ - status
+ - cancelledAt
+ additionalProperties: false
+ description: The provisional instance token was revoked.
+ DesktopPairingPending:
+ type: object
+ properties:
+ status:
+ type: string
+ const: pending
+ interval:
+ type: integer
+ minimum: 1
+ maximum: 60
+ description: Seconds to wait before the next poll.
+ required:
+ - status
+ - interval
+ additionalProperties: false
+ description: The pairing is not approved yet (HTTP 202).
+ DesktopPairingPoll:
+ anyOf:
+ - $ref: "#/components/schemas/DesktopPairingPending"
+ - $ref: "#/components/schemas/DesktopPairingProvisional"
+ description: State of a pairing, by `status`.
+ DesktopPairingPollRequest:
+ type: object
+ properties:
+ deviceSecret:
+ type: string
+ pattern: ^[A-Za-z0-9_-]{43}$
+ required:
+ - deviceSecret
+ additionalProperties: false
+ DesktopPairingProvisional:
+ type: object
+ properties:
+ status:
+ type: string
+ const: provisional
+ token:
+ type: string
+ pattern: ^propr_it_[A-Za-z0-9_-]{43}$
+ description: Instance token. It works only after the pairing is activated.
+ tokenType:
+ type: string
+ const: Bearer
+ activationTicket:
+ type: string
+ pattern: ^[A-Za-z0-9_-]{43}$
+ activationExpiresAt:
+ type: string
+ format: date-time
+ description: Activate or cancel before this time.
+ instanceId:
+ type: string
+ pattern: ^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$
+ description: "`publicInstanceIdentity.instanceId` from discovery."
+ origin:
+ type: string
+ description: Canonical API origin the client discovered, for example `https://propr.example.com`.
+ scope:
+ type: string
+ const: desktop-instance
+ credentialGeneration:
+ type: string
+ pattern: ^[A-Za-z0-9_-]{22}$
+ description: Client-chosen generation that identifies this credential.
+ required:
+ - status
+ - token
+ - tokenType
+ - activationTicket
+ - activationExpiresAt
+ - instanceId
+ - origin
+ - scope
+ - credentialGeneration
+ additionalProperties: false
+ description: The pairing was approved and a provisional instance token was issued (HTTP 200).
+ DesktopPairingStart:
+ type: object
+ properties:
+ pairingId:
+ type: string
+ pattern: ^dpr_[A-Za-z0-9_-]{22}$
+ deviceSecret:
+ type: string
+ pattern: ^[A-Za-z0-9_-]{43}$
+ description: Secret the client presents on every later pairing request. Never shown to the browser.
+ approvalUrl:
+ type: string
+ description: Same-origin URL the user opens to approve the pairing.
+ expiresAt:
+ type: string
+ format: date-time
+ description: The pairing expires at this time, at most 30 minutes after it started.
interval:
type: integer
minimum: 1
maximum: 60
- description: Seconds to wait before the next poll.
+ description: Seconds to wait between polls.
required:
- - status
+ - pairingId
+ - deviceSecret
+ - approvalUrl
+ - expiresAt
- interval
additionalProperties: false
- description: The pairing is not approved yet (HTTP 202).
- DesktopPairingPoll:
- anyOf:
- - $ref: "#/components/schemas/DesktopPairingPending"
- - $ref: "#/components/schemas/DesktopPairingProvisional"
- description: State of a pairing, by `status`.
- DesktopPairingPollRequest:
+ description: A started pairing.
+ DesktopPairingStartRequest:
type: object
properties:
- deviceSecret:
+ clientName:
type: string
- pattern: ^[A-Za-z0-9_-]{43}$
+ minLength: 1
+ maxLength: 80
+ description: Shown on the approval page; 1 to 80 printable characters.
+ instanceId:
+ type: string
+ pattern: ^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$
+ description: "`publicInstanceIdentity.instanceId` from discovery."
+ origin:
+ type: string
+ description: Canonical API origin the client discovered, for example `https://propr.example.com`.
+ scope:
+ type: string
+ const: desktop-instance
+ credentialGeneration:
+ type: string
+ pattern: ^[A-Za-z0-9_-]{22}$
+ description: Client-chosen generation that identifies this credential.
required:
- - deviceSecret
+ - clientName
+ - instanceId
+ - origin
+ - scope
+ - credentialGeneration
additionalProperties: false
- DesktopPairingProvisional:
+ description: Starts a pairing for the instance named by the binding fields.
+ DesktopPairingTicket:
type: object
properties:
- status:
- type: string
- const: provisional
- token:
- type: string
- pattern: ^propr_it_[A-Za-z0-9_-]{43}$
- description: Instance token. It works only after the pairing is activated.
- tokenType:
+ deviceSecret:
type: string
- const: Bearer
+ pattern: ^[A-Za-z0-9_-]{43}$
activationTicket:
type: string
pattern: ^[A-Za-z0-9_-]{43}$
- activationExpiresAt:
- type: string
- format: date-time
- description: Activate or cancel before this time.
+ description: "`activationTicket` from the provisional poll response."
instanceId:
type: string
pattern: ^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$
@@ -10383,1840 +11240,3219 @@ components:
pattern: ^[A-Za-z0-9_-]{22}$
description: Client-chosen generation that identifies this credential.
required:
- - status
- - token
- - tokenType
+ - deviceSecret
- activationTicket
- - activationExpiresAt
- instanceId
- origin
- scope
- credentialGeneration
additionalProperties: false
- description: The pairing was approved and a provisional instance token was issued (HTTP 200).
- DesktopPairingStart:
+ description: Proof of a provisional pairing, used to activate or cancel it.
+ EpicQueueActionError:
+ type: object
+ properties:
+ error:
+ type: string
+ code:
+ type: string
+ enum:
+ - NOT_FOUND
+ - STALE_EXECUTION
+ - IN_FLIGHT
+ - ALREADY_DONE
+ - STATE_CHANGED
+ - NOT_BLOCKED
+ - NOT_RETRYABLE
+ required:
+ - error
+ - code
+ additionalProperties: false
+ description: A refused queue action.
+ EpicQueueActionResult:
+ type: object
+ properties:
+ outcome:
+ type: string
+ enum:
+ - skipped
+ - already_skipped
+ - retried
+ - already_retried
+ executionId:
+ type: string
+ issueNumber:
+ type: integer
+ exclusiveMinimum: 0
+ queue:
+ anyOf:
+ - type: object
+ properties: {}
+ additionalProperties: {}
+ - type: "null"
+ description: The execution queue after the action.
+ required:
+ - outcome
+ - executionId
+ - issueNumber
+ - queue
+ additionalProperties: false
+ EpicQueueBlockedEvent:
+ type: object
+ properties:
+ id:
+ type: integer
+ exclusiveMinimum: 0
+ description: Monotonic event id; also the list cursor.
+ draftId:
+ type: string
+ description: The plan.
+ executionId:
+ type: string
+ issueNumber:
+ type: integer
+ exclusiveMinimum: 0
+ position:
+ type: integer
+ minimum: 0
+ description: "The occurrence: a requeued issue starts a new one at a new position."
+ type:
+ type: string
+ enum:
+ - blocked
+ - resolved
+ blockedKind:
+ type: string
+ enum:
+ - closed_unmerged
+ - missing_from_plan
+ - dispatch_failed
+ - agent_blocked
+ - awaiting_human_merge
+ - label_handoff
+ - setup_not_ready
+ reason:
+ type: string
+ blockedEventId:
+ anyOf:
+ - type: integer
+ exclusiveMinimum: 0
+ - type: "null"
+ description: "For a resolved event: the blocked event it resolves."
+ resolution:
+ anyOf:
+ - type: string
+ enum:
+ - cleared
+ - replaced
+ - retried
+ - skipped
+ - settled
+ - type: "null"
+ description: "How the blocker ended: cleared by the queue, replaced by another blocker, retried or skipped by a person, or settled with the issue."
+ createdAt:
+ type: integer
+ description: Epoch milliseconds.
+ required:
+ - id
+ - draftId
+ - executionId
+ - issueNumber
+ - position
+ - type
+ - blockedKind
+ - reason
+ - blockedEventId
+ - resolution
+ - createdAt
+ additionalProperties: false
+ description: One blocked transition of an issue in an epic execution, or the resolution of one.
+ EpicQueueBlockedEventPage:
+ type: object
+ properties:
+ executionId:
+ description: The listed execution; null when the plan never queued work.
+ type:
+ - string
+ - "null"
+ events:
+ type: array
+ items:
+ $ref: "#/components/schemas/EpicQueueBlockedEvent"
+ nextCursor:
+ description: Pass as `cursor` to read the events after this page; unchanged when the page is empty.
+ type:
+ - string
+ - "null"
+ hasMore:
+ type: boolean
+ required:
+ - executionId
+ - events
+ - nextCursor
+ - hasMore
+ additionalProperties: false
+ EpicQueueRetryRequest:
+ type: object
+ properties:
+ executionId:
+ type: string
+ description: The execution read from the execution queue; a newer or finished one refuses the retry.
+ expectedState:
+ description: Refuse with `STATE_CHANGED` unless the issue is still in this state.
+ type: string
+ enum:
+ - pending
+ - dispatched
+ - done
+ - blocked
+ - skipped
+ required:
+ - executionId
+ additionalProperties: false
+ EpicQueueSkipRequest:
+ type: object
+ properties:
+ executionId:
+ type: string
+ description: The execution read from the execution queue; a newer or finished one refuses the skip.
+ expectedState:
+ description: Refuse with `STATE_CHANGED` unless the issue is still in this state.
+ type: string
+ enum:
+ - pending
+ - dispatched
+ - done
+ - blocked
+ - skipped
+ satisfiesDependents:
+ description: "Graph executions: treat the skipped issue as a met prerequisite, so its dependents can start. Skipping again with the other value changes only this option."
+ type: boolean
+ required:
+ - executionId
+ additionalProperties: false
+ ErrorEnvelope:
type: object
properties:
- pairingId:
- type: string
- pattern: ^dpr_[A-Za-z0-9_-]{22}$
- deviceSecret:
+ code:
type: string
- pattern: ^[A-Za-z0-9_-]{43}$
- description: Secret the client presents on every later pairing request. Never shown to the browser.
- approvalUrl:
+ description: Stable, machine-readable error code, for example `TASK_NOT_FOUND`.
+ message:
type: string
- description: Same-origin URL the user opens to approve the pairing.
- expiresAt:
+ description: Human-readable explanation that is safe to show to an operator.
+ hint:
+ description: Optional next step that resolves the error.
type: string
- format: date-time
- description: The pairing expires at this time, at most 30 minutes after it started.
- interval:
- type: integer
- minimum: 1
- maximum: 60
- description: Seconds to wait between polls.
required:
- - pairingId
- - deviceSecret
- - approvalUrl
- - expiresAt
- - interval
+ - code
+ - message
+ additionalProperties: false
+ description: The common error envelope. New routes return it for every 4xx and 5xx response.
+ ExtensionItemEnabledRequest:
+ type: object
+ properties:
+ item:
+ anyOf:
+ - type: object
+ properties:
+ kind:
+ type: string
+ const: server
+ name:
+ type: string
+ required:
+ - kind
+ - name
+ additionalProperties: false
+ - type: object
+ properties:
+ kind:
+ type: string
+ const: skill
+ bundleId:
+ type: string
+ format: uuid
+ pattern: ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$
+ required:
+ - kind
+ - bundleId
+ additionalProperties: false
+ enabled:
+ type: boolean
+ required:
+ - item
+ - enabled
+ additionalProperties: false
+ ExtensionLibraryInput:
+ type: object
+ properties:
+ version:
+ type: number
+ const: 1
+ servers:
+ maxItems: 32
+ type: array
+ items:
+ oneOf:
+ - type: object
+ properties:
+ kind:
+ type: string
+ const: https
+ name:
+ type: string
+ pattern: ^[a-z][a-z0-9_-]{0,47}$
+ url:
+ type: string
+ maxLength: 2048
+ enabled:
+ type: boolean
+ tools:
+ maxItems: 64
+ type: array
+ items:
+ type: string
+ pattern: ^[A-Za-z0-9_.-]{1,128}$
+ capability:
+ type: string
+ enum:
+ - read
+ - write
+ authentication:
+ type: string
+ enum:
+ - none
+ - bearer
+ - oauth
+ scopes:
+ maxItems: 32
+ type: array
+ items:
+ type: string
+ pattern: ^[\x21\x23-\x5b\x5d-\x7e]{1,256}$
+ clientId:
+ type: string
+ pattern: ^[\x21-\x7e]{1,512}$
+ repositories:
+ default: []
+ maxItems: 256
+ type: array
+ items:
+ type: string
+ pattern: ^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$
+ agents:
+ default: []
+ maxItems: 64
+ type: array
+ items:
+ type: string
+ pattern: ^[A-Za-z0-9_-]{1,100}$
+ autoApply:
+ default: true
+ type: boolean
+ managedBy:
+ type: object
+ properties:
+ plugin:
+ type: string
+ maxLength: 48
+ pattern: ^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$
+ item:
+ type: string
+ pattern: ^[a-z][a-z0-9_-]{0,47}$
+ active:
+ type: boolean
+ registrations:
+ minItems: 1
+ maxItems: 3
+ type: array
+ items:
+ type: string
+ enum:
+ - metadata_document
+ - dynamic
+ - preregistered
+ required:
+ - plugin
+ - item
+ - active
+ additionalProperties: false
+ required:
+ - kind
+ - name
+ - url
+ - enabled
+ - tools
+ - capability
+ - authentication
+ - repositories
+ - agents
+ - autoApply
+ additionalProperties: false
+ - type: object
+ properties:
+ kind:
+ type: string
+ const: catalog
+ name:
+ type: string
+ pattern: ^[a-z][a-z0-9_-]{0,47}$
+ catalogId:
+ type: string
+ pattern: ^[a-z][a-z0-9_-]{0,63}$
+ digest:
+ type: string
+ pattern: ^[0-9a-f]{64}$
+ enabled:
+ type: boolean
+ tools:
+ maxItems: 64
+ type: array
+ items:
+ type: string
+ pattern: ^[A-Za-z0-9_.-]{1,128}$
+ capability:
+ type: string
+ enum:
+ - read
+ - write
+ repositories:
+ default: []
+ maxItems: 256
+ type: array
+ items:
+ type: string
+ pattern: ^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$
+ agents:
+ default: []
+ maxItems: 64
+ type: array
+ items:
+ type: string
+ pattern: ^[A-Za-z0-9_-]{1,100}$
+ autoApply:
+ default: true
+ type: boolean
+ managedBy:
+ type: object
+ properties:
+ plugin:
+ type: string
+ maxLength: 48
+ pattern: ^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$
+ item:
+ type: string
+ pattern: ^[a-z][a-z0-9_-]{0,47}$
+ active:
+ type: boolean
+ registrations:
+ minItems: 1
+ maxItems: 3
+ type: array
+ items:
+ type: string
+ enum:
+ - metadata_document
+ - dynamic
+ - preregistered
+ required:
+ - plugin
+ - item
+ - active
+ additionalProperties: false
+ required:
+ - kind
+ - name
+ - catalogId
+ - digest
+ - enabled
+ - tools
+ - capability
+ - repositories
+ - agents
+ - autoApply
+ additionalProperties: false
+ - type: object
+ properties:
+ name:
+ type: string
+ pattern: ^[a-z][a-z0-9_-]{0,47}$
+ enabled:
+ type: boolean
+ tools:
+ maxItems: 64
+ type: array
+ items:
+ type: string
+ pattern: ^[A-Za-z0-9_.-]{1,128}$
+ capability:
+ type: string
+ enum:
+ - read
+ - write
+ kind:
+ type: string
+ const: package
+ ecosystem:
+ type: string
+ enum:
+ - npm
+ package:
+ type: string
+ maxLength: 214
+ pattern: ^(@[a-z0-9][a-z0-9._~-]*\/)?[a-z0-9][a-z0-9._~-]*$
+ version:
+ type: string
+ maxLength: 256
+ pattern: ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$
+ integrity:
+ type: string
+ pattern: ^sha512-[A-Za-z0-9+/]{86}==$
+ bin:
+ type: string
+ pattern: ^[A-Za-z0-9@._-]{1,128}$
+ args:
+ default: []
+ maxItems: 64
+ type: array
+ items:
+ type: string
+ maxLength: 2048
+ pattern: ^[^\x00\r\n]*$
+ env:
+ default: []
+ maxItems: 32
+ type: array
+ items:
+ type: object
+ properties:
+ name:
+ type: string
+ pattern: ^[A-Z_][A-Z0-9_]{0,99}$
+ value:
+ type: string
+ maxLength: 4096
+ pattern: ^[^\x00\r\n]*$
+ required:
+ - name
+ - value
+ additionalProperties: false
+ secretEnv:
+ default: []
+ maxItems: 16
+ type: array
+ items:
+ type: string
+ pattern: ^[A-Z_][A-Z0-9_]{0,99}$
+ repositories:
+ default: []
+ maxItems: 256
+ type: array
+ items:
+ type: string
+ pattern: ^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$
+ agents:
+ default: []
+ maxItems: 64
+ type: array
+ items:
+ type: string
+ pattern: ^[A-Za-z0-9_-]{1,100}$
+ autoApply:
+ default: true
+ type: boolean
+ managedBy:
+ type: object
+ properties:
+ plugin:
+ type: string
+ maxLength: 48
+ pattern: ^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$
+ item:
+ type: string
+ pattern: ^[a-z][a-z0-9_-]{0,47}$
+ active:
+ type: boolean
+ registrations:
+ minItems: 1
+ maxItems: 3
+ type: array
+ items:
+ type: string
+ enum:
+ - metadata_document
+ - dynamic
+ - preregistered
+ required:
+ - plugin
+ - item
+ - active
+ additionalProperties: false
+ required:
+ - name
+ - enabled
+ - tools
+ - capability
+ - kind
+ - ecosystem
+ - package
+ - version
+ - args
+ - env
+ - secretEnv
+ - repositories
+ - agents
+ - autoApply
+ additionalProperties: false
+ skills:
+ maxItems: 32
+ type: array
+ items:
+ type: object
+ properties:
+ bundleId:
+ type: string
+ format: uuid
+ pattern: ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$
+ enabled:
+ type: boolean
+ repositories:
+ default: []
+ maxItems: 256
+ type: array
+ items:
+ type: string
+ pattern: ^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$
+ agents:
+ default: []
+ maxItems: 64
+ type: array
+ items:
+ type: string
+ pattern: ^[A-Za-z0-9_-]{1,100}$
+ autoApply:
+ default: true
+ type: boolean
+ managedBy:
+ type: object
+ properties:
+ plugin:
+ type: string
+ maxLength: 48
+ pattern: ^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$
+ item:
+ type: string
+ pattern: ^[a-z][a-z0-9_-]{0,47}$
+ active:
+ type: boolean
+ registrations:
+ minItems: 1
+ maxItems: 3
+ type: array
+ items:
+ type: string
+ enum:
+ - metadata_document
+ - dynamic
+ - preregistered
+ required:
+ - plugin
+ - item
+ - active
+ additionalProperties: false
+ required:
+ - bundleId
+ - enabled
+ - repositories
+ - agents
+ - autoApply
+ additionalProperties: false
+ required:
+ - version
+ - servers
+ - skills
additionalProperties: false
- description: A started pairing.
- DesktopPairingStartRequest:
+ description: "Strict shared v1 library contract: up to 32 servers and 32 skill bundle references. Each item has enabled, optional repositories (owner/repo; empty means every repository) and agents (aliases; empty means every agent) limits, and autoApply (default true; false applies the item only where an automation adds it). Items an installed plugin contributes carry managedBy (plugin id, manifest item name, active, and for OAuth servers the client registration methods the plugin permits at sign-in); their definition is read-only and only enabled, the limits and autoApply can change, and they cannot be added or removed through this route. Operator catalog descriptors accept only immutable catalogId/digest and selected tools/capability; customer commands, arguments, environment and mount paths are rejected."
+ ExtensionLibraryRead:
type: object
properties:
- clientName:
- type: string
- minLength: 1
- maxLength: 80
- description: Shown on the approval page; 1 to 80 printable characters.
+ contract:
+ type: object
+ properties: {}
+ additionalProperties: {}
instanceId:
type: string
- pattern: ^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$
- description: "`publicInstanceIdentity.instanceId` from discovery."
- origin:
- type: string
- description: Canonical API origin the client discovered, for example `https://propr.example.com`.
- scope:
- type: string
- const: desktop-instance
- credentialGeneration:
- type: string
- pattern: ^[A-Za-z0-9_-]{22}$
- description: Client-chosen generation that identifies this credential.
+ library:
+ type: object
+ properties:
+ version:
+ type: number
+ const: 1
+ instanceId:
+ type: string
+ revision:
+ type: integer
+ description: 0 before the first save.
+ createdAt:
+ type: number
+ updatedAt:
+ type: number
+ auditActor:
+ type: string
+ servers:
+ type: array
+ items:
+ type: object
+ properties: {}
+ additionalProperties: {}
+ skills:
+ type: array
+ items:
+ type: object
+ properties: {}
+ additionalProperties: {}
+ secrets:
+ type: object
+ propertyNames:
+ type: string
+ additionalProperties:
+ type: object
+ properties:
+ configured:
+ type: boolean
+ required:
+ - configured
+ additionalProperties: {}
+ description: Presence markers only; secret values are never returned.
+ skillBundles:
+ type: array
+ items:
+ type: object
+ properties: {}
+ additionalProperties: {}
+ required:
+ - version
+ - instanceId
+ - revision
+ - createdAt
+ - updatedAt
+ - auditActor
+ - servers
+ - skills
+ - secrets
+ - skillBundles
+ additionalProperties: {}
+ oauth:
+ $ref: "#/components/schemas/ExtensionOAuthInstanceInfo"
+ bundles:
+ type: array
+ items:
+ type: object
+ properties: {}
+ additionalProperties: {}
+ catalog:
+ type: array
+ items:
+ type: object
+ properties: {}
+ additionalProperties: {}
+ usage:
+ type: object
+ properties:
+ automations:
+ type: array
+ items:
+ type: object
+ properties:
+ id:
+ type: string
+ name:
+ type: string
+ servers:
+ type: array
+ items:
+ type: string
+ skills:
+ type: array
+ items:
+ type: string
+ required:
+ - id
+ - name
+ - servers
+ - skills
+ additionalProperties: false
+ importedFrom:
+ type: object
+ properties:
+ servers:
+ type: object
+ propertyNames:
+ type: string
+ additionalProperties:
+ type: array
+ items:
+ type: string
+ skills:
+ type: object
+ propertyNames:
+ type: string
+ additionalProperties:
+ type: array
+ items:
+ type: string
+ required:
+ - servers
+ - skills
+ additionalProperties: false
+ required:
+ - automations
+ - importedFrom
+ additionalProperties: false
+ description: Automations that add library items explicitly (server names and skill bundle ids), and the names of the extension profiles that migrated items came from.
required:
- - clientName
+ - contract
- instanceId
- - origin
- - scope
- - credentialGeneration
+ - library
+ - oauth
+ - bundles
+ - catalog
+ - usage
additionalProperties: false
- description: Starts a pairing for the instance named by the binding fields.
- DesktopPairingTicket:
+ ExtensionLibraryRevisionResponse:
type: object
properties:
- deviceSecret:
- type: string
- pattern: ^[A-Za-z0-9_-]{43}$
- activationTicket:
- type: string
- pattern: ^[A-Za-z0-9_-]{43}$
- description: "`activationTicket` from the provisional poll response."
- instanceId:
- type: string
- pattern: ^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$
- description: "`publicInstanceIdentity.instanceId` from discovery."
- origin:
- type: string
- description: Canonical API origin the client discovered, for example `https://propr.example.com`.
- scope:
- type: string
- const: desktop-instance
- credentialGeneration:
- type: string
- pattern: ^[A-Za-z0-9_-]{22}$
- description: Client-chosen generation that identifies this credential.
+ library:
+ type: object
+ properties:
+ version:
+ type: number
+ const: 1
+ instanceId:
+ type: string
+ revision:
+ type: integer
+ description: 0 before the first save.
+ createdAt:
+ type: number
+ updatedAt:
+ type: number
+ auditActor:
+ type: string
+ servers:
+ type: array
+ items:
+ type: object
+ properties: {}
+ additionalProperties: {}
+ skills:
+ type: array
+ items:
+ type: object
+ properties: {}
+ additionalProperties: {}
+ secrets:
+ type: object
+ propertyNames:
+ type: string
+ additionalProperties:
+ type: object
+ properties:
+ configured:
+ type: boolean
+ required:
+ - configured
+ additionalProperties: {}
+ description: Presence markers only; secret values are never returned.
+ skillBundles:
+ type: array
+ items:
+ type: object
+ properties: {}
+ additionalProperties: {}
+ required:
+ - version
+ - instanceId
+ - revision
+ - createdAt
+ - updatedAt
+ - auditActor
+ - servers
+ - skills
+ - secrets
+ - skillBundles
+ additionalProperties: {}
required:
- - deviceSecret
- - activationTicket
- - instanceId
- - origin
- - scope
- - credentialGeneration
+ - library
additionalProperties: false
- description: Proof of a provisional pairing, used to activate or cancel it.
- EpicQueueActionError:
+ ExtensionLibrarySave:
type: object
properties:
- error:
- type: string
- code:
- type: string
- enum:
- - NOT_FOUND
- - STALE_EXECUTION
- - IN_FLIGHT
- - ALREADY_DONE
- - STATE_CHANGED
- - NOT_BLOCKED
- - NOT_RETRYABLE
+ input:
+ $ref: "#/components/schemas/ExtensionLibraryInput"
+ expectedRevision:
+ type: integer
+ minimum: 0
+ secrets:
+ type: object
+ propertyNames:
+ type: string
+ additionalProperties:
+ type:
+ - string
+ - "null"
+ acknowledgements:
+ description: Names of local package servers whose secret variables the administrator confirms are readable inside the task container. Required for a package server with secret variables whenever its package, version or secret variable names are new or changed; an unchanged server keeps its recorded acknowledgement. Only package servers with secret variables may be listed.
+ type: array
+ items:
+ type: string
required:
- - error
- - code
+ - input
+ - expectedRevision
additionalProperties: false
- description: A refused queue action.
- EpicQueueActionResult:
+ ExtensionOAuthCheckRequest:
type: object
properties:
- outcome:
+ url:
type: string
- enum:
- - skipped
- - already_skipped
- - retried
- - already_retried
- executionId:
+ clientId:
type: string
- issueNumber:
- type: integer
- exclusiveMinimum: 0
- queue:
- anyOf:
- - type: object
- properties: {}
- additionalProperties: {}
- - type: "null"
- description: The execution queue after the action.
+ scopes:
+ maxItems: 32
+ type: array
+ items:
+ type: string
required:
- - outcome
- - executionId
- - issueNumber
- - queue
+ - url
additionalProperties: false
- EpicQueueBlockedEvent:
+ ExtensionOAuthCheckResponse:
type: object
properties:
- id:
- type: integer
- exclusiveMinimum: 0
- description: Monotonic event id; also the list cursor.
- draftId:
- type: string
- description: The plan.
- executionId:
- type: string
- issueNumber:
- type: integer
- exclusiveMinimum: 0
- position:
- type: integer
- minimum: 0
- description: "The occurrence: a requeued issue starts a new one at a new position."
- type:
- type: string
- enum:
- - blocked
- - resolved
- blockedKind:
- type: string
- enum:
- - closed_unmerged
- - missing_from_plan
- - dispatch_failed
- - agent_blocked
- - awaiting_human_merge
- - label_handoff
- - setup_not_ready
- reason:
- type: string
- blockedEventId:
- anyOf:
- - type: integer
- exclusiveMinimum: 0
- - type: "null"
- description: "For a resolved event: the blocked event it resolves."
- resolution:
- anyOf:
- - type: string
+ check:
+ type: object
+ properties:
+ authorizationRequired:
+ type: boolean
+ discoveredFrom:
+ type: string
enum:
- - cleared
- - replaced
- - retried
- - skipped
- - settled
- - type: "null"
- description: "How the blocker ended: cleared by the queue, replaced by another blocker, retried or skipped by a person, or settled with the issue."
- createdAt:
- type: integer
- description: Epoch milliseconds.
+ - challenge
+ - well_known
+ resource:
+ type: string
+ issuer:
+ type: string
+ scopes:
+ type: array
+ items:
+ type: string
+ registration:
+ anyOf:
+ - type: string
+ enum:
+ - metadata_document
+ - dynamic
+ - preregistered
+ - type: "null"
+ message:
+ type: string
+ redirectUri:
+ type:
+ - string
+ - "null"
+ required:
+ - authorizationRequired
+ - registration
+ - message
+ - redirectUri
+ additionalProperties: false
required:
- - id
- - draftId
- - executionId
- - issueNumber
- - position
- - type
- - blockedKind
- - reason
- - blockedEventId
- - resolution
- - createdAt
+ - check
additionalProperties: false
- description: One blocked transition of an issue in an epic execution, or the resolution of one.
- EpicQueueBlockedEventPage:
+ ExtensionOAuthInstanceInfo:
type: object
properties:
- executionId:
- description: The listed execution; null when the plan never queued work.
+ redirectUri:
+ description: Redirect URI to register with a provider; null without a public HTTPS base URL, which disables Connect.
type:
- string
- "null"
- events:
- type: array
- items:
- $ref: "#/components/schemas/EpicQueueBlockedEvent"
- nextCursor:
- description: Pass as `cursor` to read the events after this page; unchanged when the page is empty.
+ clientMetadataUrl:
+ description: ProPR's Client ID Metadata Document URL, used as the client id where supported.
type:
- string
- "null"
- hasMore:
- type: boolean
+ connections:
+ type: object
+ propertyNames:
+ type: string
+ additionalProperties:
+ type: object
+ properties:
+ state:
+ type: string
+ enum:
+ - not_connected
+ - connected
+ - needs_sign_in
+ connectedBy:
+ description: Actor id of the administrator who signed in.
+ type: string
+ connectedAt:
+ type: number
+ scopes:
+ description: Scopes the authorization server granted.
+ type: array
+ items:
+ type: string
+ registration:
+ type: string
+ enum:
+ - metadata_document
+ - dynamic
+ - preregistered
+ required:
+ - state
+ additionalProperties: false
+ description: Connection state by OAuth server name. Tokens are never returned.
required:
- - executionId
- - events
- - nextCursor
- - hasMore
+ - redirectUri
+ - clientMetadataUrl
+ - connections
additionalProperties: false
- EpicQueueRetryRequest:
+ description: OAuth sign-in facts of this instance and the connection state of its OAuth library servers.
+ ExtensionOAuthItemRequest:
type: object
properties:
- executionId:
- type: string
- description: The execution read from the execution queue; a newer or finished one refuses the retry.
- expectedState:
- description: Refuse with `STATE_CHANGED` unless the issue is still in this state.
+ name:
type: string
- enum:
- - pending
- - dispatched
- - done
- - blocked
- - skipped
required:
- - executionId
+ - name
additionalProperties: false
- EpicQueueSkipRequest:
+ ExtensionOAuthStartResponse:
type: object
properties:
- executionId:
- type: string
- description: The execution read from the execution queue; a newer or finished one refuses the skip.
- expectedState:
- description: Refuse with `STATE_CHANGED` unless the issue is still in this state.
+ authorizationUrl:
type: string
- enum:
- - pending
- - dispatched
- - done
- - blocked
- - skipped
- satisfiesDependents:
- description: "Graph executions: treat the skipped issue as a met prerequisite, so its dependents can start. Skipping again with the other value changes only this option."
- type: boolean
+ description: Provider authorization page to open in a new tab; carries a single-use state bound to this session, PKCE S256 and the resource indicator.
required:
- - executionId
+ - authorizationUrl
additionalProperties: false
- ErrorEnvelope:
+ ExtensionSecretReplaceRequest:
type: object
properties:
- code:
+ name:
type: string
- description: Stable, machine-readable error code, for example `TASK_NOT_FOUND`.
- message:
+ secret:
+ type:
+ - string
+ - "null"
+ expectedRevision:
+ type: integer
+ minimum: 0
+ required:
+ - name
+ - secret
+ additionalProperties: false
+ ExtensionSelection:
+ type: object
+ properties:
+ instanceId:
type: string
- description: Human-readable explanation that is safe to show to an operator.
- hint:
- description: Optional next step that resolves the error.
+ format: uuid
+ pattern: ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$
+ profileId:
type: string
+ format: uuid
+ pattern: ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$
+ revision:
+ type: integer
+ exclusiveMinimum: 0
required:
- - code
- - message
+ - instanceId
+ - profileId
+ - revision
additionalProperties: false
- description: The common error envelope. New routes return it for every 4xx and 5xx response.
- ExtensionItemEnabledRequest:
+ description: Legacy profile selection; no longer accepted for new submissions.
+ FollowupHandoffDetail:
type: object
properties:
- item:
- anyOf:
- - type: object
- properties:
- kind:
- type: string
- const: server
- name:
- type: string
- required:
- - kind
- - name
- additionalProperties: false
- - type: object
- properties:
- kind:
- type: string
- const: skill
- bundleId:
- type: string
- format: uuid
- pattern: ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$
- required:
- - kind
- - bundleId
- additionalProperties: false
- enabled:
+ status:
+ $ref: "#/components/schemas/FollowupHandoffStatus"
+ required:
+ - status
+ additionalProperties: false
+ FollowupHandoffList:
+ type: object
+ properties:
+ handoffs:
+ type: array
+ items:
+ $ref: "#/components/schemas/FollowupHandoffStatus"
+ required:
+ - handoffs
+ additionalProperties: false
+ FollowupHandoffRecoverySummary:
+ type: object
+ properties:
+ inspected:
+ type: integer
+ minimum: 0
+ published:
+ type: integer
+ minimum: 0
+ description: Interrupted work recovered and published exactly once.
+ noChanges:
+ type: integer
+ minimum: 0
+ description: Interrupted runs that produced nothing; their requests were returned to the queue (settlement `returned_to_queue`).
+ settled:
+ type: integer
+ minimum: 0
+ description: Runs whose no-change completion, failure or usage-limit retry the normal path had already delivered or scheduled; recorded without republishing or requeueing.
+ cleanupPending:
+ type: integer
+ minimum: 0
+ description: Publication settled but branch release still owed; retried on the next pass.
+ skippedLiveAgent:
+ type: integer
+ minimum: 0
+ failedClosed:
+ type: integer
+ minimum: 0
+ description: Records whose container evidence was unavailable or ambiguous; left untouched.
+ awaitingOperator:
+ type: integer
+ minimum: 0
+ lockedByOtherWriter:
+ type: integer
+ minimum: 0
+ errors:
+ type: integer
+ minimum: 0
+ description: Records whose recovery failed (for example unreachable remote evidence); retained for the next pass.
+ required:
+ - inspected
+ - published
+ - noChanges
+ - settled
+ - cleanupPending
+ - skippedLiveAgent
+ - failedClosed
+ - awaitingOperator
+ - lockedByOtherWriter
+ - errors
+ additionalProperties: false
+ description: Counts of one recovery pass. Keyed passes inspect at most the addressed pull request.
+ FollowupHandoffReleaseRequest:
+ type: object
+ properties:
+ force:
+ description: Also release a branch whose retained worktree still holds unpublished work. Never deletes worktree files, and never overrides a live or unverifiable agent.
+ type: boolean
+ additionalProperties: false
+ FollowupHandoffReleaseResult:
+ type: object
+ properties:
+ released:
type: boolean
+ reason:
+ description: Refusal code when `released` is false, for example `agent_still_running`, `liveness_unavailable`, `lease_held_by_live_writer`, `worktree_holds_unpublished_work`, `branch_still_registered` (a Git worktree registration still holds the branch, even after a forced detach), `worktree_registrations_unavailable` or `cancelled_claims_withdrawal_failed` (the claimed comments of a cancelled request could not be withdrawn from the pending list, so releasing could let them run).
+ type: string
required:
- - item
- - enabled
+ - released
additionalProperties: false
- ExtensionLibraryInput:
+ description: "Outcome of a release request; refusals answer `409` with `released: false`."
+ FollowupHandoffResumeRequest:
type: object
properties:
- version:
- type: number
- const: 1
- servers:
- maxItems: 32
- type: array
- items:
- oneOf:
- - type: object
- properties:
- kind:
- type: string
- const: https
- name:
- type: string
- pattern: ^[a-z][a-z0-9_-]{0,47}$
- url:
- type: string
- maxLength: 2048
- enabled:
- type: boolean
- tools:
- maxItems: 64
- type: array
- items:
- type: string
- pattern: ^[A-Za-z0-9_.-]{1,128}$
- capability:
- type: string
- enum:
- - read
- - write
- authentication:
- type: string
- enum:
- - none
- - bearer
- - oauth
- scopes:
- maxItems: 32
- type: array
- items:
- type: string
- pattern: ^[\x21\x23-\x5b\x5d-\x7e]{1,256}$
- clientId:
- type: string
- pattern: ^[\x21-\x7e]{1,512}$
- repositories:
- default: []
- maxItems: 64
- type: array
- items:
- type: string
- pattern: ^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$
- agents:
- default: []
- maxItems: 64
- type: array
- items:
- type: string
- pattern: ^[A-Za-z0-9_-]{1,100}$
- autoApply:
- default: true
- type: boolean
- required:
- - kind
- - name
- - url
- - enabled
- - tools
- - capability
- - authentication
- - repositories
- - agents
- - autoApply
- additionalProperties: false
- - type: object
- properties:
- kind:
- type: string
- const: catalog
- name:
- type: string
- pattern: ^[a-z][a-z0-9_-]{0,47}$
- catalogId:
- type: string
- pattern: ^[a-z][a-z0-9_-]{0,63}$
- digest:
- type: string
- pattern: ^[0-9a-f]{64}$
- enabled:
- type: boolean
- tools:
- maxItems: 64
- type: array
- items:
- type: string
- pattern: ^[A-Za-z0-9_.-]{1,128}$
- capability:
- type: string
- enum:
- - read
- - write
- repositories:
- default: []
- maxItems: 64
- type: array
- items:
- type: string
- pattern: ^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$
- agents:
- default: []
- maxItems: 64
- type: array
- items:
- type: string
- pattern: ^[A-Za-z0-9_-]{1,100}$
- autoApply:
- default: true
- type: boolean
- required:
- - kind
- - name
- - catalogId
- - digest
- - enabled
- - tools
- - capability
- - repositories
- - agents
- - autoApply
- additionalProperties: false
- - type: object
- properties:
- name:
- type: string
- pattern: ^[a-z][a-z0-9_-]{0,47}$
- enabled:
- type: boolean
- tools:
- maxItems: 64
- type: array
- items:
- type: string
- pattern: ^[A-Za-z0-9_.-]{1,128}$
- capability:
- type: string
- enum:
- - read
- - write
- kind:
- type: string
- const: package
- ecosystem:
- type: string
- enum:
- - npm
- package:
- type: string
- maxLength: 214
- pattern: ^(@[a-z0-9][a-z0-9._~-]*\/)?[a-z0-9][a-z0-9._~-]*$
- version:
- type: string
- maxLength: 256
- pattern: ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$
- integrity:
- type: string
- pattern: ^sha512-[A-Za-z0-9+/]{86}==$
- bin:
- type: string
- pattern: ^[A-Za-z0-9@._-]{1,128}$
- args:
- default: []
- maxItems: 64
- type: array
- items:
- type: string
- maxLength: 2048
- pattern: ^[^\x00\r\n]*$
- env:
- default: []
- maxItems: 32
- type: array
- items:
- type: object
- properties:
- name:
- type: string
- pattern: ^[A-Z_][A-Z0-9_]{0,99}$
- value:
- type: string
- maxLength: 4096
- pattern: ^[^\x00\r\n]*$
- required:
- - name
- - value
- additionalProperties: false
- secretEnv:
- default: []
- maxItems: 16
- type: array
- items:
- type: string
- pattern: ^[A-Z_][A-Z0-9_]{0,99}$
- repositories:
- default: []
- maxItems: 64
- type: array
- items:
- type: string
- pattern: ^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$
- agents:
- default: []
- maxItems: 64
- type: array
- items:
- type: string
- pattern: ^[A-Za-z0-9_-]{1,100}$
- autoApply:
- default: true
- type: boolean
- required:
- - name
- - enabled
- - tools
- - capability
- - kind
- - ecosystem
- - package
- - version
- - args
- - env
- - secretEnv
- - repositories
- - agents
- - autoApply
- additionalProperties: false
- skills:
- maxItems: 32
+ authorizeCancelled:
+ description: Set `true` to explicitly authorize publishing work the user cancelled. Without it, resume re-arms the operator-held record and refuses cancelled work with code `requires_cancellation_authorization`.
+ type: boolean
+ additionalProperties: false
+ FollowupHandoffResumeResult:
+ type: object
+ properties:
+ summary:
+ $ref: "#/components/schemas/FollowupHandoffRecoverySummary"
+ status:
+ anyOf:
+ - $ref: "#/components/schemas/FollowupHandoffStatus"
+ - type: "null"
+ description: State after the keyed recovery pass; `null` once the handoff retired completely.
+ required:
+ - summary
+ - status
+ additionalProperties: false
+ FollowupHandoffStatus:
+ type: object
+ properties:
+ repository:
+ type: string
+ description: Lowercase `owner/repo` of the pull request the handoff belongs to.
+ pullRequest:
+ type: integer
+ exclusiveMinimum: 0
+ taskId:
+ type: string
+ description: Task whose interrupted follow-up publication the handoff records.
+ predecessorTaskIds:
+ type: array
+ items:
+ type: string
+ description: Source task lineage recorded when the agent launched.
+ successorTaskId:
+ description: Task that adopted the handoff during recovery, when any.
+ type:
+ - string
+ - "null"
+ phase:
+ type: string
+ enum:
+ - agent_running
+ - agent_exited
+ - committed
+ - pushed
+ - comment_published
+ - cleanup_pending
+ description: Exact publication step recovery resumes from. `cleanup_pending` means everything is published and only branch release is owed.
+ status:
+ type: string
+ enum:
+ - active
+ - awaiting_operator
+ - released
+ description: "`active` records are recovered automatically; `awaiting_operator` records never publish without an explicit operator decision."
+ statusReason:
+ description: Why the handoff is held or failed closed, for example `cancelled_by_user`, `worktree_missing`, `head_advanced_externally`, `usage_limit_retry_unconfirmed`, `liveness_unavailable`. A mutable diagnostic; see `settlement` for the durable outcome.
+ type:
+ - string
+ - "null"
+ settlement:
+ anyOf:
+ - type: string
+ enum:
+ - returned_to_queue
+ - usage_limit_retry_scheduling
+ - usage_limit_retry_scheduled
+ - type: "null"
+ description: "Durable owner of the claimed comments once the attempt settles without publishing: `returned_to_queue` (recovery returned them to the pending list), `usage_limit_retry_scheduled` (a scheduled usage-limit retry job owns them; recovery never requeues them) or `usage_limit_retry_scheduling` (the worker stopped around the queue add; held for an operator as `usage_limit_retry_unconfirmed`). Unlike `statusReason`, later diagnostics never overwrite it, so the `noChanges`/`settled` counters stay correct."
+ operatorAuthorized:
+ type: boolean
+ description: Whether an operator explicitly authorized publishing user-cancelled work.
+ liveness:
+ type: string
+ description: Agent container liveness evidence, for example `running`, `stopped`, `not_found` or `unavailable` (recovery fails closed on the latter).
+ branchName:
+ type: string
+ description: PR head branch still attached to the retained worktree until cleanup releases it.
+ commitHash:
+ description: Commit recovery is bound to publish, once recorded.
+ type:
+ - string
+ - "null"
+ completionCommentUrl:
+ description: Delivered completion comment; recovery never overwrites it.
+ type:
+ - string
+ - "null"
+ nextAction:
+ type: string
+ description: Operator guidance for the current state.
+ retainedWorktreePath:
+ type: string
+ description: Host path of the retained worktree. Deliberately admin-only; never shown on member routes.
+ updatedAt:
+ type: number
+ description: Unix milliseconds of the last durable update.
+ required:
+ - repository
+ - pullRequest
+ - taskId
+ - predecessorTaskIds
+ - successorTaskId
+ - phase
+ - status
+ - statusReason
+ - settlement
+ - operatorAuthorized
+ - liveness
+ - branchName
+ - commitHash
+ - completionCommentUrl
+ - nextAction
+ - retainedWorktreePath
+ - updatedAt
+ additionalProperties: false
+ description: Operator-facing status of one durable follow-up publication handoff.
+ Health:
+ type: object
+ properties:
+ status:
+ type: string
+ const: ok
+ required:
+ - status
+ additionalProperties: false
+ description: Liveness probe result.
+ InstanceDatabaseExport:
+ type: object
+ properties:
+ version:
+ type: number
+ const: 1
+ instanceId:
+ type: string
+ generation:
+ type: string
+ format: uuid
+ pattern: ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$
+ operationId:
+ type: string
+ format: uuid
+ pattern: ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$
+ state:
+ type: string
+ enum:
+ - running
+ - ready
+ - failed
+ - redeemed
+ expiresAt:
+ type: string
+ format: date-time
+ scope:
+ type: string
+ const: customer_database
+ sizeBytes:
+ type: number
+ minimum: 0
+ sha256:
+ type: string
+ schemaVersion:
+ type: integer
+ migration:
+ type:
+ - string
+ - "null"
+ required:
+ - version
+ - instanceId
+ - generation
+ - operationId
+ - state
+ - expiresAt
+ - scope
+ additionalProperties: false
+ InstanceDatabaseExportRequest:
+ type: object
+ properties:
+ instanceId:
+ type: string
+ generation:
+ type: string
+ format: uuid
+ pattern: ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$
+ required:
+ - instanceId
+ - generation
+ additionalProperties: false
+ InstanceRepositoryInventory:
+ type: object
+ properties:
+ version:
+ type: number
+ const: 1
+ instanceId:
+ type: string
+ state:
+ type: string
+ enum:
+ - complete
+ - partial
+ - unknown
+ - unsupported
+ observedAt:
+ type: string
+ format: date-time
+ lastUseAt:
+ anyOf:
+ - type: string
+ format: date-time
+ - type: "null"
+ repositories:
type: array
items:
type: object
properties:
- bundleId:
+ id:
type: string
- format: uuid
- pattern: ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$
- enabled:
- type: boolean
- repositories:
- default: []
- maxItems: 64
- type: array
- items:
- type: string
- pattern: ^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$
- agents:
- default: []
- maxItems: 64
- type: array
- items:
- type: string
- pattern: ^[A-Za-z0-9_-]{1,100}$
- autoApply:
- default: true
- type: boolean
+ name:
+ type: string
+ category:
+ type: string
+ enum:
+ - database_config
+ - repository_caches
+ - worktrees
+ - salvage
+ - logs_artifacts
+ - provider_credentials
+ - task_history
+ estimatedBytes:
+ anyOf:
+ - type: number
+ minimum: 0
+ - type: "null"
+ protectedReasons:
+ type: array
+ items:
+ type: string
+ enum:
+ - partial_generated_cleanup
+ - busy
+ - unknown_use
+ - recent_use
+ - unknown_ownership
+ - unsafe_path
+ - private_work
+ - worktrees
+ - local_history
+ - salvage
+ - remote_unavailable
+ - unknown_git_state
+ - changed
+ - unsupported
required:
- - bundleId
- - enabled
- - repositories
- - agents
- - autoApply
+ - id
+ - name
+ - category
+ - estimatedBytes
+ - protectedReasons
additionalProperties: false
required:
- version
- - servers
- - skills
+ - instanceId
+ - state
+ - observedAt
+ - lastUseAt
+ - repositories
additionalProperties: false
- description: "Strict shared v1 library contract: up to 32 servers and 32 skill bundle references. Each item has enabled, optional repositories (owner/repo; empty means every repository) and agents (aliases; empty means every agent) limits, and autoApply (default true; false applies the item only where an automation adds it). Operator catalog descriptors accept only immutable catalogId/digest and selected tools/capability; customer commands, arguments, environment and mount paths are rejected."
- ExtensionLibraryRead:
+ InstanceStorageCapabilities:
type: object
properties:
- contract:
- type: object
- properties: {}
- additionalProperties: {}
+ version:
+ type: number
+ const: 1
instanceId:
type: string
- library:
- type: object
- properties:
- version:
- type: number
- const: 1
- instanceId:
- type: string
- revision:
- type: integer
- description: 0 before the first save.
- createdAt:
- type: number
- updatedAt:
- type: number
- auditActor:
- type: string
- servers:
- type: array
- items:
- type: object
- properties: {}
- additionalProperties: {}
- skills:
- type: array
- items:
- type: object
- properties: {}
- additionalProperties: {}
- secrets:
- type: object
- propertyNames:
- type: string
- additionalProperties:
- type: object
- properties:
- configured:
- type: boolean
- required:
- - configured
- additionalProperties: {}
- description: Presence markers only; secret values are never returned.
- skillBundles:
- type: array
- items:
- type: object
- properties: {}
- additionalProperties: {}
- required:
- - version
- - instanceId
- - revision
- - createdAt
- - updatedAt
- - auditActor
- - servers
- - skills
- - secrets
- - skillBundles
- additionalProperties: {}
- oauth:
- $ref: "#/components/schemas/ExtensionOAuthInstanceInfo"
- bundles:
- type: array
- items:
- type: object
- properties: {}
- additionalProperties: {}
- catalog:
+ generation:
+ type: string
+ format: uuid
+ pattern: ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$
+ features:
type: array
items:
- type: object
- properties: {}
- additionalProperties: {}
- usage:
- type: object
- properties:
- automations:
- type: array
- items:
- type: object
- properties:
- id:
- type: string
- name:
- type: string
- servers:
- type: array
- items:
- type: string
- skills:
- type: array
- items:
- type: string
- required:
- - id
- - name
- - servers
- - skills
- additionalProperties: false
- importedFrom:
- type: object
- properties:
- servers:
- type: object
- propertyNames:
- type: string
- additionalProperties:
- type: array
- items:
- type: string
- skills:
- type: object
- propertyNames:
- type: string
- additionalProperties:
- type: array
- items:
- type: string
- required:
- - servers
- - skills
- additionalProperties: false
- required:
- - automations
- - importedFrom
- additionalProperties: false
- description: Automations that add library items explicitly (server names and skill bundle ids), and the names of the extension profiles that migrated items came from.
+ type: string
+ enum:
+ - repository_inventory
+ - repository_cleanup
+ - database_export
required:
- - contract
+ - version
- instanceId
- - library
- - oauth
- - bundles
- - catalog
- - usage
+ - generation
+ - features
additionalProperties: false
- ExtensionLibraryRevisionResponse:
+ InstanceStorageExecuteRequest:
type: object
properties:
- library:
+ instanceId:
+ type: string
+ confirmation:
+ type: string
+ format: uuid
+ pattern: ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$
+ required:
+ - instanceId
+ - confirmation
+ additionalProperties: false
+ InstanceStoragePolicy:
+ type: object
+ properties:
+ version:
+ type: number
+ const: 1
+ instanceId:
+ type: string
+ policy:
type: object
properties:
- version:
- type: number
- const: 1
- instanceId:
- type: string
- revision:
- type: integer
- description: 0 before the first save.
- createdAt:
- type: number
- updatedAt:
- type: number
- auditActor:
- type: string
- servers:
- type: array
- items:
- type: object
- properties: {}
- additionalProperties: {}
- skills:
- type: array
- items:
- type: object
- properties: {}
- additionalProperties: {}
- secrets:
- type: object
- propertyNames:
- type: string
- additionalProperties:
- type: object
- properties:
- configured:
- type: boolean
- required:
- - configured
- additionalProperties: {}
- description: Presence markers only; secret values are never returned.
- skillBundles:
- type: array
- items:
- type: object
- properties: {}
- additionalProperties: {}
+ enabled:
+ type: boolean
+ idleHours:
+ type: integer
+ minimum: 1
+ maximum: 8760
required:
- - version
- - instanceId
- - revision
- - createdAt
- - updatedAt
- - auditActor
- - servers
- - skills
- - secrets
- - skillBundles
- additionalProperties: {}
+ - enabled
+ - idleHours
+ additionalProperties: false
required:
- - library
+ - version
+ - instanceId
+ - policy
additionalProperties: false
- ExtensionLibrarySave:
+ InstanceStoragePolicyRequest:
type: object
properties:
- input:
- $ref: "#/components/schemas/ExtensionLibraryInput"
- expectedRevision:
+ enabled:
+ type: boolean
+ idleHours:
type: integer
- minimum: 0
- secrets:
- type: object
- propertyNames:
+ minimum: 1
+ maximum: 8760
+ instanceId:
+ type: string
+ required:
+ - enabled
+ - idleHours
+ - instanceId
+ additionalProperties: false
+ InstanceStoragePreview:
+ type: object
+ properties:
+ version:
+ type: number
+ const: 1
+ instanceId:
+ type: string
+ confirmation:
+ type: string
+ format: uuid
+ pattern: ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$
+ expiresAt:
+ type: string
+ format: date-time
+ categories:
+ type: array
+ items:
type: string
- additionalProperties:
- type:
- - string
- - "null"
- acknowledgements:
- description: Names of local package servers whose secret variables the administrator confirms are readable inside the task container. Required for a package server with secret variables whenever its package, version or secret variable names are new or changed; an unchanged server keeps its recorded acknowledgement. Only package servers with secret variables may be listed.
+ enum:
+ - database_config
+ - repository_caches
+ - worktrees
+ - salvage
+ - logs_artifacts
+ - provider_credentials
+ - task_history
+ impact:
+ type: string
+ candidates:
+ type: array
+ items:
+ type: object
+ properties:
+ id:
+ type: string
+ name:
+ type: string
+ category:
+ type: string
+ enum:
+ - database_config
+ - repository_caches
+ - worktrees
+ - salvage
+ - logs_artifacts
+ - provider_credentials
+ - task_history
+ estimatedBytes:
+ anyOf:
+ - type: number
+ minimum: 0
+ - type: "null"
+ protectedReasons:
+ type: array
+ items:
+ type: string
+ enum:
+ - partial_generated_cleanup
+ - busy
+ - unknown_use
+ - recent_use
+ - unknown_ownership
+ - unsafe_path
+ - private_work
+ - worktrees
+ - local_history
+ - salvage
+ - remote_unavailable
+ - unknown_git_state
+ - changed
+ - unsupported
+ required:
+ - id
+ - category
+ - estimatedBytes
+ - protectedReasons
+ additionalProperties: false
+ required:
+ - version
+ - instanceId
+ - confirmation
+ - expiresAt
+ - categories
+ - impact
+ - candidates
+ additionalProperties: false
+ InstanceStoragePreviewRequest:
+ type: object
+ properties:
+ instanceId:
+ type: string
+ categories:
+ minItems: 1
+ maxItems: 7
+ type: array
+ items:
+ type: string
+ enum:
+ - database_config
+ - repository_caches
+ - worktrees
+ - salvage
+ - logs_artifacts
+ - provider_credentials
+ - task_history
+ taskIds:
+ maxItems: 10
type: array
items:
type: string
+ pattern: ^[A-Za-z0-9_-]{1,255}$
required:
- - input
- - expectedRevision
+ - instanceId
+ - categories
additionalProperties: false
- ExtensionOAuthCheckRequest:
+ InstanceStorageResult:
type: object
properties:
- url:
+ version:
+ type: number
+ const: 1
+ instanceId:
type: string
- clientId:
+ operationId:
type: string
- scopes:
- maxItems: 32
+ format: uuid
+ pattern: ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$
+ state:
+ type: string
+ enum:
+ - completed
+ - interrupted
+ deleted:
type: array
items:
type: string
+ retained:
+ type: array
+ items:
+ type: object
+ properties:
+ id:
+ type: string
+ reasons:
+ type: array
+ items:
+ type: string
+ enum:
+ - partial_generated_cleanup
+ - busy
+ - unknown_use
+ - recent_use
+ - unknown_ownership
+ - unsafe_path
+ - private_work
+ - worktrees
+ - local_history
+ - salvage
+ - remote_unavailable
+ - unknown_git_state
+ - changed
+ - unsupported
+ required:
+ - id
+ - reasons
+ additionalProperties: false
required:
- - url
+ - version
+ - instanceId
+ - operationId
+ - state
+ - deleted
+ - retained
additionalProperties: false
- ExtensionOAuthCheckResponse:
+ InstanceStorageUsage:
type: object
properties:
- check:
- type: object
- properties:
- authorizationRequired:
- type: boolean
- discoveredFrom:
- type: string
- enum:
- - challenge
- - well_known
- resource:
- type: string
- issuer:
+ version:
+ type: number
+ const: 1
+ instanceId:
+ type: string
+ observedAt:
+ anyOf:
+ - type: string
+ format: date-time
+ - type: "null"
+ state:
+ type: string
+ enum:
+ - complete
+ - partial
+ - unknown
+ - unsupported
+ scanning:
+ type: boolean
+ ownedBytes:
+ anyOf:
+ - type: number
+ minimum: 0
+ - type: "null"
+ generatedPayloads:
+ description: Last bounded generated-payload proof, protected reasons and durable result. Observations may be stale or partial; unregistered worktrees remain protected.
+ type: object
+ properties:
+ observedAt:
+ anyOf:
+ - type: string
+ format: date-time
+ - type: "null"
+ reclaimableBytes:
+ anyOf:
+ - type: number
+ minimum: 0
+ - type: "null"
+ coverage:
type: string
- scopes:
+ candidates:
type: array
items:
- type: string
- registration:
+ type: object
+ properties:
+ id:
+ type: string
+ category:
+ type: string
+ enum:
+ - database_config
+ - repository_caches
+ - worktrees
+ - salvage
+ - logs_artifacts
+ - provider_credentials
+ - task_history
+ estimatedBytes:
+ anyOf:
+ - type: number
+ minimum: 0
+ - type: "null"
+ protectedReasons:
+ type: array
+ items:
+ type: string
+ enum:
+ - partial_generated_cleanup
+ - busy
+ - unknown_use
+ - recent_use
+ - unknown_ownership
+ - unsafe_path
+ - private_work
+ - worktrees
+ - local_history
+ - salvage
+ - remote_unavailable
+ - unknown_git_state
+ - changed
+ - unsupported
+ required:
+ - id
+ - category
+ - estimatedBytes
+ - protectedReasons
+ additionalProperties: false
+ lastCleanup:
anyOf:
- - type: string
- enum:
- - metadata_document
- - dynamic
- - preregistered
+ - type: object
+ propertyNames:
+ type: string
+ additionalProperties: {}
- type: "null"
- message:
- type: string
- redirectUri:
- type:
- - string
- - "null"
required:
- - authorizationRequired
- - registration
- - message
- - redirectUri
+ - observedAt
+ - reclaimableBytes
+ - coverage
+ - lastCleanup
additionalProperties: false
- required:
- - check
- additionalProperties: false
- ExtensionOAuthInstanceInfo:
- type: object
- properties:
- redirectUri:
- description: Redirect URI to register with a provider; null without a public HTTPS base URL, which disables Connect.
- type:
- - string
- - "null"
- clientMetadataUrl:
- description: ProPR's Client ID Metadata Document URL, used as the client id where supported.
- type:
- - string
- - "null"
- connections:
- type: object
- propertyNames:
- type: string
- additionalProperties:
+ categories:
+ type: array
+ items:
type: object
properties:
- state:
+ category:
type: string
enum:
- - not_connected
- - connected
- - needs_sign_in
- connectedBy:
- description: Actor id of the administrator who signed in.
- type: string
- connectedAt:
- type: number
- scopes:
- description: Scopes the authorization server granted.
- type: array
- items:
- type: string
- registration:
+ - database_config
+ - repository_caches
+ - worktrees
+ - salvage
+ - logs_artifacts
+ - provider_credentials
+ - task_history
+ bytes:
+ anyOf:
+ - type: number
+ minimum: 0
+ - type: "null"
+ state:
type: string
enum:
- - metadata_document
- - dynamic
- - preregistered
+ - complete
+ - partial
+ - unknown
+ - unsupported
required:
+ - category
+ - bytes
- state
additionalProperties: false
- description: Connection state by OAuth server name. Tokens are never returned.
- required:
- - redirectUri
- - clientMetadataUrl
- - connections
- additionalProperties: false
- description: OAuth sign-in facts of this instance and the connection state of its OAuth library servers.
- ExtensionOAuthItemRequest:
- type: object
- properties:
- name:
- type: string
- required:
- - name
- additionalProperties: false
- ExtensionOAuthStartResponse:
- type: object
- properties:
- authorizationUrl:
- type: string
- description: Provider authorization page to open in a new tab; carries a single-use state bound to this session, PKCE S256 and the resource indicator.
+ filesystems:
+ type: array
+ items:
+ type: object
+ properties:
+ id:
+ type: string
+ totalBytes:
+ type: number
+ minimum: 0
+ availableBytes:
+ type: number
+ minimum: 0
+ required:
+ - id
+ - totalBytes
+ - availableBytes
+ additionalProperties: false
required:
- - authorizationUrl
+ - version
+ - instanceId
+ - observedAt
+ - state
+ - scanning
+ - ownedBytes
+ - categories
+ - filesystems
additionalProperties: false
- ExtensionSecretReplaceRequest:
+ JsonObject:
type: object
- properties:
- name:
- type: string
- secret:
- type:
- - string
- - "null"
- expectedRevision:
- type: integer
- minimum: 0
- required:
- - name
- - secret
- additionalProperties: false
- ExtensionSelection:
+ properties: {}
+ additionalProperties: {}
+ description: A JSON object whose fields are not documented yet.
+ LegacyError:
type: object
properties:
- instanceId:
+ error:
type: string
- format: uuid
- pattern: ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$
- profileId:
+ description: Human-readable error message.
+ code:
+ description: Machine-readable code, present on authentication and some validation errors.
+ type: string
+ message:
+ description: Longer explanation, present on some authentication errors.
type: string
- format: uuid
- pattern: ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$
- revision:
- type: integer
- exclusiveMinimum: 0
- required:
- - instanceId
- - profileId
- - revision
- additionalProperties: false
- description: Legacy profile selection; no longer accepted for new submissions.
- FollowupHandoffDetail:
- type: object
- properties:
- status:
- $ref: "#/components/schemas/FollowupHandoffStatus"
- required:
- - status
- additionalProperties: false
- FollowupHandoffList:
- type: object
- properties:
- handoffs:
- type: array
- items:
- $ref: "#/components/schemas/FollowupHandoffStatus"
required:
- - handoffs
- additionalProperties: false
- FollowupHandoffRecoverySummary:
+ - error
+ additionalProperties: {}
+ description: "The ad-hoc error shape most existing routes return. Operations that use it carry `x-legacy-error: true`."
+ NotificationUnreadCount:
type: object
properties:
- inspected:
- type: integer
- minimum: 0
- published:
- type: integer
- minimum: 0
- description: Interrupted work recovered and published exactly once.
- noChanges:
- type: integer
- minimum: 0
- description: Interrupted runs that produced nothing; their requests were returned to the queue (settlement `returned_to_queue`).
- settled:
- type: integer
- minimum: 0
- description: Runs whose no-change completion, failure or usage-limit retry the normal path had already delivered or scheduled; recorded without republishing or requeueing.
- cleanupPending:
- type: integer
- minimum: 0
- description: Publication settled but branch release still owed; retried on the next pass.
- skippedLiveAgent:
- type: integer
- minimum: 0
- failedClosed:
- type: integer
- minimum: 0
- description: Records whose container evidence was unavailable or ambiguous; left untouched.
- awaitingOperator:
- type: integer
- minimum: 0
- lockedByOtherWriter:
- type: integer
- minimum: 0
- errors:
+ unreadCount:
type: integer
minimum: 0
- description: Records whose recovery failed (for example unreachable remote evidence); retained for the next pass.
required:
- - inspected
- - published
- - noChanges
- - settled
- - cleanupPending
- - skippedLiveAgent
- - failedClosed
- - awaitingOperator
- - lockedByOtherWriter
- - errors
+ - unreadCount
additionalProperties: false
- description: Counts of one recovery pass. Keyed passes inspect at most the addressed pull request.
- FollowupHandoffReleaseRequest:
+ PluginAcknowledgementRequest:
type: object
properties:
- force:
- description: Also release a branch whose retained worktree still holds unpublished work. Never deletes worktree files, and never overrides a live or unverifiable agent.
- type: boolean
+ acknowledgements:
+ description: Package servers whose secret variables the administrator confirms are readable by the agent CLI, its shell tools and repository setup scripts in the task container.
+ type: array
+ items:
+ type: string
additionalProperties: false
- FollowupHandoffReleaseResult:
+ PluginActivityResponse:
type: object
properties:
- released:
- type: boolean
- reason:
- description: Refusal code when `released` is false, for example `agent_still_running`, `liveness_unavailable`, `lease_held_by_live_writer`, `worktree_holds_unpublished_work`, `branch_still_registered` (a Git worktree registration still holds the branch, even after a forced detach), `worktree_registrations_unavailable` or `cancelled_claims_withdrawal_failed` (the claimed comments of a cancelled request could not be withdrawn from the pending list, so releasing could let them run).
- type: string
+ health:
+ $ref: "#/components/schemas/PluginHealth"
+ deliveries:
+ type: array
+ items:
+ $ref: "#/components/schemas/PluginDelivery"
+ description: The newest 100 delivery records, newest first.
required:
- - released
- additionalProperties: false
- description: "Outcome of a release request; refusals answer `409` with `released: false`."
- FollowupHandoffResumeRequest:
- type: object
- properties:
- authorizeCancelled:
- description: Set `true` to explicitly authorize publishing work the user cancelled. Without it, resume re-arms the operator-held record and refuses cancelled work with code `requires_cancellation_authorization`.
- type: boolean
+ - health
+ - deliveries
additionalProperties: false
- FollowupHandoffResumeResult:
+ PluginConnectionRequest:
type: object
properties:
- summary:
- $ref: "#/components/schemas/FollowupHandoffRecoverySummary"
- status:
+ value:
anyOf:
- - $ref: "#/components/schemas/FollowupHandoffStatus"
+ - type: string
+ maxLength: 4096
- type: "null"
- description: State after the keyed recovery pass; `null` once the handoff retired completely.
+ description: The secret to store (write-only), or null to clear it.
required:
- - summary
- - status
+ - value
additionalProperties: false
- FollowupHandoffStatus:
+ PluginDelivery:
type: object
properties:
+ id:
+ type: integer
+ description: The activity record id, used by the retry route.
+ deliveryId:
+ type: string
+ description: The stable delivery id the handler receives (`evt--`); the same on every attempt.
+ kind:
+ type: string
+ enum:
+ - event
+ - gap
+ description: "`event`: one journal event to one handler. `gap`: journal retention removed events before they were delivered."
repository:
type: string
- description: Lowercase `owner/repo` of the pull request the handoff belongs to.
- pullRequest:
+ eventSeq:
type: integer
- exclusiveMinimum: 0
- taskId:
- type: string
- description: Task whose interrupted follow-up publication the handoff records.
- predecessorTaskIds:
- type: array
- items:
- type: string
- description: Source task lineage recorded when the agent launched.
- successorTaskId:
- description: Task that adopted the handoff during recovery, when any.
+ description: The journal `seq` of the event; for a gap, the first `seq` that may be missing.
+ eventType:
+ description: "`.`; null for a gap."
+ type:
+ - string
+ - "null"
+ handler:
type:
- string
- "null"
- phase:
- type: string
- enum:
- - agent_running
- - agent_exited
- - committed
- - pushed
- - comment_published
- - cleanup_pending
- description: Exact publication step recovery resumes from. `cleanup_pending` means everything is published and only branch release is owed.
status:
type: string
enum:
- - active
- - awaiting_operator
- - released
- description: "`active` records are recovered automatically; `awaiting_operator` records never publish without an explicit operator decision."
- statusReason:
- description: Why the handoff is held or failed closed, for example `cancelled_by_user`, `worktree_missing`, `head_advanced_externally`, `usage_limit_retry_unconfirmed`, `liveness_unavailable`. A mutable diagnostic; see `settlement` for the durable outcome.
+ - pending
+ - running
+ - retrying
+ - succeeded
+ - failed
+ - skipped
+ attempt:
+ type: integer
+ description: Attempts made so far.
+ nextAttemptAt:
+ description: When the next attempt is due, while pending or retrying.
type:
- - string
+ - number
- "null"
- settlement:
- anyOf:
- - type: string
- enum:
- - returned_to_queue
- - usage_limit_retry_scheduling
- - usage_limit_retry_scheduled
- - type: "null"
- description: "Durable owner of the claimed comments once the attempt settles without publishing: `returned_to_queue` (recovery returned them to the pending list), `usage_limit_retry_scheduled` (a scheduled usage-limit retry job owns them; recovery never requeues them) or `usage_limit_retry_scheduling` (the worker stopped around the queue add; held for an operator as `usage_limit_retry_unconfirmed`). Unlike `statusReason`, later diagnostics never overwrite it, so the `noChanges`/`settled` counters stay correct."
- operatorAuthorized:
- type: boolean
- description: Whether an operator explicitly authorized publishing user-cancelled work.
- liveness:
- type: string
- description: Agent container liveness evidence, for example `running`, `stopped`, `not_found` or `unavailable` (recovery fails closed on the latter).
- branchName:
- type: string
- description: PR head branch still attached to the retained worktree until cleanup releases it.
- commitHash:
- description: Commit recovery is bound to publish, once recorded.
+ createdAt:
+ type: number
+ startedAt:
type:
- - string
+ - number
- "null"
- completionCommentUrl:
- description: Delivered completion comment; recovery never overwrites it.
+ finishedAt:
+ type:
+ - number
+ - "null"
+ durationMs:
+ type:
+ - number
+ - "null"
+ timedOut:
+ type: boolean
+ error:
+ description: The latest attempt's redacted error message.
type:
- string
- "null"
- nextAction:
- type: string
- description: Operator guidance for the current state.
- retainedWorktreePath:
- type: string
- description: Host path of the retained worktree. Deliberately admin-only; never shown on member routes.
- updatedAt:
- type: number
- description: Unix milliseconds of the last durable update.
+ logs:
+ type: array
+ items:
+ type: object
+ properties:
+ level:
+ type: string
+ enum:
+ - debug
+ - info
+ - warn
+ - error
+ message:
+ type: string
+ fields:
+ type: object
+ propertyNames:
+ type: string
+ additionalProperties: {}
+ at:
+ type: number
+ required:
+ - level
+ - message
+ - at
+ additionalProperties: false
+ description: The latest attempt's redacted log lines.
+ required:
+ - id
+ - deliveryId
+ - kind
+ - repository
+ - eventSeq
+ - eventType
+ - handler
+ - status
+ - attempt
+ - nextAttemptAt
+ - createdAt
+ - startedAt
+ - finishedAt
+ - durationMs
+ - timedOut
+ - error
+ - logs
+ additionalProperties: false
+ description: "One event delivery to a plugin handler, or a recorded gap. Holds the event id and type only: never the event payload or a credential."
+ PluginDeliveryResponse:
+ type: object
+ properties:
+ delivery:
+ $ref: "#/components/schemas/PluginDelivery"
+ required:
+ - delivery
+ additionalProperties: false
+ PluginDetailResponse:
+ type: object
+ properties:
+ plugin:
+ type: object
+ properties:
+ id:
+ type: string
+ available:
+ type: boolean
+ description: The plugin is in this ProPR version's registry.
+ manifest:
+ anyOf:
+ - type: object
+ properties:
+ id:
+ type: string
+ name:
+ type: string
+ description:
+ type: string
+ version:
+ type: string
+ category:
+ type: string
+ enum:
+ - messaging
+ - issue-tracking
+ - observability
+ - deployment
+ - context
+ - other
+ homepage:
+ type: string
+ permissions:
+ type: array
+ items:
+ type: object
+ properties:
+ name:
+ type: string
+ enum:
+ - events.read
+ - tasks.read
+ - tasks.create
+ - issues.read
+ - issues.create
+ - issues.comment
+ - pull_requests.read
+ - pull_requests.comment
+ - labels.write
+ - repositories.read
+ - notifications.send
+ description:
+ type: string
+ required:
+ - name
+ - description
+ additionalProperties: false
+ connections:
+ type: array
+ items:
+ type: object
+ properties:
+ type:
+ type: string
+ enum:
+ - oauth
+ - token
+ - signing_secret
+ name:
+ type: string
+ label:
+ type: string
+ help:
+ type: string
+ helpUrl:
+ type: string
+ required:
+ - type
+ - name
+ - label
+ - help
+ - helpUrl
+ additionalProperties: {}
+ settings:
+ type: object
+ properties:
+ type:
+ type: string
+ const: object
+ properties:
+ type: object
+ propertyNames:
+ type: string
+ additionalProperties:
+ type: object
+ properties: {}
+ additionalProperties: {}
+ required:
+ type: array
+ items:
+ type: string
+ required:
+ - type
+ - properties
+ - required
+ additionalProperties: false
+ description: "The settings schema: a JSON Schema subset (string, number, boolean, string list, repository map)."
+ outboundHosts:
+ type: array
+ items:
+ type: string
+ contributes:
+ type: object
+ properties:
+ mcpServers:
+ type: array
+ items:
+ type: object
+ properties: {}
+ additionalProperties: {}
+ description: Contributed MCP servers without launch arguments or environment values.
+ skills:
+ type: array
+ items:
+ type: string
+ automationTemplates:
+ type: array
+ items:
+ type: string
+ commands:
+ type: array
+ items:
+ type: object
+ properties:
+ name:
+ type: string
+ description:
+ type: string
+ required:
+ - name
+ - description
+ additionalProperties: false
+ subscriptions:
+ type: array
+ items:
+ type: string
+ webhooks:
+ type: array
+ items:
+ type: object
+ properties:
+ name:
+ type: string
+ description:
+ type: string
+ required:
+ - name
+ - description
+ additionalProperties: false
+ required:
+ - mcpServers
+ - skills
+ - automationTemplates
+ - commands
+ - subscriptions
+ - webhooks
+ additionalProperties: false
+ required:
+ - id
+ - name
+ - description
+ - version
+ - category
+ - homepage
+ - permissions
+ - connections
+ - settings
+ - outboundHosts
+ - contributes
+ additionalProperties: false
+ description: The public projection of the plugin manifest.
+ - type: "null"
+ state:
+ type: string
+ enum:
+ - available
+ - installed
+ - enabled
+ installation:
+ anyOf:
+ - type: object
+ properties:
+ installedVersion:
+ type: string
+ description: The manifest version installed or last approved.
+ enabled:
+ type: boolean
+ settings:
+ type: object
+ propertyNames:
+ type: string
+ additionalProperties:
+ anyOf:
+ - type: string
+ - type: number
+ - type: boolean
+ - type: array
+ items:
+ type: string
+ - type: object
+ propertyNames:
+ type: string
+ additionalProperties:
+ type: string
+ description: Setting values; never secrets, which are connections.
+ repositories:
+ anyOf:
+ - type: array
+ items:
+ type: string
+ - type: "null"
+ description: Repositories (`owner/repo`) the plugin is limited to; null for all repositories.
+ grantedPermissions:
+ type: array
+ items:
+ type: string
+ enum:
+ - events.read
+ - tasks.read
+ - tasks.create
+ - issues.read
+ - issues.create
+ - issues.comment
+ - pull_requests.read
+ - pull_requests.comment
+ - labels.write
+ - repositories.read
+ - notifications.send
+ acknowledgements:
+ type: object
+ propertyNames:
+ type: string
+ additionalProperties:
+ type: object
+ properties:
+ actor:
+ type: string
+ at:
+ type: number
+ package:
+ type: string
+ version:
+ type: string
+ secretEnv:
+ type: array
+ items:
+ type: string
+ required:
+ - actor
+ - at
+ - package
+ - version
+ - secretEnv
+ additionalProperties: false
+ description: "Per package server: who acknowledged that its secret variables are readable inside the task container, and for which package, version and variable names."
+ installedBy:
+ type: string
+ installedAt:
+ type: number
+ updatedBy:
+ type: string
+ updatedAt:
+ type: number
+ required:
+ - installedVersion
+ - enabled
+ - settings
+ - repositories
+ - grantedPermissions
+ - acknowledgements
+ - installedBy
+ - installedAt
+ - updatedBy
+ - updatedAt
+ additionalProperties: false
+ - type: "null"
+ connections:
+ type: array
+ items:
+ type: object
+ properties:
+ name:
+ type: string
+ type:
+ type: string
+ enum:
+ - oauth
+ - token
+ - signing_secret
+ label:
+ type: string
+ state:
+ type: string
+ enum:
+ - connected
+ - not_connected
+ - needs_sign_in
+ description: An `oauth` connection is connected once every managed server using it is signed in through the extension OAuth sign-in, and needs_sign_in after a failed refresh.
+ required:
+ type: boolean
+ description: A `token` connection a contributed MCP server uses; enabling waits for it.
+ setBy:
+ description: Who set the value; for an `oauth` connection, who configured its pre-registered client.
+ type: string
+ setAt:
+ type: number
+ required:
+ - name
+ - type
+ - label
+ - state
+ - required
+ additionalProperties: false
+ needsApproval:
+ type: boolean
+ description: The current manifest asks for permissions or secret acknowledgements not yet approved; the plugin keeps its old grant.
+ pendingPermissions:
+ type: array
+ items:
+ type: string
+ enum:
+ - events.read
+ - tasks.read
+ - tasks.create
+ - issues.read
+ - issues.create
+ - issues.comment
+ - pull_requests.read
+ - pull_requests.comment
+ - labels.write
+ - repositories.read
+ - notifications.send
+ pendingAcknowledgements:
+ type: array
+ items:
+ type: object
+ properties:
+ server:
+ type: string
+ package:
+ type: string
+ version:
+ type: string
+ secretEnv:
+ type: array
+ items:
+ type: string
+ required:
+ - server
+ - package
+ - version
+ - secretEnv
+ additionalProperties: false
+ enableBlockers:
+ type: array
+ items:
+ type: string
+ description: Why enabling is refused right now; empty when it can be enabled.
+ contributions:
+ type: object
+ properties:
+ mcpServers:
+ type: array
+ items:
+ type: object
+ properties:
+ name:
+ type: string
+ description: The server name in the manifest.
+ kind:
+ type: string
+ enum:
+ - https
+ - package
+ managed:
+ anyOf:
+ - type: object
+ properties:
+ kind:
+ type: string
+ const: server
+ name:
+ type: string
+ description: The library item name; prefixed with the plugin id after a name collision.
+ enabled:
+ type: boolean
+ description: The library item's own enabled switch.
+ active:
+ type: boolean
+ description: "False while the plugin is disabled or a secret acknowledgement is pending: the item then never applies."
+ required:
+ - kind
+ - name
+ - enabled
+ - active
+ additionalProperties: false
+ - type: "null"
+ href:
+ type: string
+ description: The extension library route that lists and edits the item.
+ required:
+ - name
+ - kind
+ - managed
+ - href
+ additionalProperties: false
+ skills:
+ type: array
+ items:
+ type: object
+ properties:
+ name:
+ type: string
+ managed:
+ anyOf:
+ - type: object
+ properties:
+ kind:
+ type: string
+ const: skill
+ bundleId:
+ type: string
+ enabled:
+ type: boolean
+ description: The library item's own enabled switch.
+ active:
+ type: boolean
+ description: "False while the plugin is disabled or a secret acknowledgement is pending: the item then never applies."
+ required:
+ - kind
+ - bundleId
+ - enabled
+ - active
+ additionalProperties: false
+ - type: "null"
+ href:
+ type: string
+ required:
+ - name
+ - managed
+ - href
+ additionalProperties: false
+ automationTemplates:
+ type: array
+ items:
+ type: object
+ properties:
+ id:
+ type: string
+ catalogId:
+ type: string
+ description: The template id in the automation template catalog.
+ available:
+ type: boolean
+ description: "Listed in the catalog: the plugin is enabled."
+ href:
+ type: string
+ required:
+ - id
+ - catalogId
+ - available
+ - href
+ additionalProperties: false
+ required:
+ - mcpServers
+ - skills
+ - automationTemplates
+ additionalProperties: false
+ description: The managed extension library items and automation templates the plugin contributes; items are null until the plugin is installed.
+ audit:
+ type: array
+ items:
+ type: object
+ properties:
+ action:
+ type: string
+ enum:
+ - install
+ - update_settings
+ - set_repositories
+ - set_connection
+ - clear_connection
+ - enable
+ - disable
+ - approve
+ - uninstall
+ - start_oauth
+ - complete_oauth
+ - disconnect_oauth
+ - set_oauth_client
+ - clear_oauth_client
+ - handler_attempt
+ - handler_call
+ - handler_refused
+ - retry_delivery
+ - resume_deliveries
+ actor:
+ type: string
+ at:
+ type: number
+ detail:
+ type: object
+ properties: {}
+ additionalProperties: {}
+ required:
+ - action
+ - actor
+ - at
+ - detail
+ additionalProperties: false
+ description: The newest 50 management actions, newest first.
+ required:
+ - id
+ - available
+ - manifest
+ - state
+ - installation
+ - connections
+ - needsApproval
+ - pendingPermissions
+ - pendingAcknowledgements
+ - enableBlockers
+ - audit
+ additionalProperties: false
required:
- - repository
- - pullRequest
- - taskId
- - predecessorTaskIds
- - successorTaskId
- - phase
- - status
- - statusReason
- - settlement
- - operatorAuthorized
- - liveness
- - branchName
- - commitHash
- - completionCommentUrl
- - nextAction
- - retainedWorktreePath
- - updatedAt
+ - plugin
additionalProperties: false
- description: Operator-facing status of one durable follow-up publication handoff.
- Health:
+ PluginEmptyRequest:
+ type: object
+ properties: {}
+ additionalProperties: false
+ PluginEnabledRequest:
type: object
properties:
- status:
- type: string
- const: ok
+ enabled:
+ type: boolean
required:
- - status
+ - enabled
additionalProperties: false
- description: Liveness probe result.
- InstanceDatabaseExport:
+ PluginHealth:
type: object
properties:
- version:
- type: number
- const: 1
- instanceId:
- type: string
- generation:
- type: string
- format: uuid
- pattern: ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$
- operationId:
- type: string
- format: uuid
- pattern: ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$
state:
type: string
enum:
- - running
- - ready
- - failed
- - redeemed
- expiresAt:
- type: string
- format: date-time
- scope:
- type: string
- const: customer_database
- sizeBytes:
- type: number
- minimum: 0
- sha256:
- type: string
- schemaVersion:
+ - healthy
+ - degraded
+ - paused
+ description: "`degraded` after repeated consecutive failures; `paused` after the configured number of them, until an administrator resumes deliveries."
+ consecutiveFailures:
type: integer
- migration:
+ pauseAfterFailures:
+ type: integer
+ lastError:
type:
- string
- "null"
+ lastErrorAt:
+ type:
+ - number
+ - "null"
+ degradedAt:
+ type:
+ - number
+ - "null"
+ pausedAt:
+ type:
+ - number
+ - "null"
required:
- - version
- - instanceId
- - generation
- - operationId
- state
- - expiresAt
- - scope
+ - consecutiveFailures
+ - pauseAfterFailures
+ - lastError
+ - lastErrorAt
+ - degradedAt
+ - pausedAt
additionalProperties: false
- InstanceDatabaseExportRequest:
+ description: Delivery health of a plugin installation.
+ PluginHealthResponse:
type: object
properties:
- instanceId:
- type: string
- generation:
- type: string
- format: uuid
- pattern: ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$
+ health:
+ $ref: "#/components/schemas/PluginHealth"
required:
- - instanceId
- - generation
+ - health
additionalProperties: false
- InstanceRepositoryInventory:
+ PluginList:
type: object
properties:
- version:
- type: number
- const: 1
- instanceId:
- type: string
- state:
- type: string
- enum:
- - complete
- - partial
- - unknown
- - unsupported
- observedAt:
- type: string
- format: date-time
- lastUseAt:
- anyOf:
- - type: string
- format: date-time
- - type: "null"
- repositories:
+ plugins:
type: array
items:
- type: object
- properties:
- id:
- type: string
- name:
- type: string
- category:
- type: string
- enum:
- - database_config
- - repository_caches
- - worktrees
- - salvage
- - logs_artifacts
- - provider_credentials
- - task_history
- estimatedBytes:
- anyOf:
- - type: number
- minimum: 0
- - type: "null"
- protectedReasons:
- type: array
- items:
- type: string
- enum:
- - partial_generated_cleanup
- - busy
- - unknown_use
- - recent_use
- - unknown_ownership
- - unsafe_path
- - private_work
- - worktrees
- - local_history
- - salvage
- - remote_unavailable
- - unknown_git_state
- - changed
- - unsupported
- required:
- - id
- - name
- - category
- - estimatedBytes
- - protectedReasons
- additionalProperties: false
+ $ref: "#/components/schemas/PluginView"
required:
- - version
- - instanceId
- - state
- - observedAt
- - lastUseAt
- - repositories
+ - plugins
additionalProperties: false
- InstanceStorageCapabilities:
+ PluginOAuthClientRequest:
type: object
properties:
- version:
- type: number
- const: 1
- instanceId:
- type: string
- generation:
- type: string
- format: uuid
- pattern: ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$
- features:
- type: array
- items:
- type: string
- enum:
- - repository_inventory
- - repository_cleanup
- - database_export
+ client:
+ anyOf:
+ - type: object
+ properties:
+ clientId:
+ type: string
+ pattern: ^[\x21-\x7e]{1,512}$
+ description: The client id registered with the provider for this instance.
+ clientSecret:
+ description: The client secret (write-only); leave out for a public client.
+ anyOf:
+ - type: string
+ maxLength: 4096
+ - type: "null"
+ required:
+ - clientId
+ additionalProperties: false
+ - type: "null"
+ description: The pre-registered client, or null to clear it.
required:
- - version
- - instanceId
- - generation
- - features
+ - client
additionalProperties: false
- InstanceStorageExecuteRequest:
+ PluginOAuthCompleteRequest:
type: object
properties:
- instanceId:
+ state:
+ type: string
+ description: The `state` the provider returned to the redirect URI.
+ code:
type: string
- confirmation:
+ error:
+ type: string
+ iss:
type: string
- format: uuid
- pattern: ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$
required:
- - instanceId
- - confirmation
+ - state
additionalProperties: false
- InstanceStoragePolicy:
+ PluginOAuthStartResponse:
type: object
properties:
- version:
- type: number
- const: 1
- instanceId:
+ authorizationUrl:
type: string
- policy:
- type: object
- properties:
- enabled:
- type: boolean
- idleHours:
- type: integer
- minimum: 1
- maximum: 8760
- required:
- - enabled
- - idleHours
- additionalProperties: false
+ description: Open this URL to sign in; the provider redirects to the extension OAuth callback.
+ server:
+ type: string
+ description: The managed library server being signed in.
required:
- - version
- - instanceId
- - policy
+ - authorizationUrl
+ - server
additionalProperties: false
- InstanceStoragePolicyRequest:
+ PluginRepositoriesRequest:
type: object
properties:
- enabled:
- type: boolean
- idleHours:
- type: integer
- minimum: 1
- maximum: 8760
- instanceId:
- type: string
+ repositories:
+ anyOf:
+ - maxItems: 256
+ type: array
+ items:
+ type: string
+ - type: "null"
+ description: owner/repo names; null or an empty list for all repositories.
required:
- - enabled
- - idleHours
- - instanceId
+ - repositories
additionalProperties: false
- InstanceStoragePreview:
+ PluginResponse:
type: object
properties:
- version:
- type: number
- const: 1
- instanceId:
- type: string
- confirmation:
- type: string
- format: uuid
- pattern: ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$
- expiresAt:
- type: string
- format: date-time
- categories:
- type: array
- items:
- type: string
- enum:
- - database_config
- - repository_caches
- - worktrees
- - salvage
- - logs_artifacts
- - provider_credentials
- - task_history
- impact:
- type: string
- candidates:
- type: array
- items:
- type: object
- properties:
- id:
- type: string
- name:
- type: string
- category:
- type: string
- enum:
- - database_config
- - repository_caches
- - worktrees
- - salvage
- - logs_artifacts
- - provider_credentials
- - task_history
- estimatedBytes:
- anyOf:
- - type: number
- minimum: 0
- - type: "null"
- protectedReasons:
- type: array
- items:
- type: string
- enum:
- - partial_generated_cleanup
- - busy
- - unknown_use
- - recent_use
- - unknown_ownership
- - unsafe_path
- - private_work
- - worktrees
- - local_history
- - salvage
- - remote_unavailable
- - unknown_git_state
- - changed
- - unsupported
- required:
- - id
- - category
- - estimatedBytes
- - protectedReasons
- additionalProperties: false
+ plugin:
+ $ref: "#/components/schemas/PluginView"
required:
- - version
- - instanceId
- - confirmation
- - expiresAt
- - categories
- - impact
- - candidates
+ - plugin
additionalProperties: false
- InstanceStoragePreviewRequest:
+ PluginSettingsRequest:
type: object
properties:
- instanceId:
- type: string
- categories:
- minItems: 1
- maxItems: 7
- type: array
- items:
- type: string
- enum:
- - database_config
- - repository_caches
- - worktrees
- - salvage
- - logs_artifacts
- - provider_credentials
- - task_history
- taskIds:
- maxItems: 10
- type: array
- items:
+ settings:
+ type: object
+ propertyNames:
type: string
- pattern: ^[A-Za-z0-9_-]{1,255}$
+ additionalProperties:
+ anyOf:
+ - anyOf:
+ - type: string
+ - type: number
+ - type: boolean
+ - type: array
+ items:
+ type: string
+ - type: object
+ propertyNames:
+ type: string
+ additionalProperties:
+ type: string
+ - type: "null"
+ description: The complete settings; validated against the manifest's settings schema. Null values are removed.
required:
- - instanceId
- - categories
+ - settings
additionalProperties: false
- InstanceStorageResult:
+ PluginUninstallRequest:
type: object
properties:
- version:
- type: number
- const: 1
- instanceId:
- type: string
- operationId:
- type: string
- format: uuid
- pattern: ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$
- state:
+ confirm:
type: string
- enum:
- - completed
- - interrupted
- deleted:
- type: array
- items:
- type: string
- retained:
- type: array
- items:
- type: object
- properties:
- id:
- type: string
- reasons:
- type: array
- items:
- type: string
- enum:
- - partial_generated_cleanup
- - busy
- - unknown_use
- - recent_use
- - unknown_ownership
- - unsafe_path
- - private_work
- - worktrees
- - local_history
- - salvage
- - remote_unavailable
- - unknown_git_state
- - changed
- - unsupported
- required:
- - id
- - reasons
- additionalProperties: false
+ description: Must repeat the plugin id.
required:
- - version
- - instanceId
- - operationId
- - state
- - deleted
- - retained
+ - confirm
additionalProperties: false
- InstanceStorageUsage:
+ PluginView:
type: object
properties:
- version:
- type: number
- const: 1
- instanceId:
+ id:
type: string
- observedAt:
+ available:
+ type: boolean
+ description: The plugin is in this ProPR version's registry.
+ manifest:
anyOf:
- - type: string
- format: date-time
+ - type: object
+ properties:
+ id:
+ type: string
+ name:
+ type: string
+ description:
+ type: string
+ version:
+ type: string
+ category:
+ type: string
+ enum:
+ - messaging
+ - issue-tracking
+ - observability
+ - deployment
+ - context
+ - other
+ homepage:
+ type: string
+ permissions:
+ type: array
+ items:
+ type: object
+ properties:
+ name:
+ type: string
+ enum:
+ - events.read
+ - tasks.read
+ - tasks.create
+ - issues.read
+ - issues.create
+ - issues.comment
+ - pull_requests.read
+ - pull_requests.comment
+ - labels.write
+ - repositories.read
+ - notifications.send
+ description:
+ type: string
+ required:
+ - name
+ - description
+ additionalProperties: false
+ connections:
+ type: array
+ items:
+ type: object
+ properties:
+ type:
+ type: string
+ enum:
+ - oauth
+ - token
+ - signing_secret
+ name:
+ type: string
+ label:
+ type: string
+ help:
+ type: string
+ helpUrl:
+ type: string
+ required:
+ - type
+ - name
+ - label
+ - help
+ - helpUrl
+ additionalProperties: {}
+ settings:
+ type: object
+ properties:
+ type:
+ type: string
+ const: object
+ properties:
+ type: object
+ propertyNames:
+ type: string
+ additionalProperties:
+ type: object
+ properties: {}
+ additionalProperties: {}
+ required:
+ type: array
+ items:
+ type: string
+ required:
+ - type
+ - properties
+ - required
+ additionalProperties: false
+ description: "The settings schema: a JSON Schema subset (string, number, boolean, string list, repository map)."
+ outboundHosts:
+ type: array
+ items:
+ type: string
+ contributes:
+ type: object
+ properties:
+ mcpServers:
+ type: array
+ items:
+ type: object
+ properties: {}
+ additionalProperties: {}
+ description: Contributed MCP servers without launch arguments or environment values.
+ skills:
+ type: array
+ items:
+ type: string
+ automationTemplates:
+ type: array
+ items:
+ type: string
+ commands:
+ type: array
+ items:
+ type: object
+ properties:
+ name:
+ type: string
+ description:
+ type: string
+ required:
+ - name
+ - description
+ additionalProperties: false
+ subscriptions:
+ type: array
+ items:
+ type: string
+ webhooks:
+ type: array
+ items:
+ type: object
+ properties:
+ name:
+ type: string
+ description:
+ type: string
+ required:
+ - name
+ - description
+ additionalProperties: false
+ required:
+ - mcpServers
+ - skills
+ - automationTemplates
+ - commands
+ - subscriptions
+ - webhooks
+ additionalProperties: false
+ required:
+ - id
+ - name
+ - description
+ - version
+ - category
+ - homepage
+ - permissions
+ - connections
+ - settings
+ - outboundHosts
+ - contributes
+ additionalProperties: false
+ description: The public projection of the plugin manifest.
- type: "null"
state:
type: string
enum:
- - complete
- - partial
- - unknown
- - unsupported
- scanning:
- type: boolean
- ownedBytes:
+ - available
+ - installed
+ - enabled
+ installation:
anyOf:
- - type: number
- minimum: 0
- - type: "null"
- generatedPayloads:
- description: Last bounded generated-payload proof, protected reasons and durable result. Observations may be stale or partial; unregistered worktrees remain protected.
- type: object
- properties:
- observedAt:
- anyOf:
- - type: string
- format: date-time
- - type: "null"
- reclaimableBytes:
- anyOf:
- - type: number
- minimum: 0
- - type: "null"
- coverage:
- type: string
- candidates:
- type: array
- items:
- type: object
- properties:
- id:
- type: string
- category:
+ - type: object
+ properties:
+ installedVersion:
+ type: string
+ description: The manifest version installed or last approved.
+ enabled:
+ type: boolean
+ settings:
+ type: object
+ propertyNames:
type: string
- enum:
- - database_config
- - repository_caches
- - worktrees
- - salvage
- - logs_artifacts
- - provider_credentials
- - task_history
- estimatedBytes:
+ additionalProperties:
anyOf:
+ - type: string
- type: number
- minimum: 0
- - type: "null"
- protectedReasons:
- type: array
- items:
- type: string
- enum:
- - partial_generated_cleanup
- - busy
- - unknown_use
- - recent_use
- - unknown_ownership
- - unsafe_path
- - private_work
- - worktrees
- - local_history
- - salvage
- - remote_unavailable
- - unknown_git_state
- - changed
- - unsupported
- required:
- - id
- - category
- - estimatedBytes
- - protectedReasons
- additionalProperties: false
- lastCleanup:
- anyOf:
- - type: object
+ - type: boolean
+ - type: array
+ items:
+ type: string
+ - type: object
+ propertyNames:
+ type: string
+ additionalProperties:
+ type: string
+ description: Setting values; never secrets, which are connections.
+ repositories:
+ anyOf:
+ - type: array
+ items:
+ type: string
+ - type: "null"
+ description: Repositories (`owner/repo`) the plugin is limited to; null for all repositories.
+ grantedPermissions:
+ type: array
+ items:
+ type: string
+ enum:
+ - events.read
+ - tasks.read
+ - tasks.create
+ - issues.read
+ - issues.create
+ - issues.comment
+ - pull_requests.read
+ - pull_requests.comment
+ - labels.write
+ - repositories.read
+ - notifications.send
+ acknowledgements:
+ type: object
propertyNames:
type: string
- additionalProperties: {}
- - type: "null"
- required:
- - observedAt
- - reclaimableBytes
- - coverage
- - lastCleanup
- additionalProperties: false
- categories:
+ additionalProperties:
+ type: object
+ properties:
+ actor:
+ type: string
+ at:
+ type: number
+ package:
+ type: string
+ version:
+ type: string
+ secretEnv:
+ type: array
+ items:
+ type: string
+ required:
+ - actor
+ - at
+ - package
+ - version
+ - secretEnv
+ additionalProperties: false
+ description: "Per package server: who acknowledged that its secret variables are readable inside the task container, and for which package, version and variable names."
+ installedBy:
+ type: string
+ installedAt:
+ type: number
+ updatedBy:
+ type: string
+ updatedAt:
+ type: number
+ required:
+ - installedVersion
+ - enabled
+ - settings
+ - repositories
+ - grantedPermissions
+ - acknowledgements
+ - installedBy
+ - installedAt
+ - updatedBy
+ - updatedAt
+ additionalProperties: false
+ - type: "null"
+ connections:
type: array
items:
type: object
properties:
- category:
+ name:
+ type: string
+ type:
type: string
enum:
- - database_config
- - repository_caches
- - worktrees
- - salvage
- - logs_artifacts
- - provider_credentials
- - task_history
- bytes:
- anyOf:
- - type: number
- minimum: 0
- - type: "null"
+ - oauth
+ - token
+ - signing_secret
+ label:
+ type: string
state:
type: string
enum:
- - complete
- - partial
- - unknown
- - unsupported
+ - connected
+ - not_connected
+ - needs_sign_in
+ description: An `oauth` connection is connected once every managed server using it is signed in through the extension OAuth sign-in, and needs_sign_in after a failed refresh.
+ required:
+ type: boolean
+ description: A `token` connection a contributed MCP server uses; enabling waits for it.
+ setBy:
+ description: Who set the value; for an `oauth` connection, who configured its pre-registered client.
+ type: string
+ setAt:
+ type: number
required:
- - category
- - bytes
+ - name
+ - type
+ - label
- state
+ - required
additionalProperties: false
- filesystems:
+ needsApproval:
+ type: boolean
+ description: The current manifest asks for permissions or secret acknowledgements not yet approved; the plugin keeps its old grant.
+ pendingPermissions:
+ type: array
+ items:
+ type: string
+ enum:
+ - events.read
+ - tasks.read
+ - tasks.create
+ - issues.read
+ - issues.create
+ - issues.comment
+ - pull_requests.read
+ - pull_requests.comment
+ - labels.write
+ - repositories.read
+ - notifications.send
+ pendingAcknowledgements:
type: array
items:
type: object
properties:
- id:
+ server:
type: string
- totalBytes:
- type: number
- minimum: 0
- availableBytes:
- type: number
- minimum: 0
+ package:
+ type: string
+ version:
+ type: string
+ secretEnv:
+ type: array
+ items:
+ type: string
required:
- - id
- - totalBytes
- - availableBytes
+ - server
+ - package
+ - version
+ - secretEnv
additionalProperties: false
+ enableBlockers:
+ type: array
+ items:
+ type: string
+ description: Why enabling is refused right now; empty when it can be enabled.
+ contributions:
+ type: object
+ properties:
+ mcpServers:
+ type: array
+ items:
+ type: object
+ properties:
+ name:
+ type: string
+ description: The server name in the manifest.
+ kind:
+ type: string
+ enum:
+ - https
+ - package
+ managed:
+ anyOf:
+ - type: object
+ properties:
+ kind:
+ type: string
+ const: server
+ name:
+ type: string
+ description: The library item name; prefixed with the plugin id after a name collision.
+ enabled:
+ type: boolean
+ description: The library item's own enabled switch.
+ active:
+ type: boolean
+ description: "False while the plugin is disabled or a secret acknowledgement is pending: the item then never applies."
+ required:
+ - kind
+ - name
+ - enabled
+ - active
+ additionalProperties: false
+ - type: "null"
+ href:
+ type: string
+ description: The extension library route that lists and edits the item.
+ required:
+ - name
+ - kind
+ - managed
+ - href
+ additionalProperties: false
+ skills:
+ type: array
+ items:
+ type: object
+ properties:
+ name:
+ type: string
+ managed:
+ anyOf:
+ - type: object
+ properties:
+ kind:
+ type: string
+ const: skill
+ bundleId:
+ type: string
+ enabled:
+ type: boolean
+ description: The library item's own enabled switch.
+ active:
+ type: boolean
+ description: "False while the plugin is disabled or a secret acknowledgement is pending: the item then never applies."
+ required:
+ - kind
+ - bundleId
+ - enabled
+ - active
+ additionalProperties: false
+ - type: "null"
+ href:
+ type: string
+ required:
+ - name
+ - managed
+ - href
+ additionalProperties: false
+ automationTemplates:
+ type: array
+ items:
+ type: object
+ properties:
+ id:
+ type: string
+ catalogId:
+ type: string
+ description: The template id in the automation template catalog.
+ available:
+ type: boolean
+ description: "Listed in the catalog: the plugin is enabled."
+ href:
+ type: string
+ required:
+ - id
+ - catalogId
+ - available
+ - href
+ additionalProperties: false
+ required:
+ - mcpServers
+ - skills
+ - automationTemplates
+ additionalProperties: false
+ description: The managed extension library items and automation templates the plugin contributes; items are null until the plugin is installed.
required:
- - version
- - instanceId
- - observedAt
+ - id
+ - available
+ - manifest
- state
- - scanning
- - ownedBytes
- - categories
- - filesystems
- additionalProperties: false
- JsonObject:
- type: object
- properties: {}
- additionalProperties: {}
- description: A JSON object whose fields are not documented yet.
- LegacyError:
- type: object
- properties:
- error:
- type: string
- description: Human-readable error message.
- code:
- description: Machine-readable code, present on authentication and some validation errors.
- type: string
- message:
- description: Longer explanation, present on some authentication errors.
- type: string
- required:
- - error
- additionalProperties: {}
- description: "The ad-hoc error shape most existing routes return. Operations that use it carry `x-legacy-error: true`."
- NotificationUnreadCount:
- type: object
- properties:
- unreadCount:
- type: integer
- minimum: 0
- required:
- - unreadCount
+ - installation
+ - connections
+ - needsApproval
+ - pendingPermissions
+ - pendingAcknowledgements
+ - enableBlockers
additionalProperties: false
+ description: A plugin with its installation state on this instance.
PullRequestReadiness:
type: object
properties:
@@ -12424,9 +14660,10 @@ components:
- pull_request
- epic_queue
- plan_issue
+ - task
type:
type: string
- description: Event type within the domain, for example `head_changed`, `review_scored`, `merged`, `advanced` or `status_changed`.
+ description: Event type within the domain, for example `head_changed`, `review_scored`, `merged`, `advanced`, `status_changed` or `queued`.
repository:
type: string
pullRequest:
@@ -12437,7 +14674,7 @@ components:
type: string
entityId:
type: string
- description: PR number, epic execution id or plan issue id, as text.
+ description: PR number, epic execution id, plan issue id or task id, as text.
head:
description: Head SHA the event was recorded for, when it concerns one.
type: string
@@ -12446,7 +14683,7 @@ components:
propertyNames:
type: string
additionalProperties: {}
- description: Identifiers and small scalars only; never PR bodies, comments, diffs or logs.
+ description: Identifiers and small scalars only; never PR bodies, comments, diffs, prompts, tool output or logs.
occurredAt:
type: string
format: date-time
diff --git a/eslint.config.js b/eslint.config.js
index 4b19e1ec88..ab31c2501b 100644
--- a/eslint.config.js
+++ b/eslint.config.js
@@ -2,6 +2,33 @@ import js from '@eslint/js'
import globals from 'globals'
import tseslint from 'typescript-eslint'
+/**
+ * Plugin code (`packages/plugins/**`: handlers, the registry and the test kit)
+ * may import nothing from ProPR but `@propr/shared`, which holds the plugin SDK
+ * types. Handlers touch ProPR only through `ctx`, so the same plugin can later
+ * run out of process.
+ */
+const PLUGIN_IMPORT_MESSAGE = 'Plugin code may import only @propr/shared (including the plugin SDK types); use ctx to reach ProPR.'
+// From packages/plugins//, sibling packages are two levels up and the repository root three.
+const OTHER_PACKAGE = '^(?:\\.\\./){2,}(?:core|api|cli|client|local-setup|shared)(?:/|$)'
+const ROOT_SOURCES = '^(?:\\.\\./){3,}(?:src|propr-ui|packages|scripts)(?:/|$)'
+const pluginImportRules = {
+ 'no-restricted-imports': ['error', {
+ patterns: [
+ { group: ['@propr/*', '!@propr/shared', '!@propr/shared/*'], message: PLUGIN_IMPORT_MESSAGE },
+ { group: ['propr-ui', 'propr-ui/*'], message: PLUGIN_IMPORT_MESSAGE },
+ // Relative paths out of the plugins package into another ProPR package or the root sources.
+ { regex: OTHER_PACKAGE, message: PLUGIN_IMPORT_MESSAGE },
+ { regex: ROOT_SOURCES, message: PLUGIN_IMPORT_MESSAGE },
+ ],
+ }],
+ 'no-restricted-syntax': ['error', ...[
+ 'ImportExpression[source.value=/^@propr\\/(?!shared(?:$|\\/))/]',
+ `ImportExpression[source.value=/${OTHER_PACKAGE.replaceAll('/', '\\/')}/]`,
+ `ImportExpression[source.value=/${ROOT_SOURCES.replaceAll('/', '\\/')}/]`,
+ ].map(selector => ({ selector, message: PLUGIN_IMPORT_MESSAGE }))],
+}
+
export default [
{ ignores: ['node_modules', '**/dist', 'coverage'] },
{
@@ -26,4 +53,14 @@ export default [
...config,
files: ['src/**/*.ts'],
})),
+ {
+ files: ['packages/plugins/**/*.ts', 'packages/plugins/**/*.js', 'packages/plugins/**/*.mjs'],
+ languageOptions: {
+ parser: tseslint.parser,
+ ecmaVersion: 2022,
+ sourceType: 'module',
+ globals: { ...globals.node },
+ },
+ rules: pluginImportRules,
+ },
]
diff --git a/package-lock.json b/package-lock.json
index e12c67ca50..2aedc6bc14 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -3101,6 +3101,10 @@
"resolved": "packages/local-setup",
"link": true
},
+ "node_modules/@propr/plugins": {
+ "resolved": "packages/plugins",
+ "link": true
+ },
"node_modules/@propr/shared": {
"resolved": "packages/shared",
"link": true
@@ -16016,6 +16020,21 @@
"node": ">=22"
}
},
+ "packages/plugins": {
+ "name": "@propr/plugins",
+ "version": "0.9.1",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@propr/shared": "^0.9.1"
+ },
+ "devDependencies": {
+ "@types/node": "^22.10.0",
+ "typescript": "^5.9.3"
+ },
+ "engines": {
+ "node": ">=22"
+ }
+ },
"packages/shared": {
"name": "@propr/shared",
"version": "0.9.1",
diff --git a/package.json b/package.json
index 5cde9cfbf0..980f8819b6 100644
--- a/package.json
+++ b/package.json
@@ -29,7 +29,7 @@
"lint": "eslint src/",
"typecheck": "tsc --noEmit",
"test": "node --test",
- "test:prepare": "npm run build --workspace=packages/shared && npm run build --workspace=packages/client && npm run build --workspace=packages/core && npm run build --workspace=packages/local-setup && npm run build --workspace=packages/cli",
+ "test:prepare": "npm run build --workspace=packages/shared && npm run build --workspace=packages/plugins && npm run build --workspace=packages/client && npm run build --workspace=packages/core && npm run build --workspace=packages/local-setup && npm run build --workspace=packages/cli",
"test:server": "node scripts/run-test-suite.mjs",
"test:full:prepared": "npm run test:server",
"test:full": "npm run test:prepare && npm run test:full:prepared",
@@ -117,6 +117,7 @@
"tips:check": "node scripts/generate-usage-tips.mjs --check",
"automation-templates:generate": "tsx scripts/generate-automation-templates.ts",
"automation-templates:check": "tsx scripts/generate-automation-templates.ts --check",
+ "plugins:check": "tsx scripts/check-plugins.mjs",
"docs:check-serving": "node scripts/check-docs-static-serving.mjs docs/build"
},
"keywords": [],
diff --git a/packages/api/mcp/tools.ts b/packages/api/mcp/tools.ts
index bbb9ad5a90..8a4c51b994 100644
--- a/packages/api/mcp/tools.ts
+++ b/packages/api/mcp/tools.ts
@@ -36,6 +36,7 @@ import { planEpicDispatch, type EpicDispatch } from './epicDispatchPolicy.js';
import { addPullRequestTools } from './toolsPullRequests.js';
import { addContextTools } from './toolsContext.js';
import { addAdministrationTools } from './toolsAdministration.js';
+import { addPluginTools } from './toolsPlugins.js';
import { addMaintenanceTools } from './toolsMaintenance.js';
import { addArtifactTools } from './toolsArtifacts.js';
import { addManagementTools } from './toolsManagement.js';
@@ -194,6 +195,7 @@ export function createToolCatalog(deps: ToolDeps): McpTool[] {
addRepositoryEventTools(tools, deps);
addContextTools(tools, deps);
addManagementTools(tools, deps, { todos, config, runtime });
+ addPluginTools(tools, deps);
addNotificationTools(tools, deps, notifications);
addActivityTools(tools, deps);
addAdmissionTools(tools, deps);
diff --git a/packages/api/mcp/toolsAutomations.ts b/packages/api/mcp/toolsAutomations.ts
index 9c7cf444ae..115b8b3b2c 100644
--- a/packages/api/mcp/toolsAutomations.ts
+++ b/packages/api/mcp/toolsAutomations.ts
@@ -16,7 +16,8 @@ import {
type TriggerAgentRunInput,
type TriggerAgentRunResult,
} from '@propr/core';
-import { AUTOMATION_ACTION_OPERATOR_NOTE_MAX_CHARS, AUTOMATION_DEFINITION_CONTRACT, AUTOMATION_TEMPLATE_CATALOG, type AutomationRunState } from '@propr/shared';
+import { AUTOMATION_ACTION_OPERATOR_NOTE_MAX_CHARS, AUTOMATION_DEFINITION_CONTRACT, type AutomationRunState } from '@propr/shared';
+import { automationTemplateCatalog } from '../../core/src/plugins/pluginTemplates.js';
import { createAutomationDefinitionRoutes, publicAutomationDefinition, publicAutomationRun } from '../routes/automationDefinitionRoutes.js';
import { callWorkflow, type WorkflowHandler } from './adapter.js';
import { AGENT_RUN_MCP_CLIENT_ID } from './agentRunGrants.js';
@@ -254,8 +255,8 @@ export function addAutomationRunTools(tools: McpTool[], deps: ToolDeps): void {
tools.push({ name: 'get_automation_definition_contract', description: 'Read the automation definition contract shared by the API, MCP, UI and CLI: capabilities, autonomy modes, run triggers and states, event triggers (events, reason kinds, the per-issue run bound), field limits and schedule rules.', scope: 'read', readOnly: true,
schema: z.object({}).strict(), run: async () => ok(AUTOMATION_DEFINITION_CONTRACT) });
- tools.push({ name: 'list_automation_templates', description: 'List the shipped automation templates (for example daily issue triage): id, name, description, category, prompt, capabilities, suggested schedule, autonomy (always dry_run) and what to review before saving. A template creates nothing; the user starts one from New automation in the Web UI and saves it.', scope: 'read', readOnly: true,
- schema: z.object({}).strict(), run: async () => ok(AUTOMATION_TEMPLATE_CATALOG) });
+ tools.push({ name: 'list_automation_templates', description: 'List the automation templates (for example daily issue triage), shipped ones and those of enabled plugins (marked with plugin id and name): id, name, description, category, prompt, capabilities, suggested schedule, autonomy (always dry_run) and what to review before saving. A template creates nothing; the user starts one from New automation in the Web UI and saves it.', scope: 'read', readOnly: true,
+ schema: z.object({}).strict(), run: async () => ok(await automationTemplateCatalog(deps.db)) });
tools.push({ name: 'list_automation_runs', description: `List the runs of one of your automations newest first: trigger (with triggerEvent, the event that started an event run), state, timing and skip or failure reasons. Report bodies are omitted; read one with get_automation_run. ${NO_CHANGE_NOTE}`, scope: 'read', readOnly: true,
schema: z.object({ ...definitionShape, offset: z.number().int().min(0).max(100000).default(0), limit: z.number().int().min(1).max(100).default(20) }).strict(),
diff --git a/packages/api/mcp/toolsPlugins.ts b/packages/api/mcp/toolsPlugins.ts
new file mode 100644
index 0000000000..93f42f135f
--- /dev/null
+++ b/packages/api/mcp/toolsPlugins.ts
@@ -0,0 +1,37 @@
+import { z } from 'zod';
+import type { PluginView } from '@propr/shared';
+import { PluginError, PluginStore } from '../../core/src/plugins/pluginStore.js';
+import { McpError } from './config.js';
+import { type McpTool, type ToolDeps, ok } from './tools.js';
+
+/** The compact list row: state and what needs the administrator, without settings or the manifest body. */
+const summary = (plugin: PluginView) => ({
+ id: plugin.id, name: plugin.manifest?.name ?? plugin.id, category: plugin.manifest?.category ?? null,
+ availableVersion: plugin.manifest?.version ?? null, installedVersion: plugin.installation?.installedVersion ?? null,
+ available: plugin.available, state: plugin.state, needsApproval: plugin.needsApproval,
+ pendingPermissions: plugin.pendingPermissions, enableBlockers: plugin.enableBlockers,
+ connections: plugin.connections.map(c => ({ name: c.name, type: c.type, state: c.state, required: c.required })),
+});
+
+async function read(run: () => Promise): Promise {
+ try { return await run(); } catch (error) {
+ if (!(error instanceof PluginError)) throw error;
+ throw new McpError(error.status === 404 ? 'NOT_FOUND' : error.status === 403 ? 'FORBIDDEN' : 'PRECONDITION_FAILED', error.message, error.status);
+ }
+}
+
+/** Read-only plugin state for instance settings administrators. Install and configure in Settings or through the REST API. */
+export function addPluginTools(tools: McpTool[], deps: ToolDeps): void {
+ const store = new PluginStore(deps.db);
+ const permission = 'instance.manage_settings' as const;
+ tools.push({
+ name: 'list_plugins', description: 'List the plugins this ProPR offers and their installation state: installed, enabled, Needs approval, why enabling is refused and connection states. Never returns secrets.',
+ scope: 'manage', permission, readOnly: true, schema: z.object({}).strict(),
+ run: async ({ principal }) => ok({ plugins: (await read(() => store.list(principal.user.id))).map(summary) }),
+ });
+ tools.push({
+ name: 'get_plugin', description: 'Read one plugin: its manifest, settings, repository limit, granted and pending permissions, connection states, pending secret acknowledgements and recent audit events. Never returns secrets.',
+ scope: 'manage', permission, readOnly: true, schema: z.object({ pluginId: z.string().min(1).max(48).describe('Plugin id, for example example-pack.') }).strict(),
+ run: async ({ principal, args }) => ok({ plugin: await read(() => store.get(principal.user.id, args.pluginId)) }),
+ });
+}
diff --git a/packages/api/mcp/toolsRepositoryEvents.ts b/packages/api/mcp/toolsRepositoryEvents.ts
index 36a73c2dd3..829bb760df 100644
--- a/packages/api/mcp/toolsRepositoryEvents.ts
+++ b/packages/api/mcp/toolsRepositoryEvents.ts
@@ -55,7 +55,7 @@ const CURSOR_CONTRACT = 'Delivery is at-least-once and ordered: pass the cursor
export function addRepositoryEventTools(tools: McpTool[], deps: ToolDeps): void {
tools.push({
name: 'list_repository_events',
- description: `List durable change events of one repository after a cursor: pull_request (opened, head_changed, review_scored, ultrafix_started, ultrafix_ended, auto_merge_decision, merged, closed), epic_queue (started, advanced, blocked, completed) and plan_issue (status_changed). Filter by domains, types, pullRequest and planId. Returns {events, cursor, truncated}; truncated means more matching events follow the cursor. ${CURSOR_CONTRACT}`,
+ description: `List durable change events of one repository after a cursor: pull_request (opened, head_changed, review_scored, ultrafix_started, ultrafix_ended, auto_merge_decision, merged, closed), epic_queue (started, advanced, blocked, completed), plan_issue (status_changed) and task (queued, started, completed, failed, cancelled; entityId is the task id). Filter by domains, types, pullRequest and planId. Returns {events, cursor, truncated}; truncated means more matching events follow the cursor. ${CURSOR_CONTRACT}`,
scope: 'read',
readOnly: true,
schema: z.object(filterShape()).strict(),
diff --git a/packages/api/openapi/registeredRoutes.ts b/packages/api/openapi/registeredRoutes.ts
index aa0a109425..3d753cccef 100644
--- a/packages/api/openapi/registeredRoutes.ts
+++ b/packages/api/openapi/registeredRoutes.ts
@@ -1,4 +1,5 @@
import { createStorageRouteEntries } from '../instanceStorageRouteRegistry.js';
+import { createPluginRouteEntries } from '../pluginRouteRegistry.js';
import type { RequestHandler } from 'express';
import type { InstancePermission } from '@propr/shared';
import {
@@ -42,6 +43,7 @@ export function listRegistryRouteEntries(): RouteEntry[] {
return [
...createOperationalRouteEntries(placeholderDeps()),
...createStorageRouteEntries(placeholderDeps()),
+ ...createPluginRouteEntries(placeholderDeps(), placeholderDeps()),
...createMemberCatalogRouteEntries(placeholderDeps()),
...createManagementRouteEntries(placeholderDeps()),
];
diff --git a/packages/api/openapi/routeDocs.ts b/packages/api/openapi/routeDocs.ts
index 02f72f5f11..baddbf3b6c 100644
--- a/packages/api/openapi/routeDocs.ts
+++ b/packages/api/openapi/routeDocs.ts
@@ -4,6 +4,7 @@ import { PLAN_QUEUE_ROUTE_DOCS } from './routeDocsPlanQueue.js';
import { STATS_ROUTE_DOCS } from './routeDocsStats.js';
import { SYSTEM_ROUTE_DOCS } from './routeDocsSystem.js';
import { EXTENSION_OAUTH_ROUTE_DOCS } from './routeDocsExtensionOAuth.js';
+import { PLUGIN_ROUTE_DOCS } from './routeDocsPlugins.js';
import { TASK_ROUTE_DOCS } from './routeDocsTasks.js';
import type { RouteDoc } from './types.js';
@@ -18,6 +19,7 @@ export const ROUTE_DOCS: Readonly> = {
...STATS_ROUTE_DOCS,
...SYSTEM_ROUTE_DOCS,
...EXTENSION_OAUTH_ROUTE_DOCS,
+ ...PLUGIN_ROUTE_DOCS,
...TASK_ROUTE_DOCS,
...PLAN_QUEUE_ROUTE_DOCS,
};
diff --git a/packages/api/openapi/routeDocsPlugins.ts b/packages/api/openapi/routeDocsPlugins.ts
new file mode 100644
index 0000000000..37fdccdc38
--- /dev/null
+++ b/packages/api/openapi/routeDocsPlugins.ts
@@ -0,0 +1,54 @@
+import type { z } from 'zod';
+import {
+ PluginAcknowledgementRequest, PluginConnectionRequest, PluginDetailResponse, PluginEnabledRequest, PluginList, PluginRepositoriesRequest, PluginResponse,
+ PluginSettingsRequest, PluginUninstallRequest, PluginEmptyRequest, PluginOAuthClientRequest, PluginOAuthCompleteRequest, PluginOAuthStartResponse,
+ PluginActivityResponse, PluginDeliveryResponse, PluginHealthResponse,
+} from './schemasPlugins.js';
+import type { RouteDoc } from './types.js';
+
+/** Plugin installation routes (docs/features/plugins#installing-a-plugin). */
+const BASE = '/api/instance/plugins/v1';
+const description = 'Durable instance administrator with instance.manage_settings required. Mutations require application/json and X-ProPR-Plugins: 1, and are recorded in the plugin audit trail. Connection secrets are never returned.';
+const headers = [{ name: 'X-ProPR-Plugins', description: 'Must equal 1; browser calls require an authorized CORS preflight.', required: true }];
+const pluginId = { pluginId: 'Plugin id, for example `example-pack`.' };
+const oauthParams = { ...pluginId, connectionName: 'An `oauth` connection name from the manifest.' };
+const changed = { 200: { description: 'The plugin\'s new state.', schema: PluginResponse } };
+const mutation = (operationId: string, summary: string, schema: z.ZodType, extra: Partial = {}): RouteDoc => ({
+ operationId, summary, description, tags: ['Configuration'], pathParams: pluginId, headers, requestBody: { schema }, responses: changed, ...extra,
+});
+
+export const PLUGIN_ROUTE_DOCS: Record = {
+ [`GET ${BASE}`]: {
+ operationId: 'listPlugins', summary: 'List the plugins of this ProPR\'s registry, and installed plugins an upgrade removed from it, with their installation state.', description, tags: ['Configuration'],
+ responses: { 200: { description: 'Plugins in catalog order.', schema: PluginList } },
+ },
+ [`GET ${BASE}/:pluginId`]: {
+ operationId: 'getPlugin', summary: 'Read one plugin: manifest, settings, connection states, pending approvals and acknowledgements, and its newest audit events.', description, tags: ['Configuration'],
+ pathParams: pluginId, responses: { 200: { description: 'The plugin.', schema: PluginDetailResponse } },
+ },
+ [`POST ${BASE}/:pluginId/install`]: mutation('installPlugin', 'Install a registry plugin, disabled, with its default settings; grants the manifest\'s permissions. A package server with secret variables must be acknowledged.', PluginAcknowledgementRequest),
+ [`PUT ${BASE}/:pluginId/settings`]: mutation('updatePluginSettings', 'Replace the settings, validated against the manifest\'s settings schema. Required settings cannot be removed while the plugin is enabled.', PluginSettingsRequest),
+ [`PUT ${BASE}/:pluginId/repositories`]: mutation('setPluginRepositories', 'Limit the plugin to repositories, or clear the limit (all repositories).', PluginRepositoriesRequest),
+ [`PUT ${BASE}/:pluginId/connections/:connectionName`]: mutation('setPluginConnection', 'Set or clear a `token` or `signing_secret` connection. The secret is sealed and write-only.', PluginConnectionRequest, {
+ pathParams: { ...pluginId, connectionName: 'Connection name from the manifest.' },
+ }),
+ [`PUT ${BASE}/:pluginId/enabled`]: mutation('setPluginEnabled', 'Enable or disable. Enabling is refused (409, with the reasons) while a required setting, a required token connection or a secret acknowledgement is missing. Enabling activates the plugin\'s managed extension library items and lists its automation templates; disabling deactivates and hides them and keeps settings and connections.', PluginEnabledRequest),
+ [`POST ${BASE}/:pluginId/approve`]: mutation('approvePlugin', 'Approve the permission set of the current manifest after an upgrade, with the secret acknowledgements it needs again.', PluginAcknowledgementRequest),
+ [`POST ${BASE}/:pluginId/uninstall`]: mutation('uninstallPlugin', 'Uninstall: deletes the installation, its settings, its connections and its managed extension library items. Refused (409, naming them) while an automation adds a managed item explicitly. The audit trail stays.', PluginUninstallRequest),
+ [`PUT ${BASE}/:pluginId/connections/:connectionName/oauth/client`]: mutation('setPluginOAuthClient', 'Configure or clear the pre-registered OAuth client of an `oauth` connection whose `registrations` permit `preregistered` (409 otherwise). The client id becomes the managed MCP servers\' client id and the secret is sealed and write-only; changing the client discards their OAuth connections.', PluginOAuthClientRequest, { pathParams: oauthParams }),
+ [`POST ${BASE}/:pluginId/connections/:connectionName/oauth/start`]: mutation('startPluginOAuth', 'Start the extension OAuth sign-in of an `oauth` connection for its managed MCP server, bound to this browser session. ProPR identifies itself as it does for library servers.', PluginEmptyRequest, {
+ pathParams: oauthParams, responses: { 200: { description: 'The authorization URL to open.', schema: PluginOAuthStartResponse } },
+ }),
+ [`POST ${BASE}/:pluginId/connections/:connectionName/oauth/complete`]: mutation('completePluginOAuth', 'Complete a sign-in of this connection with the state and code the provider returned, in the session that started it. The extension OAuth callback completes it too.', PluginOAuthCompleteRequest, { pathParams: oauthParams }),
+ [`GET ${BASE}/:pluginId/activity`]: {
+ operationId: 'getPluginActivity', summary: 'Read the plugin\'s event delivery health (healthy, degraded or paused, with the last error) and its newest delivery records: time, event id and type, handler, outcome, duration, attempt, redacted error and log lines. Records are kept for 14 days.', description, tags: ['Configuration'],
+ pathParams: pluginId, responses: { 200: { description: 'Health and delivery records, newest first.', schema: PluginActivityResponse } },
+ },
+ [`POST ${BASE}/:pluginId/activity/:deliveryId/retry`]: mutation('retryPluginDelivery', 'Retry a failed event delivery: it gets one more attempt, with the same delivery id, the next time the dispatcher runs (after Resume while deliveries are paused). Refused (409) for a delivery that has not failed.', PluginEmptyRequest, {
+ pathParams: { ...pluginId, deliveryId: 'The activity record id (`id` of a delivery).' }, responses: { 200: { description: 'The delivery, pending again.', schema: PluginDeliveryResponse } },
+ }),
+ [`POST ${BASE}/:pluginId/resume`]: mutation('resumePluginDeliveries', 'Resume event deliveries paused after repeated failures: the failure count starts over, Degraded clears and waiting retries are due at once. Refused (409) when deliveries are not paused.', PluginEmptyRequest, {
+ responses: { 200: { description: 'The new delivery health.', schema: PluginHealthResponse } },
+ }),
+ [`POST ${BASE}/:pluginId/connections/:connectionName/oauth/disconnect`]: mutation('disconnectPluginOAuth', 'Disconnect an `oauth` connection: its managed servers\' tokens are revoked where possible and deleted, and later runs leave the servers out.', PluginEmptyRequest, { pathParams: oauthParams }),
+};
diff --git a/packages/api/openapi/routeDocsTasks.ts b/packages/api/openapi/routeDocsTasks.ts
index e7e1320976..552c427c4c 100644
--- a/packages/api/openapi/routeDocsTasks.ts
+++ b/packages/api/openapi/routeDocsTasks.ts
@@ -276,7 +276,7 @@ export const TASK_ROUTE_DOCS: Record = {
'GET /api/repository-events': {
operationId: 'listRepositoryEvents',
summary: 'List repository change events after a cursor',
- description: 'Reads the durable change-event journal of one repository (pull request, epic queue and plan issue changes) after `afterCursor`, in journal order, optionally filtered by domain, type, pull request and plan. Delivery is at-least-once: resume with the cursor of the last event you processed. A call without a cursor starts at the current end of the journal and returns the current cursor with no history, unless `fromStart=true`. The caller needs read access to the repository on GitHub (the check MCP `list_pull_requests` applies); events of other repositories are never returned.',
+ description: 'Reads the durable change-event journal of one repository (pull request, epic queue, plan issue and task changes) after `afterCursor`, in journal order, optionally filtered by domain, type, pull request and plan. Delivery is at-least-once: resume with the cursor of the last event you processed. A call without a cursor starts at the current end of the journal and returns the current cursor with no history, unless `fromStart=true`. The caller needs read access to the repository on GitHub (the check MCP `list_pull_requests` applies); events of other repositories are never returned.',
tags: ['Repositories'],
query: RepositoryEventsQuery,
errors: 'envelope',
diff --git a/packages/api/openapi/schemas.ts b/packages/api/openapi/schemas.ts
index 09f6e6d2a8..4bfc2f6cf0 100644
--- a/packages/api/openapi/schemas.ts
+++ b/packages/api/openapi/schemas.ts
@@ -185,7 +185,7 @@ export const TaskPage = component('TaskPage', z.object({
totalRuns: z.number().int().optional().describe('With `groupBy=task`: matching runs across all tasks.'),
}), 'A page of task runs.');
-export const ExtensionLibraryInput = component('ExtensionLibraryInput', extensionLibraryInputSchema as unknown as z.ZodType, 'Strict shared v1 library contract: up to 32 servers and 32 skill bundle references. Each item has enabled, optional repositories (owner/repo; empty means every repository) and agents (aliases; empty means every agent) limits, and autoApply (default true; false applies the item only where an automation adds it). Operator catalog descriptors accept only immutable catalogId/digest and selected tools/capability; customer commands, arguments, environment and mount paths are rejected.');
+export const ExtensionLibraryInput = component('ExtensionLibraryInput', extensionLibraryInputSchema as unknown as z.ZodType, 'Strict shared v1 library contract: up to 32 servers and 32 skill bundle references. Each item has enabled, optional repositories (owner/repo; empty means every repository) and agents (aliases; empty means every agent) limits, and autoApply (default true; false applies the item only where an automation adds it). Items an installed plugin contributes carry managedBy (plugin id, manifest item name, active, and for OAuth servers the client registration methods the plugin permits at sign-in); their definition is read-only and only enabled, the limits and autoApply can change, and they cannot be added or removed through this route. Operator catalog descriptors accept only immutable catalogId/digest and selected tools/capability; customer commands, arguments, environment and mount paths are rejected.');
export const ExtensionLibrarySave = component('ExtensionLibrarySave', z.strictObject({ input: ExtensionLibraryInput, expectedRevision: z.number().int().min(0), secrets: z.record(z.string(), z.string().nullable()).optional(), acknowledgements: z.array(z.string()).optional().describe('Names of local package servers whose secret variables the administrator confirms are readable inside the task container. Required for a package server with secret variables whenever its package, version or secret variable names are new or changed; an unchanged server keeps its recorded acknowledgement. Only package servers with secret variables may be listed.') }));
export const ExtensionItemEnabledRequest = component('ExtensionItemEnabledRequest', z.strictObject({ item: z.union([z.strictObject({ kind: z.literal('server'), name: z.string() }), z.strictObject({ kind: z.literal('skill'), bundleId: z.uuid() })]), enabled: z.boolean() }));
export const ExtensionSecretReplaceRequest = component('ExtensionSecretReplaceRequest', z.strictObject({ name: z.string(), secret: z.string().nullable(), expectedRevision: z.number().int().min(0).optional() }));
diff --git a/packages/api/openapi/schemasPlugins.ts b/packages/api/openapi/schemasPlugins.ts
new file mode 100644
index 0000000000..0418d67d3b
--- /dev/null
+++ b/packages/api/openapi/schemasPlugins.ts
@@ -0,0 +1,113 @@
+import { z } from 'zod';
+import {
+ PLUGIN_AUDIT_ACTIONS, PLUGIN_CATEGORIES, PLUGIN_CONNECTION_TYPES, PLUGIN_DELIVERY_KINDS, PLUGIN_DELIVERY_STATUSES, PLUGIN_HEALTH_STATES, PLUGIN_PERMISSIONS,
+} from '@propr/shared';
+import { component } from './schemas.js';
+
+/** OpenAPI schemas of the plugin installation routes. Connection secrets never appear in any response. */
+const permission = z.enum(PLUGIN_PERMISSIONS);
+const settingValue = z.union([z.string(), z.number(), z.boolean(), z.array(z.string()), z.record(z.string(), z.string())]);
+const PluginManifestSummary = z.object({
+ id: z.string(), name: z.string(), description: z.string(), version: z.string(), category: z.enum(PLUGIN_CATEGORIES), homepage: z.string(),
+ permissions: z.array(z.object({ name: permission, description: z.string() })),
+ connections: z.array(z.object({ type: z.enum(PLUGIN_CONNECTION_TYPES), name: z.string(), label: z.string(), help: z.string(), helpUrl: z.string() }).loose()),
+ settings: z.object({ type: z.literal('object'), properties: z.record(z.string(), z.object({}).loose()), required: z.array(z.string()) }).describe('The settings schema: a JSON Schema subset (string, number, boolean, string list, repository map).'),
+ outboundHosts: z.array(z.string()),
+ contributes: z.object({
+ mcpServers: z.array(z.object({}).loose()).describe('Contributed MCP servers without launch arguments or environment values.'),
+ skills: z.array(z.string()), automationTemplates: z.array(z.string()),
+ commands: z.array(z.object({ name: z.string(), description: z.string() })), subscriptions: z.array(z.string()),
+ webhooks: z.array(z.object({ name: z.string(), description: z.string() })),
+ }),
+}).describe('The public projection of the plugin manifest.');
+const acknowledgement = z.object({ actor: z.string(), at: z.number(), package: z.string(), version: z.string(), secretEnv: z.array(z.string()) });
+const PluginInstallation = z.object({
+ installedVersion: z.string().describe('The manifest version installed or last approved.'),
+ enabled: z.boolean(),
+ settings: z.record(z.string(), settingValue).describe('Setting values; never secrets, which are connections.'),
+ repositories: z.array(z.string()).nullable().describe('Repositories (`owner/repo`) the plugin is limited to; null for all repositories.'),
+ grantedPermissions: z.array(permission),
+ acknowledgements: z.record(z.string(), acknowledgement).describe('Per package server: who acknowledged that its secret variables are readable inside the task container, and for which package, version and variable names.'),
+ installedBy: z.string(), installedAt: z.number(), updatedBy: z.string(), updatedAt: z.number(),
+});
+const PluginConnectionStatus = z.object({
+ name: z.string(), type: z.enum(PLUGIN_CONNECTION_TYPES), label: z.string(),
+ state: z.enum(['connected', 'not_connected', 'needs_sign_in']).describe('An `oauth` connection is connected once every managed server using it is signed in through the extension OAuth sign-in, and needs_sign_in after a failed refresh.'),
+ required: z.boolean().describe('A `token` connection a contributed MCP server uses; enabling waits for it.'),
+ setBy: z.string().optional().describe('Who set the value; for an `oauth` connection, who configured its pre-registered client.'), setAt: z.number().optional(),
+});
+const managedState = { enabled: z.boolean().describe('The library item\'s own enabled switch.'), active: z.boolean().describe('False while the plugin is disabled or a secret acknowledgement is pending: the item then never applies.') };
+const PluginContributions = z.object({
+ mcpServers: z.array(z.object({
+ name: z.string().describe('The server name in the manifest.'), kind: z.enum(['https', 'package']),
+ managed: z.object({ kind: z.literal('server'), name: z.string().describe('The library item name; prefixed with the plugin id after a name collision.'), ...managedState }).nullable(),
+ href: z.string().describe('The extension library route that lists and edits the item.'),
+ })),
+ skills: z.array(z.object({ name: z.string(), managed: z.object({ kind: z.literal('skill'), bundleId: z.string(), ...managedState }).nullable(), href: z.string() })),
+ automationTemplates: z.array(z.object({ id: z.string(), catalogId: z.string().describe('The template id in the automation template catalog.'), available: z.boolean().describe('Listed in the catalog: the plugin is enabled.'), href: z.string() })),
+}).describe('The managed extension library items and automation templates the plugin contributes; items are null until the plugin is installed.');
+const pluginViewShape = {
+ id: z.string(),
+ available: z.boolean().describe('The plugin is in this ProPR version\'s registry.'),
+ manifest: PluginManifestSummary.nullable(),
+ state: z.enum(['available', 'installed', 'enabled']),
+ installation: PluginInstallation.nullable(),
+ connections: z.array(PluginConnectionStatus),
+ needsApproval: z.boolean().describe('The current manifest asks for permissions or secret acknowledgements not yet approved; the plugin keeps its old grant.'),
+ pendingPermissions: z.array(permission),
+ pendingAcknowledgements: z.array(z.object({ server: z.string(), package: z.string(), version: z.string(), secretEnv: z.array(z.string()) })),
+ enableBlockers: z.array(z.string()).describe('Why enabling is refused right now; empty when it can be enabled.'),
+ contributions: PluginContributions.optional(),
+};
+export const PluginView = component('PluginView', z.object(pluginViewShape), 'A plugin with its installation state on this instance.');
+export const PluginList = component('PluginList', z.object({ plugins: z.array(PluginView) }));
+export const PluginDetailResponse = component('PluginDetailResponse', z.object({ plugin: z.object({
+ ...pluginViewShape,
+ audit: z.array(z.object({ action: z.enum(PLUGIN_AUDIT_ACTIONS), actor: z.string(), at: z.number(), detail: z.object({}).loose() })).describe('The newest 50 management actions, newest first.'),
+}) }));
+export const PluginResponse = component('PluginResponse', z.object({ plugin: PluginView }));
+export const PluginAcknowledgementRequest = component('PluginAcknowledgementRequest', z.strictObject({
+ acknowledgements: z.array(z.string()).optional().describe('Package servers whose secret variables the administrator confirms are readable by the agent CLI, its shell tools and repository setup scripts in the task container.'),
+}));
+export const PluginSettingsRequest = component('PluginSettingsRequest', z.strictObject({ settings: z.record(z.string(), settingValue.nullable()).describe('The complete settings; validated against the manifest\'s settings schema. Null values are removed.') }));
+export const PluginRepositoriesRequest = component('PluginRepositoriesRequest', z.strictObject({ repositories: z.array(z.string()).max(256).nullable().describe('owner/repo names; null or an empty list for all repositories.') }));
+export const PluginOAuthClientRequest = component('PluginOAuthClientRequest', z.strictObject({
+ client: z.strictObject({
+ clientId: z.string().regex(/^[\x21-\x7e]{1,512}$/).describe('The client id registered with the provider for this instance.'),
+ clientSecret: z.string().max(4096).nullable().optional().describe('The client secret (write-only); leave out for a public client.'),
+ }).nullable().describe('The pre-registered client, or null to clear it.'),
+}));
+export const PluginConnectionRequest = component('PluginConnectionRequest', z.strictObject({ value: z.string().max(4096).nullable().describe('The secret to store (write-only), or null to clear it.') }));
+export const PluginEnabledRequest = component('PluginEnabledRequest', z.strictObject({ enabled: z.boolean() }));
+export const PluginUninstallRequest = component('PluginUninstallRequest', z.strictObject({ confirm: z.string().describe('Must repeat the plugin id.') }));
+export const PluginEmptyRequest = component('PluginEmptyRequest', z.strictObject({}));
+export const PluginOAuthCompleteRequest = component('PluginOAuthCompleteRequest', z.strictObject({
+ state: z.string().describe('The `state` the provider returned to the redirect URI.'), code: z.string().optional(), error: z.string().optional(), iss: z.string().optional(),
+}));
+export const PluginOAuthStartResponse = component('PluginOAuthStartResponse', z.object({ authorizationUrl: z.string().describe('Open this URL to sign in; the provider redirects to the extension OAuth callback.'), server: z.string().describe('The managed library server being signed in.') }));
+
+const PluginLogLine = z.object({ level: z.enum(['debug', 'info', 'warn', 'error']), message: z.string(), fields: z.record(z.string(), z.unknown()).optional(), at: z.number() });
+export const PluginDelivery = component('PluginDelivery', z.object({
+ id: z.number().int().describe('The activity record id, used by the retry route.'),
+ deliveryId: z.string().describe('The stable delivery id the handler receives (`evt--`); the same on every attempt.'),
+ kind: z.enum(PLUGIN_DELIVERY_KINDS).describe('`event`: one journal event to one handler. `gap`: journal retention removed events before they were delivered.'),
+ repository: z.string(),
+ eventSeq: z.number().int().describe('The journal `seq` of the event; for a gap, the first `seq` that may be missing.'),
+ eventType: z.string().nullable().describe('`.`; null for a gap.'),
+ handler: z.string().nullable(),
+ status: z.enum(PLUGIN_DELIVERY_STATUSES),
+ attempt: z.number().int().describe('Attempts made so far.'),
+ nextAttemptAt: z.number().nullable().describe('When the next attempt is due, while pending or retrying.'),
+ createdAt: z.number(), startedAt: z.number().nullable(), finishedAt: z.number().nullable(), durationMs: z.number().nullable(),
+ timedOut: z.boolean(),
+ error: z.string().nullable().describe('The latest attempt\'s redacted error message.'),
+ logs: z.array(PluginLogLine).describe('The latest attempt\'s redacted log lines.'),
+}), 'One event delivery to a plugin handler, or a recorded gap. Holds the event id and type only: never the event payload or a credential.');
+export const PluginHealth = component('PluginHealth', z.object({
+ state: z.enum(PLUGIN_HEALTH_STATES).describe('`degraded` after repeated consecutive failures; `paused` after the configured number of them, until an administrator resumes deliveries.'),
+ consecutiveFailures: z.number().int(), pauseAfterFailures: z.number().int(),
+ lastError: z.string().nullable(), lastErrorAt: z.number().nullable(), degradedAt: z.number().nullable(), pausedAt: z.number().nullable(),
+}), 'Delivery health of a plugin installation.');
+export const PluginActivityResponse = component('PluginActivityResponse', z.object({ health: PluginHealth, deliveries: z.array(PluginDelivery).describe('The newest 100 delivery records, newest first.') }));
+export const PluginDeliveryResponse = component('PluginDeliveryResponse', z.object({ delivery: PluginDelivery }));
+export const PluginHealthResponse = component('PluginHealthResponse', z.object({ health: PluginHealth }));
diff --git a/packages/api/openapi/schemasRepositoryEvents.ts b/packages/api/openapi/schemasRepositoryEvents.ts
index dadd0c58d4..529ea6ae7e 100644
--- a/packages/api/openapi/schemasRepositoryEvents.ts
+++ b/packages/api/openapi/schemasRepositoryEvents.ts
@@ -33,14 +33,14 @@ export const RepositoryEventsWaitQuery = querySet('RepositoryEventsWaitQuery', z
export const RepositoryEvent = component('RepositoryEvent', z.object({
cursor: z.string().describe('Resumes immediately after this event.'),
domain: z.enum(REPOSITORY_EVENT_DOMAINS),
- type: z.string().describe('Event type within the domain, for example `head_changed`, `review_scored`, `merged`, `advanced` or `status_changed`.'),
+ type: z.string().describe('Event type within the domain, for example `head_changed`, `review_scored`, `merged`, `advanced`, `status_changed` or `queued`.'),
repository: z.string(),
pullRequest: z.number().int().optional(),
issueNumber: z.number().int().optional(),
planId: z.string().optional(),
- entityId: z.string().describe('PR number, epic execution id or plan issue id, as text.'),
+ entityId: z.string().describe('PR number, epic execution id, plan issue id or task id, as text.'),
head: z.string().optional().describe('Head SHA the event was recorded for, when it concerns one.'),
- payload: z.record(z.string(), z.unknown()).describe('Identifiers and small scalars only; never PR bodies, comments, diffs or logs.'),
+ payload: z.record(z.string(), z.unknown()).describe('Identifiers and small scalars only; never PR bodies, comments, diffs, prompts, tool output or logs.'),
occurredAt: z.string().meta({ format: 'date-time' }),
}), 'One durable repository change event.');
diff --git a/packages/api/pluginRouteRegistry.ts b/packages/api/pluginRouteRegistry.ts
new file mode 100644
index 0000000000..04d0cdbb35
--- /dev/null
+++ b/packages/api/pluginRouteRegistry.ts
@@ -0,0 +1,28 @@
+import type { createPluginRoutes } from './routes/pluginRoutes.js';
+import type { createPluginActivityRoutes } from './routes/pluginActivityRoutes.js';
+import { requireManageSettings } from './permissionGuards.js';
+import type { RouteEntry } from './routeRegistry.js';
+import { PLUGIN_API_BASE } from '@propr/shared';
+
+/** Plugin installation and delivery activity routes; kept out of `routeRegistry.ts`, which is at its size limit. */
+export function createPluginRouteEntries(plugins: ReturnType, activity: ReturnType): RouteEntry[] {
+ const base = PLUGIN_API_BASE;
+ return [
+ ['get', base, requireManageSettings, plugins.list],
+ ['get', `${base}/:pluginId`, requireManageSettings, plugins.get],
+ ['post', `${base}/:pluginId/install`, requireManageSettings, plugins.install],
+ ['put', `${base}/:pluginId/settings`, requireManageSettings, plugins.updateSettings],
+ ['put', `${base}/:pluginId/repositories`, requireManageSettings, plugins.setRepositories],
+ ['put', `${base}/:pluginId/connections/:connectionName`, requireManageSettings, plugins.setConnection],
+ ['put', `${base}/:pluginId/enabled`, requireManageSettings, plugins.setEnabled],
+ ['post', `${base}/:pluginId/approve`, requireManageSettings, plugins.approve],
+ ['post', `${base}/:pluginId/uninstall`, requireManageSettings, plugins.uninstall],
+ ['put', `${base}/:pluginId/connections/:connectionName/oauth/client`, requireManageSettings, plugins.setOAuthClient],
+ ['post', `${base}/:pluginId/connections/:connectionName/oauth/start`, requireManageSettings, plugins.startOAuth],
+ ['post', `${base}/:pluginId/connections/:connectionName/oauth/complete`, requireManageSettings, plugins.completeOAuth],
+ ['post', `${base}/:pluginId/connections/:connectionName/oauth/disconnect`, requireManageSettings, plugins.disconnectOAuth],
+ ['get', `${base}/:pluginId/activity`, requireManageSettings, activity.list],
+ ['post', `${base}/:pluginId/activity/:deliveryId/retry`, requireManageSettings, activity.retry],
+ ['post', `${base}/:pluginId/resume`, requireManageSettings, activity.resume],
+ ];
+}
diff --git a/packages/api/routes/automationDefinitionRoutes.ts b/packages/api/routes/automationDefinitionRoutes.ts
index 868588ed6a..5fb9340874 100644
--- a/packages/api/routes/automationDefinitionRoutes.ts
+++ b/packages/api/routes/automationDefinitionRoutes.ts
@@ -1,3 +1,4 @@
+import { automationTemplateCatalog } from '../../core/src/plugins/pluginTemplates.js';
import { ExtensionLibraryStore } from '../../core/src/extensions/extensionProfileStore.js';
/* eslint-disable max-lines -- automation definitions, their input files and their runs share one owner-scoped HTTP boundary */
import path from 'node:path';
@@ -40,7 +41,6 @@ import {
import {
AUTOMATION_ACTION_OPERATOR_NOTE_MAX_CHARS,
AUTOMATION_DEFINITION_CONTRACT,
- AUTOMATION_TEMPLATE_CATALOG,
automationRunNoChangeReason,
hasAutomationExtensions,
type AutomationExtensions,
@@ -454,10 +454,10 @@ export function createAutomationDefinitionRoutes(deps: AutomationDefinitionRoute
res.json(AUTOMATION_DEFINITION_CONTRACT);
});
- /** The shipped template catalog; a template only prefills the new-automation form and creates nothing. */
+ /** The template catalog: shipped templates and those of enabled plugins (marked with `plugin`). A template only prefills the new-automation form and creates nothing. */
const templates = handler('Failed to load the automation template catalog', async (req, res) => {
requireOwner(req);
- res.json(AUTOMATION_TEMPLATE_CATALOG);
+ res.json(await automationTemplateCatalog(database));
});
const create = handler('Failed to create automation definition', async (req, res) => {
diff --git a/packages/api/routes/pluginActivityRoutes.ts b/packages/api/routes/pluginActivityRoutes.ts
new file mode 100644
index 0000000000..96c3e437d8
--- /dev/null
+++ b/packages/api/routes/pluginActivityRoutes.ts
@@ -0,0 +1,31 @@
+import type { Request } from 'express';
+import { db } from '@propr/core';
+import { pluginIdSchema } from '@propr/shared';
+import { PluginError, PluginStore } from '../../core/src/plugins/pluginStore.js';
+import { listPluginActivity, resumePluginDeliveries, retryPluginDelivery } from '../../core/src/plugins/pluginDeliveries.js';
+import { pluginRouteHandler } from './pluginRoutes.js';
+
+/**
+ * Plugin event delivery activity under `/api/instance/plugins/v1/:pluginId`:
+ * the installation's delivery health and newest delivery records, retrying a
+ * failed delivery and resuming paused deliveries. The same administrator and
+ * preflight requirements as the installation routes apply. Records carry the
+ * event id and type, never its payload or a credential.
+ */
+export function createPluginActivityRoutes(store = new PluginStore(db), database = db, environment: NodeJS.ProcessEnv = process.env) {
+ const handle = pluginRouteHandler(store);
+ const id = (req: Request) => {
+ const parsed = pluginIdSchema.safeParse(req.params.pluginId);
+ if (!parsed.success) throw new PluginError(404, 'Plugin not found');
+ return parsed.data;
+ };
+ const empty = (req: Request) => {
+ const value = req.body as unknown;
+ if (!value || typeof value !== 'object' || Array.isArray(value) || Object.keys(value).length) throw new PluginError(400, 'Invalid plugin request');
+ };
+ return {
+ list: handle(async req => listPluginActivity(database, id(req), { environment })),
+ retry: handle(async (req, actor) => { empty(req); return { delivery: await retryPluginDelivery(database, id(req), String(req.params.deliveryId), actor) }; }),
+ resume: handle(async (req, actor) => { empty(req); return { health: await resumePluginDeliveries(database, id(req), actor, { environment }) }; }),
+ };
+}
diff --git a/packages/api/routes/pluginRoutes.ts b/packages/api/routes/pluginRoutes.ts
new file mode 100644
index 0000000000..cc6a24495d
--- /dev/null
+++ b/packages/api/routes/pluginRoutes.ts
@@ -0,0 +1,72 @@
+import type { Request, RequestHandler } from 'express';
+import { db } from '@propr/core';
+import { PluginError, PluginStore } from '../../core/src/plugins/pluginStore.js';
+import { isDemoViewer } from '../demoMode.js';
+import { refuseDemoViewer } from '../permissionGuards.js';
+import { extensionOAuthUrls } from './extensionOAuthRoutes.js';
+
+/**
+ * The plugin routes' request wrapper: refuses the demo viewer, a missing user,
+ * anyone but a durable administrator with `instance.manage_settings`, and a
+ * mutation without the preflight-protected JSON contract; answers `no-store`
+ * JSON, and a refusal as `{ error }` with the plugin error's status.
+ */
+export function pluginRouteHandler(store: PluginStore) {
+ return (run: (req: Request, actor: string) => Promise): RequestHandler => async (req, res, next) => {
+ // Plugins hold sealed per-installation secrets; the demo explains instead of reading them.
+ if (isDemoViewer(req.authorization)) return refuseDemoViewer(req, res, next);
+ try {
+ if (!req.user?.id) throw new PluginError(401, 'Authentication required');
+ if (req.authorization?.role !== 'admin' || !req.authorization.permissions.includes('instance.manage_settings')) throw new PluginError(403, 'Instance settings administration is required');
+ if (req.method !== 'GET' && (req.get('X-ProPR-Plugins') !== '1' || !req.is('application/json'))) throw new PluginError(403, 'Plugin changes require the preflight-protected JSON contract');
+ // Durable authority before any input is read; the store checks it again inside each write.
+ await store.authorize(req.user.id);
+ res.set('Cache-Control', 'no-store').json(await run(req, req.user.id));
+ } catch (error) {
+ res.status(error instanceof PluginError ? error.status : 400).json({ error: error instanceof PluginError ? error.message : 'Plugin operation unavailable or invalid' });
+ }
+ };
+}
+
+/**
+ * Plugin installation state under `/api/instance/plugins/v1`. Every route,
+ * reads included, needs a current durable administrator with
+ * `instance.manage_settings`; mutations additionally need the
+ * preflight-protected JSON contract (`X-ProPR-Plugins: 1`). Connection
+ * secrets are write-only: no response carries them. `oauth` connections are
+ * signed in through the extension OAuth module: the provider redirects to the
+ * extension callback, which completes the sign-in as `oauth/complete` does.
+ */
+export function createPluginRoutes(store = new PluginStore(db), environment: NodeJS.ProcessEnv = process.env) {
+ const handle = pluginRouteHandler(store);
+ const body = (req: Request, keys: string[], required: string[] = keys): Record => {
+ const value = req.body as unknown;
+ if (!value || typeof value !== 'object' || Array.isArray(value) || Object.keys(value).some(k => !keys.includes(k)) || required.some(k => !Object.hasOwn(value, k))) throw new PluginError(400, 'Invalid plugin request');
+ return value as Record;
+ };
+ const id = (req: Request) => String(req.params.pluginId);
+ const connection = (req: Request) => String(req.params.connectionName);
+ return {
+ list: handle(async (_req, actor) => ({ plugins: await store.list(actor) })),
+ get: handle(async (req, actor) => ({ plugin: await store.get(actor, id(req)) })),
+ /** `acknowledgements` names the package servers whose secret variables the administrator confirmed are readable in the task container. */
+ install: handle(async (req, actor) => ({ plugin: await store.install(actor, id(req), body(req, ['acknowledgements'], []).acknowledgements) })),
+ updateSettings: handle(async (req, actor) => ({ plugin: await store.updateSettings(actor, id(req), body(req, ['settings']).settings) })),
+ setRepositories: handle(async (req, actor) => ({ plugin: await store.setRepositories(actor, id(req), body(req, ['repositories']).repositories) })),
+ /** `value` sets the secret; `null` clears it. */
+ setConnection: handle(async (req, actor) => ({ plugin: await store.setConnection(actor, id(req), String(req.params.connectionName), body(req, ['value']).value) })),
+ setEnabled: handle(async (req, actor) => ({ plugin: await store.setEnabled(actor, id(req), body(req, ['enabled']).enabled) })),
+ approve: handle(async (req, actor) => ({ plugin: await store.approve(actor, id(req), body(req, ['acknowledgements'], []).acknowledgements) })),
+ uninstall: handle(async (req, actor) => ({ plugin: await store.uninstall(actor, id(req), body(req, ['confirm']).confirm) })),
+ /** Start the sign-in of an `oauth` connection, bound to this browser session; returns the provider's authorization URL. */
+ startOAuth: handle(async (req, actor) => { body(req, [], []); return store.startOAuth(actor, id(req), connection(req), req.sessionID, extensionOAuthUrls(environment)); }),
+ /** Complete it with the `state` and `code` the provider returned, in the session that started it. */
+ completeOAuth: handle(async (req, actor) => {
+ const query = body(req, ['state', 'code', 'error', 'iss'], ['state']);
+ return { plugin: await store.completeOAuth(actor, id(req), connection(req), req.sessionID, query) };
+ }),
+ /** Configure (`{ clientId, clientSecret? }`) or clear (`null`) the pre-registered client of an `oauth` connection; the secret is write-only. */
+ setOAuthClient: handle(async (req, actor) => ({ plugin: await store.setOAuthClient(actor, id(req), connection(req), body(req, ['client']).client) })),
+ disconnectOAuth: handle(async (req, actor) => { body(req, [], []); return { plugin: await store.disconnectOAuth(actor, id(req), connection(req)) }; }),
+ };
+}
diff --git a/packages/api/server.ts b/packages/api/server.ts
index 6afa2887be..3bf4c422f6 100644
--- a/packages/api/server.ts
+++ b/packages/api/server.ts
@@ -1,6 +1,9 @@
import { createExtensionLibraryRoutes } from './routes/extensionProfileRoutes.js';
import { startConfiguredFleetData, refusePublicFleetData } from './fleetDataListener.js';
import { createStorageRouteEntries } from './instanceStorageRouteRegistry.js';
+import { createPluginRouteEntries } from './pluginRouteRegistry.js';
+import { createPluginRoutes } from './routes/pluginRoutes.js';
+import { createPluginActivityRoutes } from './routes/pluginActivityRoutes.js';
import { protectStorageQueue } from '../core/src/storage/lifecycle.js';
import { createDefaultStorageService, createInstanceStorageRoutes } from './routes/instanceStorageRoutes.js';
import { startDashboardReadService, type DashboardReadService } from './services/dashboardReadService.js';
@@ -513,6 +516,7 @@ export function setupRoutes(): void {
const routes = [
...operationalRoutes,
...createStorageRouteEntries(storageRoutes),
+ ...createPluginRouteEntries(createPluginRoutes(), createPluginActivityRoutes()),
...createMemberCatalogRouteEntries({ instanceCatalogRoutes }),
...createManagementRouteEntries({
adminRoutes,
diff --git a/packages/api/test/mcpRepositoryEvents.test.ts b/packages/api/test/mcpRepositoryEvents.test.ts
index 5f6552c051..8c806e97e3 100644
--- a/packages/api/test/mcpRepositoryEvents.test.ts
+++ b/packages/api/test/mcpRepositoryEvents.test.ts
@@ -200,6 +200,21 @@ test('list_repository_events, wait_repository_events and the REST routes share o
assert.equal((await rest('waitEvents', { repository, timeoutSeconds: '0' })).body!.code, 'REPOSITORY_NOT_ACCESSIBLE');
denied = false;
+ assert.match(tool('list_repository_events').description, /task \(queued, started, completed, failed, cancelled/);
+
+ // Task events read through the same tool and REST route, filtered by the task domain.
+ const beforeTasks = (await call('list_repository_events', { repository })).data.cursor;
+ await append({ domain: 'task', type: 'queued', entityId: 'pr-comments-7-1', prNumber: 7, payload: { taskId: 'pr-comments-7-1', kind: 'follow-up' } });
+ await append({ domain: 'task', type: 'cancelled', entityId: 'pr-comments-7-1', prNumber: 7, payload: { taskId: 'pr-comments-7-1', kind: 'follow-up', status: 'cancelled' } });
+ const tasks = await call('list_repository_events', { repository, afterCursor: beforeTasks, domains: ['task'] });
+ assert.deepEqual(tasks.data.events.map((event: Json) => [event.domain, event.type, event.entityId, event.pullRequest]),
+ [['task', 'queued', 'pr-comments-7-1', 7], ['task', 'cancelled', 'pr-comments-7-1', 7]]);
+ assert.equal(tasks.data.events[1].payload.status, 'cancelled');
+ const restTasks = await rest('listEvents', { repository, afterCursor: beforeTasks, domains: 'task' });
+ assert.equal(restTasks.status, 200);
+ RepositoryEventList.parse(restTasks.body);
+ assert.deepEqual(restTasks.body!.events.map((event: Json) => event.type), ['queued', 'cancelled']);
+
// Expired cursors answer 410, distinct from an empty result.
await db('repository_event_floors').insert({ repository, floor_seq: 3, pruned_at: new Date().toISOString() });
const expired = await rest('listEvents', { repository, afterCursor: cursorFor(repository, 0) });
diff --git a/packages/api/test/mcpTunnelMode.test.ts b/packages/api/test/mcpTunnelMode.test.ts
index d30912e1b6..9249aeec7c 100644
--- a/packages/api/test/mcpTunnelMode.test.ts
+++ b/packages/api/test/mcpTunnelMode.test.ts
@@ -318,7 +318,10 @@ test('a 409 keeps forcing registration through transient failures until one succ
assert.equal(manager.scheduledDelayMs(), 60_000, 'the success keeps the newer rejection\'s cooldown, not the daily refresh');
clock += 60_000;
timers.pending().at(-1)!.callback();
- for (let tick = 0; tick < 100 && registrations.length < 5; tick += 1) await new Promise(resolve => setImmediate(resolve));
+ // The timer-driven check awaits database and crypto work on the libuv threadpool, so a fixed
+ // number of event-loop turns is not enough under load; wait on wall-clock time instead.
+ const deadline = Date.now() + 10_000;
+ while (registrations.length < 5 && Date.now() < deadline) await new Promise(resolve => setTimeout(resolve, 5));
assert.equal(registrations.length, 5, 'the newer rejection still forces a registration through the timer');
assert.equal(await manager.trigger(), 'registered');
assert.equal(manager.scheduledDelayMs(), CONNECT_REGISTRATION_REFRESH_MS);
diff --git a/packages/api/test/pluginActivityRoutes.test.ts b/packages/api/test/pluginActivityRoutes.test.ts
new file mode 100644
index 0000000000..12ec756d4c
--- /dev/null
+++ b/packages/api/test/pluginActivityRoutes.test.ts
@@ -0,0 +1,138 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import express from 'express';
+import { rateLimit } from 'express-rate-limit';
+import knex from 'knex';
+import fs from 'node:fs/promises';
+import os from 'node:os';
+import path from 'node:path';
+import type { AddressInfo } from 'node:net';
+import { z } from 'zod';
+import { getOfficialPlugin, loadPluginHandlers } from '../../plugins/src/index.js';
+import { PluginStore } from '../../core/src/plugins/pluginStore.js';
+import { PluginDispatcher } from '../../core/src/plugins/pluginDispatcher.js';
+import { appendRepositoryEvent } from '../../core/src/services/repositoryEventJournal.js';
+import { up as identityMigration } from '../../core/src/db/migrations/20261013210000_extension_profiles.js';
+import { up as grantMigration } from '../../core/src/db/migrations/20261013220000_extension_grant_network.js';
+import { up as libraryMigration } from '../../core/src/db/migrations/20261113000000_extension_library.js';
+import { up as oauthMigration } from '../../core/src/db/migrations/20261113120000_extension_oauth.js';
+import { up as pluginMigration } from '../../core/src/db/migrations/20261114000000_plugin_installations.js';
+import { up as pluginStateMigration } from '../../core/src/db/migrations/20261114180000_plugin_state.js';
+import { up as journalMigration } from '../../core/src/db/migrations/20261112160000_create_repository_events.js';
+import { up as taskDomainMigration } from '../../core/src/db/migrations/20261114120000_add_task_repository_event_domain.js';
+import { up as deliveriesMigration } from '../../core/src/db/migrations/20261115000000_plugin_event_deliveries.js';
+
+/** The fixture plugin of the core dispatcher tests: its `flaky` handler fails while state key `fail` is above zero. */
+const REGISTRY = path.join(path.dirname(new URL(import.meta.url).pathname), '..', '..', 'core', 'test', 'fixtures', 'plugins');
+const ID = 'fixture-events';
+const BASE = '/api/instance/plugins/v1';
+
+function assertConforms(schema: z.ZodType, body: unknown, label: string): void {
+ const result = schema.safeParse(body);
+ assert.ok(result.success, `${label} does not match its schema:\n${result.success ? '' : z.prettifyError(result.error)}`);
+ assert.deepEqual(result.data, body, `${label} sends fields its schema does not allow`);
+}
+
+test('plugin activity API: deliveries and health are listed, a failed delivery is retried and paused deliveries resumed, administrators only', async t => {
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), 'plugin-activity-api-'));
+ process.env.DB_FILENAME = path.join(root, 'fixture.sqlite');
+ const { createPluginRoutes } = await import('../routes/pluginRoutes.js');
+ const { createPluginActivityRoutes } = await import('../routes/pluginActivityRoutes.js');
+ const { createPluginRouteEntries } = await import('../pluginRouteRegistry.js');
+ const { registerRouteEntries } = await import('../routeRegistry.js');
+ const schemas = await import('../openapi/schemasPlugins.js');
+ const { ROUTE_DOCS } = await import('../openapi/routeDocs.js');
+
+ const database = knex({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true });
+ await database.schema.createTable('instance_members', c => { c.string('github_user_id').primary(); c.string('role'); });
+ await database.schema.createTable('agent_definitions', c => { c.string('id').primary(); c.string('name'); });
+ for (const migrate of [identityMigration, grantMigration, libraryMigration, oauthMigration, pluginMigration, pluginStateMigration, journalMigration, taskDomainMigration, deliveriesMigration]) await migrate(database);
+ await database('instance_members').insert([{ github_user_id: '1', role: 'admin' }, { github_user_id: '2', role: 'member' }]);
+ const manifest = getOfficialPlugin(ID, { root: REGISTRY, includeDevelopment: true })!.manifest;
+ const catalog = async () => [manifest];
+ const store = new PluginStore(database, { SYSTEM_TASK_SECRET: 'plugin-activity-custody' }, catalog);
+
+ const app = express();
+ app.use(rateLimit({ windowMs: 60_000, limit: 1000 }));
+ app.use(express.json());
+ app.use((req, _res, next) => {
+ const actor = req.get('x-test-actor'); if (actor) req.user = { id: actor, username: 'fixture' } as typeof req.user;
+ const member = req.get('x-test-role') === 'member';
+ req.authorization = { role: member ? 'member' : 'admin', permissions: member ? [] : ['instance.manage_settings'], source: 'local' };
+ next();
+ });
+ const entries = createPluginRouteEntries(createPluginRoutes(store, {}), createPluginActivityRoutes(store, database, { PROPR_PLUGIN_PAUSE_AFTER_FAILURES: '3' }));
+ registerRouteEntries(app, entries);
+ const server = app.listen(0, '127.0.0.1'); await new Promise(r => server.once('listening', r));
+ t.after(async () => { await new Promise(r => server.close(() => r())); await database.destroy(); await (await import('@propr/core')).closeConnection(); await fs.rm(root, { recursive: true, force: true }); });
+ const origin = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
+ const bodies: string[] = [];
+ const admin = { 'x-test-actor': '1', 'content-type': 'application/json', 'x-propr-plugins': '1' };
+ const call = async (method: string, route: string, body?: unknown, headers: Record = admin) => {
+ const response = await fetch(origin + BASE + route, { method, headers, ...(body === undefined ? {} : { body: JSON.stringify(body) }) });
+ const text = await response.text(); bodies.push(text);
+ return { status: response.status, body: JSON.parse(text), cache: response.headers.get('cache-control') };
+ };
+ for (const route of ['/:pluginId/activity', '/:pluginId/activity/:deliveryId/retry', '/:pluginId/resume']) {
+ const entry = entries.find(([, p]) => p === BASE + route)!;
+ assert.ok(ROUTE_DOCS[`${entry[0].toUpperCase()} ${entry[1]}`], `${route} has an OpenAPI entry`);
+ }
+
+ assert.equal((await call('GET', `/${ID}/activity`)).status, 404, 'not installed');
+ await store.install('1', ID); await store.setEnabled('1', ID, true);
+ const empty = await call('GET', `/${ID}/activity`);
+ assert.equal(empty.status, 200); assert.equal(empty.cache, 'no-store');
+ assertConforms(schemas.PluginActivityResponse, empty.body, 'empty activity');
+ assert.deepEqual(empty.body.deliveries, []); assert.equal(empty.body.health.state, 'healthy'); assert.equal(empty.body.health.pauseAfterFailures, 3);
+
+ // Three failing deliveries: Degraded at the third failure, which also pauses (threshold 3 here).
+ await database('plugin_state').insert({ plugin_id: ID, state_key: 'fail', value: '3', bytes: 1, updated_at: 0 });
+ for (const n of [1, 2, 3, 4]) await appendRepositoryEvent(database, { repository: 'owner/repo', domain: 'task', type: 'failed', entityId: `task-${n}`, payload: { body: 'payload-sentinel-77' } }, { publish: false });
+ const dispatcher = new PluginDispatcher({
+ database, catalog, retryDelaysMs: [], pauseAfterFailures: 3, timeoutMs: 1_000, notify: async () => {},
+ handlers: id => loadPluginHandlers(id, { root: REGISTRY, includeDevelopment: true }),
+ });
+ for (let i = 0; i < 5; i++) await dispatcher.pass();
+ await dispatcher.stop();
+
+ const activity = await call('GET', `/${ID}/activity`);
+ assertConforms(schemas.PluginActivityResponse, activity.body, 'activity');
+ assert.equal(activity.body.health.state, 'paused');
+ assert.match(activity.body.health.lastError, /flaky failure/);
+ const deliveries = activity.body.deliveries as { id: number; status: string; handler: string; eventType: string; attempt: number; error: string; logs: { message: string }[] }[];
+ assert.deepEqual(deliveries.map(d => [d.status, d.handler, d.eventType, d.attempt]), [['failed', 'flaky', 'task.failed', 1], ['failed', 'flaky', 'task.failed', 1], ['failed', 'flaky', 'task.failed', 1]], 'newest first; the fourth waits for Resume');
+ assert.equal(deliveries[0].logs[0].message, 'failing on purpose');
+
+ // Members, a missing preflight header, a non-empty body, an unknown record and a delivery that has not failed are refused.
+ assert.equal((await call('GET', `/${ID}/activity`, undefined, { 'x-test-actor': '2', 'x-test-role': 'member' })).status, 403);
+ assert.equal((await call('POST', `/${ID}/activity/${deliveries[0].id}/retry`, {}, { 'x-test-actor': '1', 'content-type': 'application/json' })).status, 403);
+ assert.equal((await call('POST', `/${ID}/activity/${deliveries[0].id}/retry`, { now: 1 })).status, 400);
+ assert.equal((await call('POST', `/${ID}/activity/999/retry`, {})).status, 404);
+ assert.equal((await call('POST', `/${ID}/activity/abc/retry`, {})).status, 400);
+ assert.equal((await call('GET', '/Not_A_Plugin/activity')).status, 404);
+
+ const retried = await call('POST', `/${ID}/activity/${deliveries[0].id}/retry`, {});
+ assert.equal(retried.status, 200);
+ assertConforms(schemas.PluginDeliveryResponse, retried.body, 'retry');
+ assert.equal(retried.body.delivery.status, 'pending');
+ assert.equal((await call('POST', `/${ID}/activity/${deliveries[0].id}/retry`, {})).status, 409);
+
+ const resumed = await call('POST', `/${ID}/resume`, {});
+ assert.equal(resumed.status, 200);
+ assertConforms(schemas.PluginHealthResponse, resumed.body, 'resume');
+ assert.equal(resumed.body.health.state, 'healthy');
+ assert.equal((await call('POST', `/${ID}/resume`, {})).status, 409, 'not paused any more');
+
+ // After Resume the retried delivery and the waiting event are delivered (the fixture's failures are used up).
+ const after = new PluginDispatcher({ database, catalog, notify: async () => {}, handlers: id => loadPluginHandlers(id, { root: REGISTRY, includeDevelopment: true }) });
+ for (let i = 0; i < 3; i++) await after.pass();
+ await after.stop();
+ const final = await call('GET', `/${ID}/activity`);
+ assert.deepEqual((final.body.deliveries as { status: string; attempt: number }[]).map(d => [d.status, d.attempt]), [['succeeded', 1], ['succeeded', 2], ['failed', 1], ['failed', 1]]);
+
+ // Retry and resume are audited; no response or record carries the event payload.
+ const audit = (await store.get('1', ID)).audit.map(a => a.action);
+ assert.ok(audit.includes('retry_delivery') && audit.includes('resume_deliveries'));
+ assert.equal(bodies.some(text => text.includes('payload-sentinel-77')), false);
+ assert.equal(JSON.stringify(await database('plugin_deliveries').select()).includes('payload-sentinel-77'), false);
+});
diff --git a/packages/api/test/pluginRoutes.test.ts b/packages/api/test/pluginRoutes.test.ts
new file mode 100644
index 0000000000..9beea2a1ea
--- /dev/null
+++ b/packages/api/test/pluginRoutes.test.ts
@@ -0,0 +1,222 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import express from 'express';
+import { rateLimit } from 'express-rate-limit';
+import knex from 'knex';
+import fs from 'node:fs/promises';
+import os from 'node:os';
+import path from 'node:path';
+import type { AddressInfo } from 'node:net';
+import { z } from 'zod';
+import { pluginManifestSchema, type PluginManifest } from '@propr/shared';
+import { getOfficialPlugin } from '../../plugins/src/index.js';
+import { PluginStore } from '../../core/src/plugins/pluginStore.js';
+import { up as identityMigration } from '../../core/src/db/migrations/20261013210000_extension_profiles.js';
+import { up as pluginMigration } from '../../core/src/db/migrations/20261114000000_plugin_installations.js';
+import { up as pluginStateMigration } from '../../core/src/db/migrations/20261114180000_plugin_state.js';
+import { up as journalMigration } from '../../core/src/db/migrations/20261112160000_create_repository_events.js';
+import { up as deliveriesMigration } from '../../core/src/db/migrations/20261115000000_plugin_event_deliveries.js';
+import { up as grantMigration } from '../../core/src/db/migrations/20261013220000_extension_grant_network.js';
+import { up as libraryMigration } from '../../core/src/db/migrations/20261113000000_extension_library.js';
+import { up as oauthMigration } from '../../core/src/db/migrations/20261113120000_extension_oauth.js';
+import { ExtensionOAuthStore, setExtensionOAuth } from '../../core/src/extensions/extensionOAuthStore.js';
+import { OAUTH_MCP_URL, syntheticOAuthProvider } from '../../../test/fixtures/extensionOAuthFixture.js';
+
+const SENTINEL = 'plugin-api-token-sentinel-41c7';
+const ID = 'example-pack';
+const BASE = '/api/instance/plugins/v1';
+
+function assertConforms(schema: z.ZodType, body: unknown, label: string): void {
+ const result = schema.safeParse(body);
+ assert.ok(result.success, `${label} does not match its schema:\n${result.success ? '' : z.prettifyError(result.error)}`);
+ assert.deepEqual(result.data, body, `${label} sends fields its schema does not allow`);
+}
+
+test('plugin REST API and MCP read tools: administrators only, audited, secret-free and schema-conformant', async t => {
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), 'plugin-api-'));
+ process.env.DB_FILENAME = path.join(root, 'fixture.sqlite');
+ const { createPluginRoutes } = await import('../routes/pluginRoutes.js');
+ const { createPluginRouteEntries } = await import('../pluginRouteRegistry.js');
+ const { registerRouteEntries } = await import('../routeRegistry.js');
+ const { addPluginTools } = await import('../mcp/toolsPlugins.js');
+ const schemas = await import('../openapi/schemasPlugins.js');
+ const { ROUTE_DOCS } = await import('../openapi/routeDocs.js');
+
+ const database = knex({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true });
+ await database.schema.createTable('instance_members', c => { c.string('github_user_id').primary(); c.string('role'); });
+ await database.schema.createTable('agent_definitions', c => { c.string('id').primary(); c.string('name'); });
+ await identityMigration(database); await grantMigration(database); await libraryMigration(database); await oauthMigration(database); await pluginMigration(database); await pluginStateMigration(database); await journalMigration(database); await deliveriesMigration(database);
+ // 3 holds the admin role in the session but is no longer a durable administrator.
+ await database('instance_members').insert([{ github_user_id: '1', role: 'admin' }, { github_user_id: '2', role: 'member' }, { github_user_id: '3', role: 'member' }]);
+ // The remote server at the synthetic OAuth provider's address literal: no DNS in tests.
+ const official = structuredClone(getOfficialPlugin(ID, { includeDevelopment: true })!.manifest);
+ official.contributes.mcpServers[0] = { ...official.contributes.mcpServers[0], url: OAUTH_MCP_URL } as typeof official.contributes.mcpServers[0];
+ const example = pluginManifestSchema.parse(official);
+ let catalog: PluginManifest[] = [example];
+ const store = new PluginStore(database, { SYSTEM_TASK_SECRET: 'plugin-api-custody' }, async () => catalog);
+ const synthetic = syntheticOAuthProvider({ advertise: { dynamic: true } });
+ setExtensionOAuth(store.library, new ExtensionOAuthStore(store.library, { transport: synthetic.transport, retryDelayMs: 1 }));
+
+ const app = express();
+ app.use(rateLimit({ windowMs: 60_000, limit: 1000 }));
+ app.use(express.json());
+ app.use((req, _res, next) => {
+ const actor = req.get('x-test-actor'); if (actor) req.user = { id: actor, username: 'fixture' } as typeof req.user;
+ const member = req.get('x-test-role') === 'member';
+ req.authorization = { role: member ? 'member' : 'admin', permissions: member ? [] : ['instance.manage_settings'], source: 'local' };
+ (req as { sessionID?: string }).sessionID = req.get('x-test-session') ?? 'session-a'; next();
+ });
+ const { createPluginActivityRoutes } = await import('../routes/pluginActivityRoutes.js');
+ const entries = createPluginRouteEntries(createPluginRoutes(store, { API_PUBLIC_URL: 'https://propr.example.com' }), createPluginActivityRoutes(store, database));
+ registerRouteEntries(app, entries);
+ const server = app.listen(0, '127.0.0.1'); await new Promise(r => server.once('listening', r));
+ t.after(async () => { await new Promise(r => server.close(() => r())); await database.destroy(); await (await import('@propr/core')).closeConnection(); await fs.rm(root, { recursive: true, force: true }); });
+ const origin = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
+ const bodies: string[] = [];
+ const admin = { 'x-test-actor': '1', 'content-type': 'application/json', 'x-propr-plugins': '1' };
+ const call = async (method: string, route: string, body?: unknown, headers: Record = admin) => {
+ const response = await fetch(origin + BASE + route, { method, headers, ...(body === undefined ? {} : { body: JSON.stringify(body) }) });
+ const text = await response.text(); bodies.push(text);
+ return { status: response.status, body: JSON.parse(text), cache: response.headers.get('cache-control') };
+ };
+
+ // Every route is documented.
+ for (const [method, route] of entries) assert.ok(ROUTE_DOCS[`${method.toUpperCase()} ${route}`], `${method} ${route} has an OpenAPI entry`);
+
+ const list = await call('GET', '');
+ assert.equal(list.status, 200);
+ assert.equal(list.cache, 'no-store');
+ assertConforms(schemas.PluginList, list.body, 'GET plugins');
+ assert.equal(list.body.plugins[0].state, 'available');
+
+ // Install needs the package server acknowledgement.
+ const unacknowledged = await call('POST', `/${ID}/install`, {});
+ assert.equal(unacknowledged.status, 400);
+ assert.match(unacknowledged.body.error, /example-local/);
+ const installed = await call('POST', `/${ID}/install`, { acknowledgements: ['example-local'] });
+ assert.equal(installed.status, 200);
+ assertConforms(schemas.PluginResponse, installed.body, 'install');
+ assert.equal(installed.body.plugin.state, 'installed');
+
+ const refusedEnable = await call('PUT', `/${ID}/enabled`, { enabled: true });
+ assert.equal(refusedEnable.status, 409);
+ assert.match(refusedEnable.body.error, /Connection Example API token \(example-token\) is not set/);
+ assert.equal((await call('PUT', `/${ID}/settings`, { settings: { verbosity: 'loud' } })).status, 400);
+ assert.equal((await call('PUT', `/${ID}/settings`, { settings: { verbosity: 'quiet', maxItems: 3 } })).status, 200);
+ assert.equal((await call('PUT', `/${ID}/repositories`, { repositories: ['acme/app'] })).body.plugin.installation.repositories[0], 'acme/app');
+ const connected = await call('PUT', `/${ID}/connections/example-token`, { value: SENTINEL });
+ assert.equal(connected.status, 200);
+ assert.equal(connected.body.plugin.connections.find((c: { name: string }) => c.name === 'example-token').state, 'connected');
+ assert.equal((await call('PUT', `/${ID}/connections/example-account`, { value: SENTINEL })).status, 400);
+ assert.equal((await call('PUT', `/${ID}/connections/missing`, { value: SENTINEL })).status, 404);
+ const enabled = await call('PUT', `/${ID}/enabled`, { enabled: true });
+ assert.equal(enabled.body.plugin.state, 'enabled');
+ assertConforms(schemas.PluginResponse, enabled.body, 'enable');
+ // The read model lists the contributions with their managed library items; the plugin's repository limit applies to them.
+ const contributions = enabled.body.plugin.contributions;
+ assert.deepEqual(contributions.mcpServers.map((s: { name: string; managed: unknown }) => [s.name, s.managed]), [
+ ['example-remote', { kind: 'server', name: 'example-remote', enabled: true, active: true }],
+ ['example-local', { kind: 'server', name: 'example-local', enabled: true, active: true }],
+ ]);
+ assert.equal(contributions.skills[0].managed.kind, 'skill');
+ assert.equal(contributions.mcpServers[0].href, '/api/instance/extensions/v1');
+ assert.deepEqual(contributions.automationTemplates.map((t: { catalogId: string; available: boolean }) => [t.catalogId, t.available]), [['example-weekly-digest', true]]);
+ const libraryItems = (await store.library.readLibrary('1'));
+ assert.deepEqual([...libraryItems.servers, ...libraryItems.skills].map(item => item.repositories), [['acme/app'], ['acme/app'], ['acme/app']]);
+
+ // OAuth connections: start, complete (as the provider's redirect would) and disconnect.
+ assert.equal(enabled.body.plugin.connections.find((c: { name: string }) => c.name === 'example-account').state, 'not_connected');
+ assert.equal((await call('POST', `/${ID}/connections/example-token/oauth/start`, {})).status, 404);
+ assert.equal((await call('POST', `/${ID}/connections/example-account/oauth/start`, { extra: 1 })).status, 400);
+ const started = await call('POST', `/${ID}/connections/example-account/oauth/start`, {});
+ assert.equal(started.status, 200);
+ assertConforms(schemas.PluginOAuthStartResponse, started.body, 'start OAuth');
+ assert.equal(started.body.server, 'example-remote');
+ const params = synthetic.authorize(started.body.authorizationUrl);
+ assert.equal((await call('POST', `/${ID}/connections/example-account/oauth/complete`, params, { ...admin, 'x-test-session': 'session-b' })).status, 403, 'only the session that started it completes it');
+ const second = await call('POST', `/${ID}/connections/example-account/oauth/start`, {});
+ const oauthDone = await call('POST', `/${ID}/connections/example-account/oauth/complete`, synthetic.authorize(second.body.authorizationUrl));
+ assert.equal(oauthDone.status, 200, JSON.stringify(oauthDone.body));
+ assertConforms(schemas.PluginResponse, oauthDone.body, 'complete OAuth');
+ assert.equal(oauthDone.body.plugin.connections.find((c: { name: string }) => c.name === 'example-account').state, 'connected');
+ const oauthOff = await call('POST', `/${ID}/connections/example-account/oauth/disconnect`, {});
+ assert.equal(oauthOff.body.plugin.connections.find((c: { name: string }) => c.name === 'example-account').state, 'not_connected');
+ // A pre-registered client of the OAuth connection: configured and cleared through the plugin; the secret is write-only.
+ assert.equal((await call('PUT', `/${ID}/connections/example-account/oauth/client`, { client: { clientId: 'propr-client', extra: 1 } })).status, 400);
+ assert.equal((await call('PUT', `/${ID}/connections/example-token/oauth/client`, { client: { clientId: 'propr-client' } })).status, 404);
+ const client = await call('PUT', `/${ID}/connections/example-account/oauth/client`, { client: { clientId: 'propr-client', clientSecret: SENTINEL } });
+ assert.equal(client.status, 200, JSON.stringify(client.body));
+ assertConforms(schemas.PluginResponse, client.body, 'set OAuth client');
+ assert.equal(client.body.plugin.connections.find((c: { name: string }) => c.name === 'example-account').setBy, '1');
+ const remote = (await store.library.readLibrary('1')).servers.find(s => s.name === 'example-remote');
+ assert.equal(remote?.kind === 'https' && remote.clientId, 'propr-client');
+ assert.equal((await call('PUT', `/${ID}/connections/example-account/oauth/client`, { client: null })).status, 200);
+
+ const disabled = await call('PUT', `/${ID}/enabled`, { enabled: false });
+ assert.equal(disabled.body.plugin.state, 'installed');
+ assert.deepEqual(disabled.body.plugin.contributions.mcpServers.map((s: { managed: { active: boolean } }) => s.managed.active), [false, false]);
+ assert.equal(disabled.body.plugin.contributions.automationTemplates[0].available, false);
+ const reenabled = await call('PUT', `/${ID}/enabled`, { enabled: true });
+ assert.equal(reenabled.body.plugin.state, 'enabled');
+ assert.equal(reenabled.body.plugin.installation.settings.verbosity, 'quiet', 'settings survive disable');
+
+ // An upgraded manifest that adds a permission: Needs approval, old grant kept until approved.
+ catalog = [pluginManifestSchema.parse({ ...structuredClone(example), version: '0.2.0', permissions: [...example.permissions, 'pull_requests.read'] })];
+ const pending = await call('GET', `/${ID}`);
+ assertConforms(schemas.PluginDetailResponse, pending.body, 'GET plugin');
+ assert.equal(pending.body.plugin.needsApproval, true);
+ assert.deepEqual(pending.body.plugin.pendingPermissions, ['pull_requests.read']);
+ assert.deepEqual(pending.body.plugin.installation.grantedPermissions, ['events.read', 'tasks.read']);
+ const approved = await call('POST', `/${ID}/approve`, {});
+ assert.equal(approved.body.plugin.needsApproval, false);
+ assert.equal(approved.body.plugin.installation.installedVersion, '0.2.0');
+
+ // Non-administrators are refused on every route, reads included; mutations need the preflight-protected JSON contract.
+ const routes = entries.map(([method, route]) => [method.toUpperCase(), route.replace(BASE, '').replace(':pluginId', ID).replace(':connectionName', 'example-token')] as const);
+ for (const [method, route] of routes) {
+ const body = method === 'GET' ? undefined : {};
+ for (const headers of [
+ { ...admin, 'x-test-role': 'member' },
+ { ...admin, 'x-test-actor': '3' },
+ { ...admin, 'x-test-actor': '2', 'x-test-role': 'admin' },
+ { 'content-type': 'application/json', 'x-propr-plugins': '1' },
+ ]) {
+ const response = await call(method, route, body, headers);
+ assert.ok([401, 403].includes(response.status), `${method} ${route} as ${JSON.stringify(headers)} answered ${response.status}`);
+ }
+ if (method !== 'GET') assert.equal((await call(method, route, body, { 'x-test-actor': '1', 'content-type': 'application/json' })).status, 403, `${method} ${route} without X-ProPR-Plugins`);
+ }
+
+ // MCP read-only tools project the same state, without secrets.
+ const tools: { name: string; readOnly?: boolean; permission?: string; run: (input: { principal: unknown; args: Record }) => Promise<{ data: unknown }> }[] = [];
+ addPluginTools(tools as never, { db: database } as never);
+ assert.deepEqual(tools.map(tool => [tool.name, tool.readOnly, tool.permission]), [['list_plugins', true, 'instance.manage_settings'], ['get_plugin', true, 'instance.manage_settings']]);
+ const principal = { user: { id: '1' } };
+ // The MCP tools use the default official catalog, which includes the example plugin only with the development flag.
+ process.env.PROPR_PLUGINS_DEVELOPMENT = '1';
+ t.after(() => { delete process.env.PROPR_PLUGINS_DEVELOPMENT; });
+ const listed = await tools[0].run({ principal, args: {} }) as { data: { plugins: { id: string; state: string; connections: { name: string; state: string }[] }[] } };
+ const row = listed.data.plugins.find(p => p.id === ID)!;
+ assert.equal(row.state, 'enabled');
+ assert.equal(row.connections.find(c => c.name === 'example-token')?.state, 'connected');
+ const detail = await tools[1].run({ principal, args: { pluginId: ID } }) as { data: { plugin: { audit: { action: string }[] } } };
+ assert.ok(detail.data.plugin.audit.some(event => event.action === 'set_connection'));
+ await assert.rejects(tools[1].run({ principal: { user: { id: '2' } }, args: { pluginId: ID } }), /administrator/);
+ await assert.rejects(tools[1].run({ principal, args: { pluginId: 'no-such-plugin' } }), (error: { code?: string }) => error.code === 'NOT_FOUND');
+ bodies.push(JSON.stringify(listed), JSON.stringify(detail));
+
+ // Uninstall needs the confirmation, deletes settings and connections, and is audited.
+ assert.equal((await call('POST', `/${ID}/uninstall`, { confirm: 'nope' })).status, 400);
+ const disabledBeforeUninstall = await call('POST', `/${ID}/uninstall`, { confirm: ID });
+ assert.equal(disabledBeforeUninstall.body.plugin.state, 'available');
+ assert.equal((await database('plugin_secrets').count({ n: '*' }))[0].n, 0);
+ const emptied = await store.library.readLibrary('1');
+ assert.deepEqual([emptied.servers, emptied.skills], [[], []], 'uninstall removes the managed items');
+ bodies.push(JSON.stringify(await store.library.history('1')));
+ const audit = await call('GET', `/${ID}`);
+ assert.deepEqual(audit.body.plugin.audit.map((e: { action: string }) => e.action).reverse(), [
+ 'install', 'update_settings', 'set_repositories', 'set_connection', 'enable', 'start_oauth', 'start_oauth', 'complete_oauth', 'disconnect_oauth', 'set_oauth_client', 'clear_oauth_client', 'disable', 'enable', 'approve', 'uninstall',
+ ]);
+
+ for (const text of bodies) assert.ok(!text.includes(SENTINEL), 'no response carries a connection secret');
+});
diff --git a/packages/api/test/repositoryEventTaskDomain.test.ts b/packages/api/test/repositoryEventTaskDomain.test.ts
new file mode 100644
index 0000000000..76a6a2fd09
--- /dev/null
+++ b/packages/api/test/repositoryEventTaskDomain.test.ts
@@ -0,0 +1,66 @@
+import assert from 'node:assert/strict';
+import { after, afterEach, beforeEach, test } from 'node:test';
+import { fileURLToPath } from 'node:url';
+import knex, { type Knex } from 'knex';
+import { appendRepositoryEvent, closeConnection, pruneRepositoryEvents } from '@propr/core';
+import {
+ activeRepositoryEventWaiterCount,
+ listRepositoryEventsAfterCursor,
+ waitForRepositoryEvents,
+} from '../services/repositoryEventWait.js';
+
+/** The `task` domain reads, waits and expires through the same journal read path as every other domain. */
+const migrations = fileURLToPath(new URL('../../core/src/db/migrations/', import.meta.url));
+const repository = 'acme/app';
+let db: Knex;
+
+after(closeConnection);
+beforeEach(async () => {
+ db = knex({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true, pool: { min: 1, max: 1 } });
+ await db.migrate.latest({ directory: migrations });
+});
+afterEach(async () => {
+ await db.destroy();
+ assert.equal(activeRepositoryEventWaiterCount(), 0);
+});
+
+const DAY_MS = 24 * 60 * 60 * 1000;
+async function appendTask(type: string, payload: Record = {}, occurredAt?: string): Promise {
+ const result = await appendRepositoryEvent(db, {
+ repository, domain: 'task', type, entityId: 'issue-42', issueNumber: 42,
+ payload: { taskId: 'issue-42', kind: 'implementation', ...payload }, dedupeKey: `task:issue-42:${type}`, occurredAt,
+ }, { publish: false });
+ assert.equal(result.status, 'appended');
+}
+const list = (options: Partial[0]> = {}) =>
+ listRepositoryEventsAfterCursor({ db, repository, ...options });
+
+test('task events are listed, filtered by domain and type, and waited on', async () => {
+ const start = (await list()).cursor;
+ await appendRepositoryEvent(db, { repository, domain: 'pull_request', type: 'opened', entityId: 7, prNumber: 7 }, { publish: false });
+ await appendTask('queued');
+ await appendTask('started');
+ await appendTask('failed', { status: 'failed', failureCategory: 'git_operation' });
+
+ const page = await list({ afterCursor: start, domains: ['task'] });
+ assert.deepEqual(page.events.map(event => [event.domain, event.type, event.entityId, event.issueNumber]), [
+ ['task', 'queued', 'issue-42', 42], ['task', 'started', 'issue-42', 42], ['task', 'failed', 'issue-42', 42],
+ ]);
+ assert.equal(page.events[2].payload.failureCategory, 'git_operation');
+ assert.deepEqual((await list({ afterCursor: start, types: ['failed'] })).events.map(event => event.type), ['failed']);
+
+ const waited = await waitForRepositoryEvents({
+ db, repository, ownerId: '1', afterCursor: start, domains: ['task'], types: ['started'], timeoutSeconds: 1, pollIntervalMs: 60_000,
+ });
+ assert.equal(waited.outcome, 'events');
+ assert.deepEqual(waited.events.map(event => event.type), ['started']);
+});
+
+test('retention prunes task events by age like any other event', async () => {
+ await appendTask('queued', {}, new Date(Date.now() - 40 * DAY_MS).toISOString());
+ await appendTask('started');
+ await appendTask('cancelled', { status: 'cancelled', terminalReason: 'cancelled_by_user' });
+ const result = await pruneRepositoryEvents(db);
+ assert.equal(result.deleted, 1);
+ assert.deepEqual((await list({ fromStart: true, domains: ['task'] })).events.map(event => event.type), ['started', 'cancelled']);
+});
diff --git a/packages/client/src/generated/apiTypes.ts b/packages/client/src/generated/apiTypes.ts
index 72c2871769..87be9089a5 100644
--- a/packages/client/src/generated/apiTypes.ts
+++ b/packages/client/src/generated/apiTypes.ts
@@ -433,7 +433,7 @@ export interface ExtensionItemEnabledRequest {
enabled: boolean;
}
-/** Strict shared v1 library contract: up to 32 servers and 32 skill bundle references. Each item has enabled, optional repositories (owner/repo; empty means every repository) and agents (aliases; empty means every agent) limits, and autoApply (default true; false applies the item only where an automation adds it). Operator catalog descriptors accept only immutable catalogId/digest and selected tools/capability; customer commands, arguments, environment and mount paths are rejected. */
+/** Strict shared v1 library contract: up to 32 servers and 32 skill bundle references. Each item has enabled, optional repositories (owner/repo; empty means every repository) and agents (aliases; empty means every agent) limits, and autoApply (default true; false applies the item only where an automation adds it). Items an installed plugin contributes carry managedBy (plugin id, manifest item name, active, and for OAuth servers the client registration methods the plugin permits at sign-in); their definition is read-only and only enabled, the limits and autoApply can change, and they cannot be added or removed through this route. Operator catalog descriptors accept only immutable catalogId/digest and selected tools/capability; customer commands, arguments, environment and mount paths are rejected. */
export interface ExtensionLibraryInput {
version: 1;
servers: Array<{
@@ -449,6 +449,12 @@ export interface ExtensionLibraryInput {
repositories: string[];
agents: string[];
autoApply: boolean;
+ managedBy?: {
+ plugin: string;
+ item: string;
+ active: boolean;
+ registrations?: Array<"metadata_document" | "dynamic" | "preregistered">;
+ };
} | {
kind: "catalog";
name: string;
@@ -460,6 +466,12 @@ export interface ExtensionLibraryInput {
repositories: string[];
agents: string[];
autoApply: boolean;
+ managedBy?: {
+ plugin: string;
+ item: string;
+ active: boolean;
+ registrations?: Array<"metadata_document" | "dynamic" | "preregistered">;
+ };
} | {
name: string;
enabled: boolean;
@@ -480,14 +492,26 @@ export interface ExtensionLibraryInput {
repositories: string[];
agents: string[];
autoApply: boolean;
+ managedBy?: {
+ plugin: string;
+ item: string;
+ active: boolean;
+ registrations?: Array<"metadata_document" | "dynamic" | "preregistered">;
+ };
}>;
- skills: {
+ skills: Array<{
bundleId: string;
enabled: boolean;
repositories: string[];
agents: string[];
autoApply: boolean;
- }[];
+ managedBy?: {
+ plugin: string;
+ item: string;
+ active: boolean;
+ registrations?: Array<"metadata_document" | "dynamic" | "preregistered">;
+ };
+ }>;
}
export interface ExtensionLibraryRead {
@@ -889,6 +913,439 @@ export interface NotificationUnreadCount {
unreadCount: number;
}
+export interface PluginAcknowledgementRequest {
+ /** Package servers whose secret variables the administrator confirms are readable by the agent CLI, its shell tools and repository setup scripts in the task container. */
+ acknowledgements?: string[];
+}
+
+export interface PluginActivityResponse {
+ health: PluginHealth;
+ /** The newest 100 delivery records, newest first. */
+ deliveries: PluginDelivery[];
+}
+
+export interface PluginConnectionRequest {
+ /** The secret to store (write-only), or null to clear it. */
+ value: string | null;
+}
+
+/** One event delivery to a plugin handler, or a recorded gap. Holds the event id and type only: never the event payload or a credential. */
+export interface PluginDelivery {
+ /** The activity record id, used by the retry route. */
+ id: number;
+ /** The stable delivery id the handler receives (`evt--`); the same on every attempt. */
+ deliveryId: string;
+ /** `event`: one journal event to one handler. `gap`: journal retention removed events before they were delivered. */
+ kind: "event" | "gap";
+ repository: string;
+ /** The journal `seq` of the event; for a gap, the first `seq` that may be missing. */
+ eventSeq: number;
+ /** `.`; null for a gap. */
+ eventType: string | null;
+ handler: string | null;
+ status: "pending" | "running" | "retrying" | "succeeded" | "failed" | "skipped";
+ /** Attempts made so far. */
+ attempt: number;
+ /** When the next attempt is due, while pending or retrying. */
+ nextAttemptAt: number | null;
+ createdAt: number;
+ startedAt: number | null;
+ finishedAt: number | null;
+ durationMs: number | null;
+ timedOut: boolean;
+ /** The latest attempt's redacted error message. */
+ error: string | null;
+ /** The latest attempt's redacted log lines. */
+ logs: Array<{
+ level: "debug" | "info" | "warn" | "error";
+ message: string;
+ fields?: {
+ [key: string]: unknown;
+ };
+ at: number;
+ }>;
+}
+
+export interface PluginDeliveryResponse {
+ delivery: PluginDelivery;
+}
+
+export interface PluginDetailResponse {
+ plugin: {
+ id: string;
+ /** The plugin is in this ProPR version's registry. */
+ available: boolean;
+ manifest: {
+ id: string;
+ name: string;
+ description: string;
+ version: string;
+ category: "messaging" | "issue-tracking" | "observability" | "deployment" | "context" | "other";
+ homepage: string;
+ permissions: Array<{
+ name: "events.read" | "tasks.read" | "tasks.create" | "issues.read" | "issues.create" | "issues.comment" | "pull_requests.read" | "pull_requests.comment" | "labels.write" | "repositories.read" | "notifications.send";
+ description: string;
+ }>;
+ connections: Array<{
+ type: "oauth" | "token" | "signing_secret";
+ name: string;
+ label: string;
+ help: string;
+ helpUrl: string;
+ [key: string]: unknown;
+ }>;
+ /** The settings schema: a JSON Schema subset (string, number, boolean, string list, repository map). */
+ settings: {
+ type: "object";
+ properties: {
+ [key: string]: {
+ [key: string]: unknown;
+ };
+ };
+ required: string[];
+ };
+ outboundHosts: string[];
+ contributes: {
+ /** Contributed MCP servers without launch arguments or environment values. */
+ mcpServers: {
+ [key: string]: unknown;
+ }[];
+ skills: string[];
+ automationTemplates: string[];
+ commands: {
+ name: string;
+ description: string;
+ }[];
+ subscriptions: string[];
+ webhooks: {
+ name: string;
+ description: string;
+ }[];
+ };
+ } | null;
+ state: "available" | "installed" | "enabled";
+ installation: {
+ /** The manifest version installed or last approved. */
+ installedVersion: string;
+ enabled: boolean;
+ /** Setting values; never secrets, which are connections. */
+ settings: {
+ [key: string]: string | number | boolean | string[] | {
+ [key: string]: string;
+ };
+ };
+ /** Repositories (`owner/repo`) the plugin is limited to; null for all repositories. */
+ repositories: string[] | null;
+ grantedPermissions: Array<"events.read" | "tasks.read" | "tasks.create" | "issues.read" | "issues.create" | "issues.comment" | "pull_requests.read" | "pull_requests.comment" | "labels.write" | "repositories.read" | "notifications.send">;
+ /** Per package server: who acknowledged that its secret variables are readable inside the task container, and for which package, version and variable names. */
+ acknowledgements: {
+ [key: string]: {
+ actor: string;
+ at: number;
+ package: string;
+ version: string;
+ secretEnv: string[];
+ };
+ };
+ installedBy: string;
+ installedAt: number;
+ updatedBy: string;
+ updatedAt: number;
+ } | null;
+ connections: Array<{
+ name: string;
+ type: "oauth" | "token" | "signing_secret";
+ label: string;
+ /** An `oauth` connection is connected once every managed server using it is signed in through the extension OAuth sign-in, and needs_sign_in after a failed refresh. */
+ state: "connected" | "not_connected" | "needs_sign_in";
+ /** A `token` connection a contributed MCP server uses; enabling waits for it. */
+ required: boolean;
+ /** Who set the value; for an `oauth` connection, who configured its pre-registered client. */
+ setBy?: string;
+ setAt?: number;
+ }>;
+ /** The current manifest asks for permissions or secret acknowledgements not yet approved; the plugin keeps its old grant. */
+ needsApproval: boolean;
+ pendingPermissions: Array<"events.read" | "tasks.read" | "tasks.create" | "issues.read" | "issues.create" | "issues.comment" | "pull_requests.read" | "pull_requests.comment" | "labels.write" | "repositories.read" | "notifications.send">;
+ pendingAcknowledgements: {
+ server: string;
+ package: string;
+ version: string;
+ secretEnv: string[];
+ }[];
+ /** Why enabling is refused right now; empty when it can be enabled. */
+ enableBlockers: string[];
+ /** The managed extension library items and automation templates the plugin contributes; items are null until the plugin is installed. */
+ contributions?: {
+ mcpServers: Array<{
+ /** The server name in the manifest. */
+ name: string;
+ kind: "https" | "package";
+ managed: {
+ kind: "server";
+ /** The library item name; prefixed with the plugin id after a name collision. */
+ name: string;
+ /** The library item's own enabled switch. */
+ enabled: boolean;
+ /** False while the plugin is disabled or a secret acknowledgement is pending: the item then never applies. */
+ active: boolean;
+ } | null;
+ /** The extension library route that lists and edits the item. */
+ href: string;
+ }>;
+ skills: Array<{
+ name: string;
+ managed: {
+ kind: "skill";
+ bundleId: string;
+ /** The library item's own enabled switch. */
+ enabled: boolean;
+ /** False while the plugin is disabled or a secret acknowledgement is pending: the item then never applies. */
+ active: boolean;
+ } | null;
+ href: string;
+ }>;
+ automationTemplates: {
+ id: string;
+ /** The template id in the automation template catalog. */
+ catalogId: string;
+ /** Listed in the catalog: the plugin is enabled. */
+ available: boolean;
+ href: string;
+ }[];
+ };
+ /** The newest 50 management actions, newest first. */
+ audit: Array<{
+ action: "install" | "update_settings" | "set_repositories" | "set_connection" | "clear_connection" | "enable" | "disable" | "approve" | "uninstall" | "start_oauth" | "complete_oauth" | "disconnect_oauth" | "set_oauth_client" | "clear_oauth_client" | "handler_attempt" | "handler_call" | "handler_refused" | "retry_delivery" | "resume_deliveries";
+ actor: string;
+ at: number;
+ detail: {
+ [key: string]: unknown;
+ };
+ }>;
+ };
+}
+
+export type PluginEmptyRequest = Record;
+
+export interface PluginEnabledRequest {
+ enabled: boolean;
+}
+
+/** Delivery health of a plugin installation. */
+export interface PluginHealth {
+ /** `degraded` after repeated consecutive failures; `paused` after the configured number of them, until an administrator resumes deliveries. */
+ state: "healthy" | "degraded" | "paused";
+ consecutiveFailures: number;
+ pauseAfterFailures: number;
+ lastError: string | null;
+ lastErrorAt: number | null;
+ degradedAt: number | null;
+ pausedAt: number | null;
+}
+
+export interface PluginHealthResponse {
+ health: PluginHealth;
+}
+
+export interface PluginList {
+ plugins: PluginView[];
+}
+
+export interface PluginOAuthClientRequest {
+ /** The pre-registered client, or null to clear it. */
+ client: {
+ /** The client id registered with the provider for this instance. */
+ clientId: string;
+ /** The client secret (write-only); leave out for a public client. */
+ clientSecret?: string | null;
+ } | null;
+}
+
+export interface PluginOAuthCompleteRequest {
+ /** The `state` the provider returned to the redirect URI. */
+ state: string;
+ code?: string;
+ error?: string;
+ iss?: string;
+}
+
+export interface PluginOAuthStartResponse {
+ /** Open this URL to sign in; the provider redirects to the extension OAuth callback. */
+ authorizationUrl: string;
+ /** The managed library server being signed in. */
+ server: string;
+}
+
+export interface PluginRepositoriesRequest {
+ /** owner/repo names; null or an empty list for all repositories. */
+ repositories: string[] | null;
+}
+
+export interface PluginResponse {
+ plugin: PluginView;
+}
+
+export interface PluginSettingsRequest {
+ /** The complete settings; validated against the manifest's settings schema. Null values are removed. */
+ settings: {
+ [key: string]: string | number | boolean | string[] | {
+ [key: string]: string;
+ } | null;
+ };
+}
+
+export interface PluginUninstallRequest {
+ /** Must repeat the plugin id. */
+ confirm: string;
+}
+
+/** A plugin with its installation state on this instance. */
+export interface PluginView {
+ id: string;
+ /** The plugin is in this ProPR version's registry. */
+ available: boolean;
+ manifest: {
+ id: string;
+ name: string;
+ description: string;
+ version: string;
+ category: "messaging" | "issue-tracking" | "observability" | "deployment" | "context" | "other";
+ homepage: string;
+ permissions: Array<{
+ name: "events.read" | "tasks.read" | "tasks.create" | "issues.read" | "issues.create" | "issues.comment" | "pull_requests.read" | "pull_requests.comment" | "labels.write" | "repositories.read" | "notifications.send";
+ description: string;
+ }>;
+ connections: Array<{
+ type: "oauth" | "token" | "signing_secret";
+ name: string;
+ label: string;
+ help: string;
+ helpUrl: string;
+ [key: string]: unknown;
+ }>;
+ /** The settings schema: a JSON Schema subset (string, number, boolean, string list, repository map). */
+ settings: {
+ type: "object";
+ properties: {
+ [key: string]: {
+ [key: string]: unknown;
+ };
+ };
+ required: string[];
+ };
+ outboundHosts: string[];
+ contributes: {
+ /** Contributed MCP servers without launch arguments or environment values. */
+ mcpServers: {
+ [key: string]: unknown;
+ }[];
+ skills: string[];
+ automationTemplates: string[];
+ commands: {
+ name: string;
+ description: string;
+ }[];
+ subscriptions: string[];
+ webhooks: {
+ name: string;
+ description: string;
+ }[];
+ };
+ } | null;
+ state: "available" | "installed" | "enabled";
+ installation: {
+ /** The manifest version installed or last approved. */
+ installedVersion: string;
+ enabled: boolean;
+ /** Setting values; never secrets, which are connections. */
+ settings: {
+ [key: string]: string | number | boolean | string[] | {
+ [key: string]: string;
+ };
+ };
+ /** Repositories (`owner/repo`) the plugin is limited to; null for all repositories. */
+ repositories: string[] | null;
+ grantedPermissions: Array<"events.read" | "tasks.read" | "tasks.create" | "issues.read" | "issues.create" | "issues.comment" | "pull_requests.read" | "pull_requests.comment" | "labels.write" | "repositories.read" | "notifications.send">;
+ /** Per package server: who acknowledged that its secret variables are readable inside the task container, and for which package, version and variable names. */
+ acknowledgements: {
+ [key: string]: {
+ actor: string;
+ at: number;
+ package: string;
+ version: string;
+ secretEnv: string[];
+ };
+ };
+ installedBy: string;
+ installedAt: number;
+ updatedBy: string;
+ updatedAt: number;
+ } | null;
+ connections: Array<{
+ name: string;
+ type: "oauth" | "token" | "signing_secret";
+ label: string;
+ /** An `oauth` connection is connected once every managed server using it is signed in through the extension OAuth sign-in, and needs_sign_in after a failed refresh. */
+ state: "connected" | "not_connected" | "needs_sign_in";
+ /** A `token` connection a contributed MCP server uses; enabling waits for it. */
+ required: boolean;
+ /** Who set the value; for an `oauth` connection, who configured its pre-registered client. */
+ setBy?: string;
+ setAt?: number;
+ }>;
+ /** The current manifest asks for permissions or secret acknowledgements not yet approved; the plugin keeps its old grant. */
+ needsApproval: boolean;
+ pendingPermissions: Array<"events.read" | "tasks.read" | "tasks.create" | "issues.read" | "issues.create" | "issues.comment" | "pull_requests.read" | "pull_requests.comment" | "labels.write" | "repositories.read" | "notifications.send">;
+ pendingAcknowledgements: {
+ server: string;
+ package: string;
+ version: string;
+ secretEnv: string[];
+ }[];
+ /** Why enabling is refused right now; empty when it can be enabled. */
+ enableBlockers: string[];
+ /** The managed extension library items and automation templates the plugin contributes; items are null until the plugin is installed. */
+ contributions?: {
+ mcpServers: Array<{
+ /** The server name in the manifest. */
+ name: string;
+ kind: "https" | "package";
+ managed: {
+ kind: "server";
+ /** The library item name; prefixed with the plugin id after a name collision. */
+ name: string;
+ /** The library item's own enabled switch. */
+ enabled: boolean;
+ /** False while the plugin is disabled or a secret acknowledgement is pending: the item then never applies. */
+ active: boolean;
+ } | null;
+ /** The extension library route that lists and edits the item. */
+ href: string;
+ }>;
+ skills: Array<{
+ name: string;
+ managed: {
+ kind: "skill";
+ bundleId: string;
+ /** The library item's own enabled switch. */
+ enabled: boolean;
+ /** False while the plugin is disabled or a secret acknowledgement is pending: the item then never applies. */
+ active: boolean;
+ } | null;
+ href: string;
+ }>;
+ automationTemplates: {
+ id: string;
+ /** The template id in the automation template catalog. */
+ catalogId: string;
+ /** Listed in the catalog: the plugin is enabled. */
+ available: boolean;
+ href: string;
+ }[];
+ };
+}
+
/** Read-only readiness verdict of one pull request. Evaluating it never writes to GitHub or Redis, never updates the branch or merges, and never changes auto-merge state. */
export interface PullRequestReadiness {
repository: string;
@@ -946,18 +1403,18 @@ export interface PullRequestReadiness {
export interface RepositoryEvent {
/** Resumes immediately after this event. */
cursor: string;
- domain: "pull_request" | "epic_queue" | "plan_issue";
- /** Event type within the domain, for example `head_changed`, `review_scored`, `merged`, `advanced` or `status_changed`. */
+ domain: "pull_request" | "epic_queue" | "plan_issue" | "task";
+ /** Event type within the domain, for example `head_changed`, `review_scored`, `merged`, `advanced`, `status_changed` or `queued`. */
type: string;
repository: string;
pullRequest?: number;
issueNumber?: number;
planId?: string;
- /** PR number, epic execution id or plan issue id, as text. */
+ /** PR number, epic execution id, plan issue id or task id, as text. */
entityId: string;
/** Head SHA the event was recorded for, when it concerns one. */
head?: string;
- /** Identifiers and small scalars only; never PR bodies, comments, diffs or logs. */
+ /** Identifiers and small scalars only; never PR bodies, comments, diffs, prompts, tool output or logs. */
payload: {
[key: string]: unknown;
};
@@ -1344,9 +1801,9 @@ export interface RepositoryEventsQuery {
afterCursor?: string;
/** Without `afterCursor`: `true` starts at the oldest retained event. By default a call without a cursor starts at the current end of the journal and returns no history. */
fromStart?: "true" | "false";
- /** Comma-separated domains: `pull_request`, `epic_queue`, `plan_issue`. */
+ /** Comma-separated domains: `pull_request`, `epic_queue`, `plan_issue`, `task`. */
domains?: string;
- /** Comma-separated event types: `opened`, `head_changed`, `review_scored`, `ultrafix_started`, `ultrafix_ended`, `auto_merge_decision`, `merged`, `closed`, `started`, `advanced`, `blocked`, `completed`, `status_changed`. */
+ /** Comma-separated event types: `opened`, `head_changed`, `review_scored`, `ultrafix_started`, `ultrafix_ended`, `auto_merge_decision`, `merged`, `closed`, `started`, `advanced`, `blocked`, `completed`, `status_changed`, `queued`, `failed`, `cancelled`. */
types?: string;
/** Only events of this pull request number. */
pullRequest?: number;
@@ -1387,9 +1844,9 @@ export interface RepositoryEventsWaitQuery {
afterCursor?: string;
/** Without `afterCursor`: `true` starts at the oldest retained event. By default a call without a cursor starts at the current end of the journal and returns no history. */
fromStart?: "true" | "false";
- /** Comma-separated domains: `pull_request`, `epic_queue`, `plan_issue`. */
+ /** Comma-separated domains: `pull_request`, `epic_queue`, `plan_issue`, `task`. */
domains?: string;
- /** Comma-separated event types: `opened`, `head_changed`, `review_scored`, `ultrafix_started`, `ultrafix_ended`, `auto_merge_decision`, `merged`, `closed`, `started`, `advanced`, `blocked`, `completed`, `status_changed`. */
+ /** Comma-separated event types: `opened`, `head_changed`, `review_scored`, `ultrafix_started`, `ultrafix_ended`, `auto_merge_decision`, `merged`, `closed`, `started`, `advanced`, `blocked`, `completed`, `status_changed`, `queued`, `failed`, `cancelled`. */
types?: string;
/** Only events of this pull request number. */
pullRequest?: number;
diff --git a/packages/core/src/db/migrations/20261114000000_plugin_installations.js b/packages/core/src/db/migrations/20261114000000_plugin_installations.js
new file mode 100644
index 0000000000..152a43acc7
--- /dev/null
+++ b/packages/core/src/db/migrations/20261114000000_plugin_installations.js
@@ -0,0 +1,45 @@
+// Plugin installation state: one row per plugin an administrator installed on
+// this instance, with its enabled flag, settings, repository limit, granted
+// permissions and package-server secret acknowledgements. Token and signing
+// secret connections are sealed with the extension private-secret mechanism in
+// their own table and erased from native exports. Management actions are kept
+// in an append-only audit table that outlives an uninstall.
+
+export async function up(knex) {
+ if (!await knex.schema.hasTable('plugin_installations')) {
+ await knex.schema.createTable('plugin_installations', t => {
+ t.string('plugin_id').primary();
+ // The manifest version installed or last approved.
+ t.string('manifest_version').notNullable();
+ t.boolean('enabled').notNullable().defaultTo(false);
+ t.text('settings').notNullable().defaultTo('{}');
+ // JSON list of owner/repo; NULL means all repositories.
+ t.text('repositories');
+ // JSON set of granted permissions.
+ t.text('granted_permissions').notNullable().defaultTo('[]');
+ // JSON map of package server name -> { actor, at, package, version, secretEnv }.
+ t.text('acknowledgements').notNullable().defaultTo('{}');
+ t.string('installed_by').notNullable(); t.bigInteger('installed_at').notNullable();
+ t.string('updated_by').notNullable(); t.bigInteger('updated_at').notNullable();
+ });
+ }
+ if (!await knex.schema.hasTable('plugin_secrets')) {
+ await knex.schema.createTable('plugin_secrets', t => {
+ t.string('plugin_id').notNullable(); t.string('connection_name').notNullable();
+ t.text('sealed').notNullable();
+ t.string('set_by').notNullable(); t.bigInteger('set_at').notNullable();
+ t.primary(['plugin_id', 'connection_name']);
+ });
+ }
+ if (!await knex.schema.hasTable('plugin_audit')) {
+ await knex.schema.createTable('plugin_audit', t => {
+ t.increments('id').primary(); t.string('plugin_id').notNullable();
+ t.string('action').notNullable(); t.string('actor').notNullable(); t.bigInteger('at').notNullable();
+ t.text('detail').notNullable().defaultTo('{}');
+ t.index(['plugin_id', 'id']);
+ });
+ }
+}
+
+// Installations are customer configuration and stay on rollback; older versions ignore them.
+export async function down() {}
diff --git a/packages/core/src/db/migrations/20261114120000_add_task_repository_event_domain.js b/packages/core/src/db/migrations/20261114120000_add_task_repository_event_domain.js
new file mode 100644
index 0000000000..673162a506
--- /dev/null
+++ b/packages/core/src/db/migrations/20261114120000_add_task_repository_event_domain.js
@@ -0,0 +1,59 @@
+/**
+ * Allow the `task` domain in the repository event journal (#3547).
+ *
+ * `repository_events.domain` carries a CHECK constraint, which SQLite cannot
+ * alter in place, so the table is rebuilt with the wider constraint: rows are
+ * copied with their `seq`, and the AUTOINCREMENT high-water mark is carried
+ * over so a position retention already deleted is never handed out again.
+ * The indexes of the original table and of the NOCASE index migration are
+ * recreated. `repository_event_floors` and the journal identity are untouched,
+ * so existing cursors stay valid.
+ */
+const DOMAINS_BEFORE = ['pull_request', 'epic_queue', 'plan_issue'];
+const DOMAINS_AFTER = [...DOMAINS_BEFORE, 'task'];
+
+async function rebuild(knex, domains) {
+ const quoted = domains.map(domain => `'${domain}'`).join(', ');
+ const [sequence] = await knex.raw("SELECT seq FROM sqlite_sequence WHERE name = 'repository_events'");
+ await knex.raw('DROP TABLE IF EXISTS repository_events_rebuild');
+ await knex.raw(`CREATE TABLE repository_events_rebuild (
+ seq INTEGER PRIMARY KEY AUTOINCREMENT,
+ repository TEXT NOT NULL,
+ domain TEXT NOT NULL CHECK (domain IN (${quoted})),
+ type TEXT NOT NULL,
+ entity_id TEXT NOT NULL,
+ pr_number INTEGER NULL,
+ issue_number INTEGER NULL,
+ plan_id TEXT NULL,
+ head_sha TEXT NULL,
+ payload TEXT NOT NULL DEFAULT '{}',
+ dedupe_key TEXT NULL,
+ occurred_at TEXT NOT NULL
+ )`);
+ const columns = 'seq, repository, domain, type, entity_id, pr_number, issue_number, plan_id, head_sha, payload, dedupe_key, occurred_at';
+ await knex.raw(`INSERT INTO repository_events_rebuild (${columns}) SELECT ${columns} FROM repository_events WHERE domain IN (${quoted})`);
+ await knex.raw('DROP TABLE repository_events');
+ await knex.raw('ALTER TABLE repository_events_rebuild RENAME TO repository_events');
+ await knex.raw('CREATE INDEX repository_events_repository_seq ON repository_events (repository, seq)');
+ await knex.raw('CREATE INDEX repository_events_entity_seq ON repository_events (repository, domain, entity_id, seq)');
+ await knex.raw('CREATE UNIQUE INDEX repository_events_dedupe ON repository_events (repository, dedupe_key)');
+ await knex.raw('CREATE INDEX repository_events_occurred_at ON repository_events (occurred_at)');
+ await knex.raw('CREATE INDEX repository_events_repository_nocase_seq ON repository_events (repository COLLATE NOCASE, seq)');
+ const highWater = Number(sequence?.seq ?? 0);
+ if (highWater > 0) {
+ const [row] = await knex.raw("SELECT seq FROM sqlite_sequence WHERE name = 'repository_events'");
+ if (row) await knex.raw("UPDATE sqlite_sequence SET seq = MAX(seq, ?) WHERE name = 'repository_events'", [highWater]);
+ else await knex.raw("INSERT INTO sqlite_sequence (name, seq) VALUES ('repository_events', ?)", [highWater]);
+ }
+}
+
+export async function up(knex) {
+ if (!await knex.schema.hasTable('repository_events')) return;
+ await rebuild(knex, DOMAINS_AFTER);
+}
+
+/** Rolling back drops the `task` events: the narrower constraint cannot hold them. */
+export async function down(knex) {
+ if (!await knex.schema.hasTable('repository_events')) return;
+ await rebuild(knex, DOMAINS_BEFORE);
+}
diff --git a/packages/core/src/db/migrations/20261114180000_plugin_state.js b/packages/core/src/db/migrations/20261114180000_plugin_state.js
new file mode 100644
index 0000000000..c8b6d69757
--- /dev/null
+++ b/packages/core/src/db/migrations/20261114180000_plugin_state.js
@@ -0,0 +1,20 @@
+// Plugin handler state: a small key-value store per installed plugin
+// (`ctx.state`), for cursors and mappings such as "this pull request maps to
+// that chat thread". Values are JSON; `bytes` is the UTF-8 size of the stored
+// value, so the per-installation size limit is one indexed sum. Rows are
+// deleted when the plugin is uninstalled.
+
+export async function up(knex) {
+ if (await knex.schema.hasTable('plugin_state')) return;
+ await knex.schema.createTable('plugin_state', t => {
+ t.string('plugin_id').notNullable();
+ t.string('state_key', 200).notNullable();
+ t.text('value').notNullable();
+ t.integer('bytes').notNullable();
+ t.bigInteger('updated_at').notNullable();
+ t.primary(['plugin_id', 'state_key']);
+ });
+}
+
+// Handler state belongs to installations, which stay on rollback; older versions ignore it.
+export async function down() {}
diff --git a/packages/core/src/db/migrations/20261115000000_plugin_event_deliveries.js b/packages/core/src/db/migrations/20261115000000_plugin_event_deliveries.js
new file mode 100644
index 0000000000..beba7c7fdc
--- /dev/null
+++ b/packages/core/src/db/migrations/20261115000000_plugin_event_deliveries.js
@@ -0,0 +1,85 @@
+// Plugin event subscriptions: the dispatcher's durable state.
+//
+// `plugin_dispatch` has one row per installation: the journal position its
+// subscriptions start from (`start_seq`, "now" when it was installed or last
+// enabled), the delivery lease (only one worker delivers per installation;
+// the lease is a row with an expiry, not a database lock), and its delivery
+// health (consecutive failures, last error, Degraded and paused times).
+// `degraded_notice` is the queue (a JSON list) of administrator notifications
+// owed for becoming Degraded: each is appended with its transition and removed
+// once it was created.
+//
+// `plugin_delivery_cursors` holds, per installation and repository, the last
+// journal `seq` whose deliveries are final, and the journal floor already
+// recorded as a gap.
+//
+// `plugin_deliveries` records each delivery (one journal event to one handler)
+// and each gap: status, attempts, the latest attempt's duration, redacted
+// error and log lines. The event payload is never stored, only its `seq` and
+// type. `manual` marks an administrator's Retry until its one attempt is
+// final. Rows are pruned after a retention period.
+
+export async function up(knex) {
+ if (!await knex.schema.hasTable('plugin_dispatch')) {
+ await knex.schema.createTable('plugin_dispatch', t => {
+ t.string('plugin_id').primary();
+ // The installation this state belongs to: a reinstall starts over.
+ t.bigInteger('installed_at').notNullable();
+ t.bigInteger('start_seq').notNullable();
+ t.string('lease_owner');
+ t.bigInteger('lease_expires_at');
+ t.integer('consecutive_failures').notNullable().defaultTo(0);
+ t.text('last_error');
+ t.bigInteger('last_error_at');
+ t.bigInteger('degraded_at');
+ t.bigInteger('paused_at');
+ t.text('degraded_notice');
+ t.bigInteger('updated_at').notNullable();
+ });
+ }
+ // A database that ran an earlier copy of this migration lacks the column.
+ if (!await knex.schema.hasColumn('plugin_dispatch', 'degraded_notice')) {
+ await knex.schema.alterTable('plugin_dispatch', t => { t.text('degraded_notice'); });
+ }
+ if (!await knex.schema.hasTable('plugin_delivery_cursors')) {
+ await knex.schema.createTable('plugin_delivery_cursors', t => {
+ t.string('plugin_id').notNullable();
+ t.string('repository').notNullable();
+ t.bigInteger('cursor_seq').notNullable();
+ // The journal floor last recorded as a gap, so a gap is recorded once.
+ t.bigInteger('floor_seq').notNullable().defaultTo(1);
+ t.primary(['plugin_id', 'repository']);
+ });
+ }
+ if (!await knex.schema.hasTable('plugin_deliveries')) {
+ await knex.schema.createTable('plugin_deliveries', t => {
+ t.increments('id').primary();
+ t.string('plugin_id').notNullable();
+ t.string('delivery_id').notNullable();
+ t.string('kind').notNullable();
+ t.string('repository').notNullable();
+ t.bigInteger('event_seq').notNullable();
+ t.string('event_type');
+ t.string('handler');
+ t.string('status').notNullable();
+ t.integer('attempt').notNullable().defaultTo(0);
+ // An administrator's Retry: one attempt, out of the stream's order; recovered after a crash until it is final.
+ t.boolean('manual').notNullable().defaultTo(false);
+ t.bigInteger('next_attempt_at');
+ t.bigInteger('created_at').notNullable();
+ t.bigInteger('started_at');
+ t.bigInteger('finished_at');
+ t.integer('duration_ms');
+ t.boolean('timed_out').notNullable().defaultTo(false);
+ t.text('error');
+ t.text('logs').notNullable().defaultTo('[]');
+ t.unique(['plugin_id', 'delivery_id']);
+ t.index(['plugin_id', 'id']);
+ t.index(['plugin_id', 'repository', 'status']);
+ t.index(['created_at']);
+ });
+ }
+}
+
+// Delivery state is operational history; older versions ignore it.
+export async function down() {}
diff --git a/packages/core/src/extensions/endpointPolicy.ts b/packages/core/src/extensions/endpointPolicy.ts
index 2581953a5c..aa5359dff9 100644
--- a/packages/core/src/extensions/endpointPolicy.ts
+++ b/packages/core/src/extensions/endpointPolicy.ts
@@ -22,13 +22,17 @@ export class ExtensionUpstreamUnauthorizedError extends Error { constructor() {
export function extensionPublicAddress(address: string): boolean {
return !isNonPublicAddress(address) && !additional.check(address, isIP(address) === 6 ? 'ipv6' : 'ipv4');
}
+/** Names that resolve to this host, the local network or cloud metadata, whatever DNS says. */
+export function extensionReservedHostname(host: string): boolean {
+ return /^(localhost|.*\.localhost|.*\.local|metadata\.google\.internal)$/i.test(host);
+}
export function extensionEndpoint(value: string): URL {
let url: URL; try { url = new URL(value); } catch { throw new Error('Invalid MCP endpoint'); }
// Query parameters can be credentials; v1 accepts path-only endpoints.
if (!value.startsWith('https://') || /[?#]/.test(value) || url.protocol !== 'https:' || url.username || url.password || url.hash || url.search || (url.port && url.port !== '443') || value.length > 2048) throw new Error('MCP endpoints require HTTPS without credentials, query parameters or fragments');
const host = url.hostname.replace(/^\[|\]$/g, '');
if (isIP(host) && !extensionPublicAddress(host)) throw new Error('MCP endpoint must be public');
- if (/^(localhost|.*\.localhost|.*\.local|metadata\.google\.internal)$/i.test(host)) throw new Error('MCP endpoint must be public');
+ if (extensionReservedHostname(host)) throw new Error('MCP endpoint must be public');
return url;
}
export type ExtensionLookup = (host: string) => Promise>;
diff --git a/packages/core/src/extensions/extensionManagedItems.ts b/packages/core/src/extensions/extensionManagedItems.ts
new file mode 100644
index 0000000000..4a59c40510
--- /dev/null
+++ b/packages/core/src/extensions/extensionManagedItems.ts
@@ -0,0 +1,52 @@
+import { EXTENSION_MANAGED_EDITABLE_FIELDS, type ExtensionLibraryItems, type ExtensionLibraryServer, type ExtensionLibrarySkill } from '@propr/shared';
+import { ExtensionError } from './extensionProfileStore.js';
+import { credentialOwner } from './extensionCredentials.js';
+
+type Item = ExtensionLibraryServer | ExtensionLibrarySkill;
+const key = (item: Item) => ('kind' in item ? `server:${item.name}` : `skill:${item.bundleId}`);
+const label = (item: Item) => ('kind' in item ? item.name : item.bundleId);
+/** The read-only part of an item: everything except the fields an administrator may change. */
+function definition(item: Item, fallback?: Item): Record {
+ const rest: Record = { ...item };
+ for (const field of EXTENSION_MANAGED_EDITABLE_FIELDS) delete rest[field];
+ // A save request may leave out the integrity value; ProPR fills it from the saved revision.
+ if ('kind' in item && item.kind === 'package' && rest.integrity === undefined && fallback && 'kind' in fallback && fallback.kind === 'package') rest.integrity = fallback.integrity;
+ return Object.fromEntries(Object.entries(rest).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)));
+}
+
+/**
+ * Refuse a library save that edits, removes or invents a plugin-managed item.
+ * Managed items keep their definition, marker and stored credentials; only
+ * `enabled`, `repositories`, `agents` and `autoApply` may change. They are
+ * removed by uninstalling the plugin, never through the library.
+ */
+export function assertManagedItemsUnchanged(previous: Pick, next: Pick, replacements: Record = {}): void {
+ const before = new Map([...previous.servers, ...previous.skills].map(item => [key(item), item]));
+ const after = new Map([...next.servers, ...next.skills].map(item => [key(item), item]));
+ for (const [itemKey, item] of before) {
+ if (!item.managedBy) continue;
+ const current = after.get(itemKey);
+ if (!current) throw new ExtensionError(409, `${label(item)} is managed by plugin ${item.managedBy.plugin}; uninstall the plugin to remove it`);
+ if (JSON.stringify(definition(current, item)) !== JSON.stringify(definition(item))) throw new ExtensionError(409, `${label(item)} is managed by plugin ${item.managedBy.plugin}; only enabled, repository and agent limits and automatic apply can be changed`);
+ }
+ for (const [itemKey, item] of after) {
+ if (item.managedBy && !before.get(itemKey)?.managedBy) throw new ExtensionError(400, 'Only ProPR adds plugin-managed items');
+ }
+ for (const name of Object.keys(replacements)) {
+ if (isManagedCredential(previous.servers, name)) throw new ExtensionError(409, `The credential of ${credentialOwner(name)} comes from its plugin's connections`);
+ }
+}
+
+/**
+ * The items of a save request with the marker of each saved managed item
+ * filled in where the request left it out (a client that rebuilds items from
+ * their editable fields). Every other field is compared as sent.
+ */
+export function withManagedMarkers>(previous: Pick, next: T): T {
+ const marker = (item: Item) => (item.managedBy ? item : [...previous.servers, ...previous.skills].find(saved => saved.managedBy && key(saved) === key(item))?.managedBy);
+ const fill = (item: I): I => { const found = marker(item); return found && found !== item ? { ...item, managedBy: found } : item; };
+ return { ...next, servers: next.servers.map(fill), skills: next.skills.map(fill) };
+}
+
+/** Whether a sealed-credential key (`server` or `server/VARIABLE`) belongs to a managed server. */
+export const isManagedCredential = (servers: readonly ExtensionLibraryServer[], name: string) => servers.some(s => s.managedBy && s.name === credentialOwner(name));
diff --git a/packages/core/src/extensions/extensionOAuth.ts b/packages/core/src/extensions/extensionOAuth.ts
index 1d85e70776..0465b1ee7f 100644
--- a/packages/core/src/extensions/extensionOAuth.ts
+++ b/packages/core/src/extensions/extensionOAuth.ts
@@ -131,13 +131,22 @@ export async function discoverOAuth(transport: OAuthTransport, serverUrl: string
* The registration method a sign-in uses. A client id the administrator
* entered is explicit configuration and is used as given; otherwise a Client
* ID Metadata Document when the server supports it and this instance has a
- * public URL, then Dynamic Client Registration. Null when none is possible.
+ * public URL, then Dynamic Client Registration. With `allowed` (a plugin
+ * connection's permitted methods) only those are considered, in that order,
+ * except that a configured client id always selects pre-registration (or
+ * nothing when pre-registration is not permitted). Null when none is possible.
*/
-export function chooseRegistration(metadata: AuthorizationServerMetadata, options: { clientId?: string; clientMetadataUrl: string | null }): ExtensionOAuthRegistration | null {
- if (options.clientId) return 'preregistered';
- if (metadata.client_id_metadata_document_supported && options.clientMetadataUrl) return 'metadata_document';
- if (metadata.registration_endpoint) return 'dynamic';
- return null;
+export function chooseRegistration(metadata: AuthorizationServerMetadata, options: { clientId?: string; clientMetadataUrl: string | null; allowed?: readonly ExtensionOAuthRegistration[] }): ExtensionOAuthRegistration | null {
+ // A configured client id binds the server's connection to that client (see connectionBelongsTo), so no automatic
+ // registration can produce a usable connection for it: pre-registration is the only method, whatever the order.
+ if (options.clientId) return !options.allowed || options.allowed.includes('preregistered') ? 'preregistered' : null;
+ const available: Record = {
+ preregistered: false,
+ metadata_document: Boolean(metadata.client_id_metadata_document_supported && options.clientMetadataUrl),
+ dynamic: Boolean(metadata.registration_endpoint),
+ };
+ const order: readonly ExtensionOAuthRegistration[] = options.allowed ?? ['preregistered', 'metadata_document', 'dynamic'];
+ return order.find(method => available[method]) ?? null;
}
/** ProPR's own client metadata, served at `clientMetadataUrl`, which is also the client id. */
diff --git a/packages/core/src/extensions/extensionOAuthState.ts b/packages/core/src/extensions/extensionOAuthState.ts
index 69b4b405d0..5e13fb22cb 100644
--- a/packages/core/src/extensions/extensionOAuthState.ts
+++ b/packages/core/src/extensions/extensionOAuthState.ts
@@ -74,6 +74,25 @@ export async function discardStaleOAuthConnections(trx: Knex.Transaction, instan
return stale;
}
+/**
+ * Inside a change that replaces the OAuth client of `serverNames` (a client
+ * secret rotation keeps everything `connectionBelongsTo` compares): delete
+ * their pending sign-ins, then their connections. The pending rows go first:
+ * a callback still exchanging a code under the old client then finds its
+ * sign-in cancelled, and one that already committed has its connection
+ * deleted by the second statement.
+ */
+export async function discardOAuthConnectionsOf(trx: Knex.Transaction, instanceId: string, serverNames: readonly string[], actor: string): Promise {
+ if (!serverNames.length || !await hasOAuthTables(trx)) return [];
+ await trx(OAUTH_PENDING).where({ instance_id: instanceId }).whereIn('server_name', serverNames).delete();
+ const discarded = (await trx(OAUTH_CONNECTIONS).where({ instance_id: instanceId }).whereIn('server_name', serverNames).select('server_name')).map(row => row.server_name as string);
+ if (!discarded.length) return [];
+ await trx(OAUTH_CONNECTIONS).where({ instance_id: instanceId }).whereIn('server_name', discarded).delete();
+ const now = Date.now();
+ await trx(OAUTH_AUDIT).insert(discarded.map(name => ({ instance_id: instanceId, server_name: name, action: 'discarded', actor, at: now })));
+ return discarded;
+}
+
/** The library view with the current connection state of its enabled OAuth servers, for resolution. */
export async function withOAuthStates(database: Knex, snapshot: T): Promise }> {
return { ...snapshot, oauth: await oauthConnectionStates(database, snapshot.instanceId, snapshot.servers.filter(s => s.enabled)) };
diff --git a/packages/core/src/extensions/extensionOAuthStore.ts b/packages/core/src/extensions/extensionOAuthStore.ts
index 7ddd5e7bad..a680d52465 100644
--- a/packages/core/src/extensions/extensionOAuthStore.ts
+++ b/packages/core/src/extensions/extensionOAuthStore.ts
@@ -148,11 +148,14 @@ export class ExtensionOAuthStore {
try { discovery = await discoverOAuth(this.transport, server.url, server.scopes); }
catch (error) { throw new ExtensionError(502, error instanceof OAuthProtocolError || error instanceof OAuthNetworkError ? error.message : 'OAuth discovery failed'); }
if (!discovery) throw new ExtensionError(409, 'The server answered without requiring authorization');
- const registration = chooseRegistration(discovery.metadata, { clientId: server.clientId, clientMetadataUrl: urls.clientMetadataUrl });
+ // A plugin-managed server signs in only through the registration methods its plugin connection permits.
+ const allowed = server.managedBy?.registrations;
+ const registration = chooseRegistration(discovery.metadata, { clientId: server.clientId, clientMetadataUrl: urls.clientMetadataUrl, ...(allowed ? { allowed } : {}) });
let client: OAuthClient;
if (registration === 'preregistered') client = preregisteredClient(discovery.metadata, server.clientId!, secret);
else if (registration === 'metadata_document') client = { registration, clientId: urls.clientMetadataUrl!, authMethod: 'none' };
else if (registration === 'dynamic') client = await this.dynamicClient(instanceId, server, discovery.metadata, urls.redirectUri);
+ else if (allowed) throw new ExtensionError(409, `The authorization server supports none of the client registration methods the plugin permits (${allowed.join(', ')})`);
else throw new ExtensionError(409, 'The authorization server supports no client registration ProPR can use. Enter a pre-registered client id, or use a Bearer token.');
const pkce = createPkce();
const { clientSecret, ...publicClient } = client;
@@ -198,15 +201,18 @@ export class ExtensionOAuthStore {
* unused, unexpired and started by this administrator in this session for
* the same item and endpoint; `iss` must name the issuer when supplied (and
* when the server promises it). Nothing is stored unless the code exchange succeeds.
+ * With `servers`, only a sign-in pending for one of those items is claimed; any
+ * other state is refused without consuming it.
*/
- async completeSignIn(actor: string, sessionId: string, query: { state?: unknown; code?: unknown; error?: unknown; iss?: unknown }): Promise<{ name: string }> {
+ async completeSignIn(actor: string, sessionId: string, query: { state?: unknown; code?: unknown; error?: unknown; iss?: unknown }, servers?: readonly string[]): Promise<{ name: string }> {
await this.tables();
if (typeof query.state !== 'string' || !/^[A-Za-z0-9_-]{43}$/.test(query.state)) throw new ExtensionError(400, 'Unknown, used or expired sign-in request');
const stateHash = sha256(query.state);
// Single use: the first callback claims the state, and it is deleted whatever the outcome. The claimed
// row is this attempt: Disconnect and binding changes delete it, which cancels the attempt.
const claim = randomUUID();
- if (await this.database(OAUTH_PENDING).where({ state_hash: stateHash }).whereNull('claim').update({ claim }) !== 1) throw new ExtensionError(400, 'Unknown, used or expired sign-in request');
+ const pending = this.database(OAUTH_PENDING).where({ state_hash: stateHash }).whereNull('claim');
+ if (await (servers ? pending.whereIn('server_name', [...servers]) : pending).update({ claim }) !== 1) throw new ExtensionError(400, 'Unknown, used or expired sign-in request');
try { return await this.completeClaimed(actor, sessionId, query, { state_hash: stateHash, claim }); }
finally { await this.database(OAUTH_PENDING).where({ state_hash: stateHash, claim }).delete(); }
}
diff --git a/packages/core/src/extensions/extensionPackageLibrary.ts b/packages/core/src/extensions/extensionPackageLibrary.ts
index eee9117448..7efbbde72f 100644
--- a/packages/core/src/extensions/extensionPackageLibrary.ts
+++ b/packages/core/src/extensions/extensionPackageLibrary.ts
@@ -20,6 +20,9 @@ export async function pinPackageServers(servers: ExtensionLibraryServer[], saved
return pinned;
}
+/** Acknowledgements of plugin-managed package servers, from their plugin; a name mapped to undefined has none. */
+export type ManagedAcknowledgements = Record;
+
const acknowledgementBinding = (value: { package: string; version: string; secretEnv: string[] }) => JSON.stringify([value.package, value.version, [...value.secretEnv].sort()]);
/** Validate the names a save acknowledges: package servers with secret variables only. */
@@ -36,12 +39,22 @@ export function parseAcknowledgements(value: unknown, servers: ExtensionLibraryS
* version and secret variable names are unchanged, otherwise given by this
* save (`acknowledged`) and recorded with the actor and time. With `carry`
* (a change that alters no server) existing ones carry over unchanged.
+ *
+ * A plugin-managed server's acknowledgement is the one recorded when its
+ * plugin was installed or approved, passed in `managed` by the plugin sync;
+ * a library save never acknowledges it. Without one the server is recorded
+ * without an acknowledgement and its marker keeps it inactive.
*/
-// eslint-disable-next-line max-params -- the base revision, new servers, acknowledged names, actor and time are explicit inputs
-export function secretAcknowledgements(previous: ExtensionLibraryRevision, servers: ExtensionLibraryServer[], acknowledged: readonly string[] | 'carry', actor: string, now: number): Record {
+// eslint-disable-next-line max-params -- the base revision, new servers, acknowledged names, actor, time and managed acknowledgements are explicit inputs
+export function secretAcknowledgements(previous: ExtensionLibraryRevision, servers: ExtensionLibraryServer[], acknowledged: readonly string[] | 'carry', actor: string, now: number, managed: ManagedAcknowledgements = {}): Record {
const result: Record = {};
for (const server of packageSecretServers(servers)) {
const carried = previous.acknowledgements && Object.hasOwn(previous.acknowledgements, server.name) ? previous.acknowledgements[server.name] : undefined;
+ if (server.managedBy) {
+ const recorded = Object.hasOwn(managed, server.name) ? managed[server.name] : carried;
+ if (recorded && acknowledgementBinding(recorded) === acknowledgementBinding(server)) result[server.name] = recorded;
+ continue;
+ }
if (carried && acknowledgementBinding(carried) === acknowledgementBinding(server)) result[server.name] = carried;
else if (acknowledged !== 'carry' && acknowledged.includes(server.name)) result[server.name] = { actor, at: now, package: server.package, version: server.version, secretEnv: [...server.secretEnv] };
else if (acknowledged !== 'carry') throw new ExtensionError(400, `Confirm that the secret variables of ${server.name} are readable by the agent and scripts in the task container before saving`);
diff --git a/packages/core/src/extensions/extensionProfileStore.ts b/packages/core/src/extensions/extensionProfileStore.ts
index 98719558b1..391054c8d6 100644
--- a/packages/core/src/extensions/extensionProfileStore.ts
+++ b/packages/core/src/extensions/extensionProfileStore.ts
@@ -17,7 +17,8 @@ import { discardStaleOAuthConnections, withOAuthStates } from './extensionOAuthS
import {
bearerServers, credentialBindings, credentialField, credentialOwner, credentialServers, isCredentialKey, nextCredentials, packageSecretKeys, packageSecretServers, packageSecretStatus, packageSecretValues, revisionSecretStatuses,
} from './extensionCredentials.js';
-import { parseAcknowledgements, pinPackageServers, secretAcknowledgements } from './extensionPackageLibrary.js';
+import { parseAcknowledgements, pinPackageServers, secretAcknowledgements, type ManagedAcknowledgements } from './extensionPackageLibrary.js';
+import { assertManagedItemsUnchanged, isManagedCredential, withManagedMarkers } from './extensionManagedItems.js';
export interface ExtensionGrantNetwork { mode: 'open' | 'restricted'; allow: string[]; instanceAllow: string[] }
export class ExtensionError extends Error { constructor(readonly status: number, message: string) { super(message); } }
@@ -41,7 +42,7 @@ export function unsealLibrarySecrets(encrypted: string, instanceId: string, revi
/** A revision's sealed credentials as a later revision carries them, naming the revision that sealed them. */
const carriedSecrets = (head: Head) => (head.secretsEncrypted === '' || carriedSecretsPattern.test(head.secretsEncrypted) ? head.secretsEncrypted : `${head.snapshot.revision}:${head.secretsEncrypted}`);
/** Passed instead of credentials by a change that alters none: the base revision's sealed credentials carry over. */
-const UNCHANGED_CREDENTIALS = Symbol('unchanged library credentials');
+export const UNCHANGED_CREDENTIALS = Symbol('unchanged library credentials');
const legacyIdentity = (s: { instanceId: string; profileId: string; revision: number }) => `${s.instanceId}/${s.profileId}/${s.revision}`;
const tokenPattern = /^[\x21-\x7e]{1,4096}$/;
@@ -61,7 +62,7 @@ export interface GrantSnapshot {
snapshot: { instanceId: string; revision: number; servers: ExtensionLibraryServer[]; skillBundles: ExtensionSkillBundle[] };
secrets: Record; network: ExtensionGrantNetwork; taskId: string; allowWrite: boolean;
}
-interface Head { snapshot: ExtensionLibraryRevision; secretsEncrypted: string }
+export interface Head { snapshot: ExtensionLibraryRevision; secretsEncrypted: string }
const emptyLibrary = (instanceId: string): ExtensionLibraryRevision => ({
version: EXTENSION_LIBRARY_VERSION, instanceId, revision: 0, createdAt: 0, updatedAt: 0, auditActor: '',
@@ -107,7 +108,7 @@ export class ExtensionLibraryStore {
if (row.secrets_encrypted === '') for (const server of [...credentialServers(snapshot.servers), ...packageSecretServers(snapshot.servers)]) snapshot.secrets[server.name] = server.kind === 'package' ? packageSecretStatus(server, {}) : { fields: credentialField(server), configured: false };
return { snapshot: publicExtensionProfile(snapshot), secretsEncrypted: row.secrets_encrypted as string };
}
- private unseal(head: Head): Record {
+ unseal(head: Head): Record {
return unsealLibrarySecrets(head.secretsEncrypted, head.snapshot.instanceId, head.snapshot.revision, this.environment);
}
@@ -195,7 +196,7 @@ export class ExtensionLibraryStore {
* bound to the same Bearer servers and endpoints.
*/
// eslint-disable-next-line max-params -- the transaction, actor, base head, items, credentials, catalog status and acknowledgements are explicit revision inputs
- private async writeRevision(trx: Knex.Transaction, actor: string, head: Head, items: ExtensionLibraryItems, secrets: Record | typeof UNCHANGED_CREDENTIALS, catalogSecrets: Map, acknowledged: readonly string[] | 'carry' = 'carry'): Promise {
+ async writeRevision(trx: Knex.Transaction, actor: string, head: Head, items: ExtensionLibraryItems, secrets: Record | typeof UNCHANGED_CREDENTIALS, catalogSecrets: Map, acknowledged: readonly string[] | 'carry' = 'carry', managed?: ManagedAcknowledgements): Promise {
const { instanceId } = head.snapshot; const previous = head.snapshot; const revision = previous.revision + 1; const now = Date.now();
const bundleIds = items.skills.map(s => s.bundleId);
const bundleRows = bundleIds.length ? await trx('extension_skill_bundles').where({ instance_id: instanceId }).whereIn('id', bundleIds) : [];
@@ -209,7 +210,7 @@ export class ExtensionLibraryStore {
version: EXTENSION_LIBRARY_VERSION, instanceId, revision, createdAt: previous.revision ? previous.createdAt : now, updatedAt: now, auditActor: actor,
servers: items.servers, skills: items.skills, secrets: statuses, skillBundles: bundleIds.map(id => descriptors.get(id)!),
};
- const acknowledgements = secretAcknowledgements(previous, items.servers, acknowledged, actor, now);
+ const acknowledgements = secretAcknowledgements(previous, items.servers, acknowledged, actor, now, managed);
if (Object.keys(acknowledgements).length) snapshot.acknowledgements = acknowledgements;
const sealed = unchanged ? carriedSecrets(head) : sealExtensionSecrets(Object.fromEntries(Object.entries(secrets).filter(([name, token]) => token && Object.hasOwn(statuses, credentialOwner(name)))), libraryIdentity(instanceId, revision), this.environment);
if (previous.revision === 0 && !await trx('extension_library').where({ instance_id: instanceId }).first()) await trx('extension_library').insert({ instance_id: instanceId, revision });
@@ -219,7 +220,7 @@ export class ExtensionLibraryStore {
}
/** Items removed by this change that automations still add explicitly are refused, naming the automations. */
- private async refuseReferencedRemoval(trx: Knex, previous: ExtensionLibraryRevision, next: ExtensionLibraryItems): Promise {
+ async refuseReferencedRemoval(trx: Knex, previous: ExtensionLibraryRevision, next: ExtensionLibraryItems): Promise {
const servers = previous.servers.map(s => s.name).filter(name => !next.servers.some(s => s.name === name));
const skills = previous.skills.map(s => s.bundleId).filter(id => !next.skills.some(s => s.bundleId === id));
const using = await this.automationsAdding({ servers, skills }, trx);
@@ -233,14 +234,15 @@ export class ExtensionLibraryStore {
if (!replacements || typeof replacements !== 'object' || Array.isArray(replacements) || Object.entries(replacements).some(([name, token]) => !isCredentialKey(parsed.servers, name) || (token !== null && (typeof token !== 'string' || !tokenPattern.test(token))))) throw new ExtensionError(400, 'Invalid secret replacement');
const acknowledged = parseAcknowledgements(acknowledgedInput, parsed.servers);
await this.authorize(actor);
+ const saved = (await this.head()).snapshot; parsed = withManagedMarkers(saved, parsed); assertManagedItemsUnchanged(saved, parsed, replacements);
// Package versions resolve to the registry's integrity value; nothing is installed or run here.
- parsed = { ...parsed, servers: await pinPackageServers(parsed.servers, (await this.head()).snapshot.servers, this.environment) };
+ parsed = { ...parsed, servers: await pinPackageServers(parsed.servers, saved.servers, this.environment) };
const catalogSecrets = await this.validateServers(parsed.servers);
return this.database.transaction(async trx => {
await this.authorize(actor, trx);
const head = await this.head(trx);
if (head.snapshot.revision !== expectedRevision) throw new ExtensionError(409, 'Library revision changed; refresh before saving');
- await this.refuseReferencedRemoval(trx, head.snapshot, parsed);
+ assertManagedItemsUnchanged(head.snapshot, parsed, replacements); await this.refuseReferencedRemoval(trx, head.snapshot, parsed);
const secrets = this.unseal(head);
// Checked before credentials are cleared, dropped with their server or
// withheld from a changed endpoint: none may become a public field.
@@ -261,6 +263,7 @@ export class ExtensionLibraryStore {
const head = await this.head(trx);
if (expectedRevision !== undefined && head.snapshot.revision !== expectedRevision) throw new ExtensionError(409, 'Library revision changed; refresh before saving');
if (!isCredentialKey(head.snapshot.servers, name)) throw new ExtensionError(404, 'Bearer-authenticated, pre-registered OAuth or package secret variable unavailable');
+ if (isManagedCredential(head.snapshot.servers, name)) throw new ExtensionError(409, 'The credential of a plugin-managed server comes from its plugin\'s connections');
const secrets = { ...this.unseal(head) };
if (token) secrets[name] = token; else delete secrets[name];
return this.writeRevision(trx, actor, head, { version: EXTENSION_LIBRARY_VERSION, servers: head.snapshot.servers, skills: head.snapshot.skills }, secrets, new Map());
@@ -298,7 +301,8 @@ export class ExtensionLibraryStore {
return { servers: head.snapshot.servers.filter(s => s.enabled), secrets: this.unseal(head) };
}
- async importSkill(actor: string, files: SkillFile[], source: ExtensionSkillBundle['source']): Promise {
+ /** Import an uploaded or GitHub skill. Plugin skills are written by the plugin sync, never imported here. */
+ async importSkill(actor: string, files: SkillFile[], source: Exclude): Promise {
await this.authorize(actor); const validated = validateSkillFiles(files);
if (source.kind === 'github' && !/^[0-9a-f]{40}$/.test(source.commit)) throw new ExtensionError(400, 'Skill source must pin an exact Git commit');
// Project provenance explicitly; TypeScript structural types cannot exclude extra runtime fields.
@@ -508,5 +512,4 @@ export class ExtensionLibraryStore {
}
}
/** @deprecated Extension profiles were replaced by the library; use ExtensionLibraryStore. */
-export const ExtensionProfileStore = ExtensionLibraryStore;
-export type ExtensionProfileStore = ExtensionLibraryStore;
+export const ExtensionProfileStore = ExtensionLibraryStore; export type ExtensionProfileStore = ExtensionLibraryStore;
diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
index d52e729edd..f54518801e 100644
--- a/packages/core/src/index.ts
+++ b/packages/core/src/index.ts
@@ -677,6 +677,9 @@ export * from './services/reviewScoreStore.js';
export * from './services/pullRequestStatus/index.js';
// Durable repository change-event journal (pull requests, epic queues, plan issues).
export * from './services/repositoryEventJournal.js';
+// Plugin event subscriptions: the journal dispatcher and its delivery records.
+export { PluginDispatcher, type PluginDispatcherOptions, type PluginDispatchPass } from './plugins/pluginDispatcher.js';
+export { prunePluginDeliveries } from './plugins/pluginDeliveries.js';
// GitHub user profile cache: maps stored GitHub numeric user ids to the login
// and avatar an assignee is rendered with.
diff --git a/packages/core/src/plugins/pluginAssets.ts b/packages/core/src/plugins/pluginAssets.ts
new file mode 100644
index 0000000000..bee98fe79d
--- /dev/null
+++ b/packages/core/src/plugins/pluginAssets.ts
@@ -0,0 +1,68 @@
+import { lstat, readFile, readdir } from 'node:fs/promises';
+import path from 'node:path';
+import { EXTENSION_LIMITS, validateAutomationTemplate, type AutomationTemplate, type PluginManifest } from '@propr/shared';
+import type { SkillFile } from '../extensions/skillBundles.js';
+
+/**
+ * Files an official plugin ships next to its manifest: skill folders and
+ * automation templates. Read from the plugin folder of the registry package;
+ * nothing in them is executed.
+ */
+export interface PluginAssetSource {
+ /** The files of a skill folder (`path` from the manifest), base64-encoded, relative to the folder. */
+ skillFiles(manifest: PluginManifest, skillPath: string): Promise;
+ /** One automation template in the shipped format (`template.json` + `prompt.md`), validated. */
+ automationTemplate(manifest: PluginManifest, templatePath: string): Promise;
+}
+
+interface PluginRegistryModule { officialPlugins(options?: { includeDevelopment?: boolean }): { manifest: PluginManifest; directory: string }[] }
+const REGISTRY_MODULE = '@propr/plugins';
+
+/** A file or folder inside `directory`; manifest paths are already relative without `..`, checked again here. */
+function inside(directory: string, relative: string): string {
+ const target = path.resolve(directory, ...relative.split('/'));
+ if (target !== directory && !target.startsWith(directory + path.sep)) throw new Error('Plugin asset outside its folder');
+ return target;
+}
+
+async function listFiles(root: string, prefix = ''): Promise {
+ const files: string[] = [];
+ for (const entry of (await readdir(path.join(root, prefix), { withFileTypes: true })).sort((a, b) => a.name.localeCompare(b.name, 'en'))) {
+ const relative = prefix ? `${prefix}/${entry.name}` : entry.name;
+ // Links are never followed: a skill is the regular files of its folder.
+ if (entry.isDirectory()) files.push(...await listFiles(root, relative));
+ else if (entry.isFile()) files.push(relative);
+ if (files.length > EXTENSION_LIMITS.files) throw new Error('Plugin skill folder has too many files');
+ }
+ return files;
+}
+
+/** Assets of plugin folders found by `directoryOf`. */
+export function pluginFolderAssets(directoryOf: (manifest: PluginManifest) => Promise): PluginAssetSource {
+ return {
+ async skillFiles(manifest, skillPath) {
+ const folder = inside(await directoryOf(manifest), skillPath);
+ if (!(await lstat(folder)).isDirectory()) throw new Error('Plugin skill folder unavailable');
+ const files: SkillFile[] = [];
+ for (const relative of await listFiles(folder)) files.push({ path: relative, content: (await readFile(path.join(folder, ...relative.split('/')))).toString('base64') });
+ return files;
+ },
+ async automationTemplate(manifest, templatePath) {
+ const folder = inside(await directoryOf(manifest), templatePath);
+ const metadata = JSON.parse(await readFile(path.join(folder, 'template.json'), 'utf8')) as Record;
+ const template = { ...metadata, prompt: (await readFile(path.join(folder, 'prompt.md'), 'utf8')).trim() };
+ const error = 'prompt' in metadata ? 'the prompt belongs in prompt.md' : validateAutomationTemplate(template);
+ if (error) throw new Error(`Plugin automation template invalid: ${error}`);
+ return template as AutomationTemplate;
+ },
+ };
+}
+
+/** Assets of the official registry (`@propr/plugins`): the folder of the registry plugin with the manifest's id. */
+export const officialPluginAssets: PluginAssetSource = pluginFolderAssets(async manifest => {
+ const registry = await import(REGISTRY_MODULE) as PluginRegistryModule;
+ // Assets are read only for plugins the catalog offered; the development filter applied there.
+ const plugin = registry.officialPlugins({ includeDevelopment: true }).find(p => p.manifest.id === manifest.id);
+ if (!plugin) throw new Error('Plugin folder unavailable');
+ return plugin.directory;
+});
diff --git a/packages/core/src/plugins/pluginCatalog.ts b/packages/core/src/plugins/pluginCatalog.ts
new file mode 100644
index 0000000000..0eb76f4519
--- /dev/null
+++ b/packages/core/src/plugins/pluginCatalog.ts
@@ -0,0 +1,22 @@
+import type { PluginManifest } from '@propr/shared';
+import logger from '../utils/logger.js';
+
+/** The plugins this ProPR offers, in catalog order. */
+export type PluginCatalogSource = () => Promise;
+
+interface PluginRegistryModule { officialPlugins(): { manifest: PluginManifest }[] }
+// Not a literal import: Core stays buildable and runnable where the registry
+// package is not part of the image; the catalog is then empty.
+const REGISTRY_MODULE = '@propr/plugins';
+let warned = false;
+
+/** The official plugin registry (`@propr/plugins`), subject to its development flag. */
+export const officialPluginCatalog: PluginCatalogSource = async () => {
+ try {
+ const registry = await import(REGISTRY_MODULE) as PluginRegistryModule;
+ return registry.officialPlugins().map(plugin => plugin.manifest);
+ } catch (error) {
+ if (!warned) { warned = true; logger.warn({ error: error instanceof Error ? error.message : String(error) }, '[plugins] Official plugin registry unavailable; no plugins are offered'); }
+ return [];
+ }
+};
diff --git a/packages/core/src/plugins/pluginContext.ts b/packages/core/src/plugins/pluginContext.ts
new file mode 100644
index 0000000000..3d8f097241
--- /dev/null
+++ b/packages/core/src/plugins/pluginContext.ts
@@ -0,0 +1,244 @@
+import type { Knex } from 'knex';
+import {
+ PLUGIN_LOG_LIMITS, PluginSdkError, defaultPluginSettings, validatePluginSettings,
+ type PluginContext, type PluginHttpFetch, type PluginInstallationRecord, type PluginJsonValue, type PluginLogLevel, type PluginLogLine,
+ type PluginAuditAction, type PluginLogger, type PluginManifest, type PluginPermission,
+} from '@propr/shared';
+import logger from '../utils/logger.js';
+import type { ExtensionLookup } from '../extensions/endpointPolicy.js';
+import { redactExtensionOutput } from '../extensions/extensionOutput.js';
+import { createPluginHttp, scrubCredentialValues, type PluginHttpTransport } from './pluginHttp.js';
+import { githubPluginProprBackend } from './pluginProprBackend.js';
+import { pluginActor, scopedProprClient, type PluginProprBackend, type PluginProprCallOutcome, type PluginProprCallRecord, type PluginProprCallScope } from './pluginProprClient.js';
+import { pluginStateStore, requireInstallation, type PluginInstallationAuthority, type PluginStateWriteGuard } from './pluginStateStore.js';
+
+/** An installed plugin as a handler runs it: the current manifest and the installation record. */
+export interface PluginInstallationTarget { pluginId: string; manifest: PluginManifest; record: PluginInstallationRecord }
+
+export interface PluginContextDeps {
+ database: Knex;
+ environment?: NodeJS.ProcessEnv;
+ /** The ProPR services behind `ctx.propr`; defaults to GitHub, the event journal and the task queue. */
+ backend?: PluginProprBackend;
+ /** The stored secret of a connection; defaults to the plugin store's sealed connection secrets. */
+ connectionSecret?: (pluginId: string, connection: string) => Promise;
+ lookup?: ExtensionLookup;
+ transport?: PluginHttpTransport;
+ now?: () => number;
+}
+
+/** What building a context returns besides `ctx`: the captured log and redaction of anything returned to the caller. */
+export interface BuiltPluginContext {
+ ctx: PluginContext;
+ /** The log lines for the caller: each redacted again with every credential of the run, then cut to `PLUGIN_LOG_LIMITS.messageChars`. */
+ finalLogs(): PluginLogLine[];
+ /** Redact a value for the caller: connection credentials used in this run (in every spelling, percent-escaped too) and the general secret patterns. */
+ redact(value: T): T;
+ /** Redact `text` (kept with `retainForRedaction`), then cut it to `limit` characters, withholding a credential the cut would leave partly visible. */
+ clip(text: string, limit: number): string;
+}
+
+/**
+ * Characters of a message kept past its length limit until the run's final
+ * redaction. A credential that straddles the limit (and may only be acquired
+ * after the line was written) is then still matched whole and redacted before
+ * the message is cut, rather than cut first and left one character short. It
+ * covers every spelling of a storable connection secret (at most 4,096
+ * characters, `PLUGIN_SECRET_PATTERN`): the hex of its header value, each
+ * character percent-escaped, is about 24,600 characters.
+ */
+const REDACTION_MARGIN_CHARS = 32 * 1024;
+/** `text` as kept for a later redaction and cut to `limit`: `limit` characters and the margin. */
+export const retainForRedaction = (text: string, limit: number): string => text.slice(0, limit + REDACTION_MARGIN_CHARS);
+
+/** The raw spellings of a credential whose start a cut message could end with (`[spelling, any letter case]`). */
+function cutSpellings(secret: string): [string, boolean][] {
+ return [secret, `Bearer ${secret}`].flatMap(value => [
+ [value, false], [Buffer.from(value).toString('base64'), false], [Buffer.from(value).toString('base64url'), false],
+ [encodeURIComponent(value), false], [Buffer.from(value).toString('hex'), true],
+ ] as [string, boolean][]);
+}
+/** How many trailing characters of `text` begin a credential spelling: a credential longer than the margin, cut at the limit. */
+function cutCredentialTail(text: string, credentials: readonly string[]): number {
+ let longest = 0;
+ const folded = text.toLowerCase();
+ for (const [spelling, anyCase] of credentials.filter(Boolean).flatMap(cutSpellings)) {
+ const haystack = anyCase ? folded : text;
+ for (let length = Math.min(spelling.length - 1, haystack.length); length > longest; length--) {
+ if (haystack.endsWith(spelling.slice(0, length))) { longest = length; break; }
+ }
+ }
+ return longest;
+}
+
+/** The audit action of each recorded `ctx.propr` call outcome. */
+const CALL_AUDIT_ACTIONS = { attempted: 'handler_attempt', completed: 'handler_call', refused: 'handler_refused' } as const satisfies Record;
+
+/** Permissions the handler has: granted by the administrator and still asked for by the current manifest. */
+export function effectivePluginPermissions(manifest: PluginManifest, record: Pick): PluginPermission[] {
+ return manifest.permissions.filter(permission => record.grantedPermissions.includes(permission));
+}
+
+/** The installation as it is now, required to be the one the run started with (not a reinstall) and enabled. */
+async function currentInstallation(database: Knex, pluginId: string, installation: PluginStateWriteGuard): Promise {
+ const authority = await database.transaction(trx => requireInstallation(trx, pluginId, installation));
+ if (!authority.enabled) throw new PluginSdkError('unavailable', 'The plugin is disabled');
+ return authority;
+}
+
+/**
+ * Authorization of a run's dispatches against its installation as it is at
+ * that moment, not as the run started: an administrator may have disabled the
+ * plugin, uninstalled it (or reinstalled it), changed its repository limit or
+ * its grants while the handler was suspended. `request` is for an
+ * authenticated `ctx.http` request: the installation must be the same and
+ * enabled. `call` is for a `ctx.propr` dispatch: it must also still grant the
+ * permission (through the run's manifest) and, when the call names a
+ * repository, still allow it.
+ */
+export function installationAuthorizer(database: Knex, pluginId: string, manifest: PluginManifest, installation: PluginStateWriteGuard) {
+ return {
+ request: async (): Promise => { await currentInstallation(database, pluginId, installation); },
+ call: async ({ permission, repository }: PluginProprCallScope): Promise => {
+ const authority = await currentInstallation(database, pluginId, installation);
+ if (!effectivePluginPermissions(manifest, authority).includes(permission)) throw new PluginSdkError('permission_denied', `The ${permission} permission is no longer granted to this plugin`);
+ if (repository && authority.repositories && !authority.repositories.some(allowed => allowed.toLowerCase() === repository.toLowerCase())) {
+ throw new PluginSdkError('repository_not_allowed', `This plugin is no longer enabled for ${repository}`);
+ }
+ },
+ };
+}
+
+/** The installation's settings with the manifest's defaults, validated against the current manifest. */
+export function handlerSettings(manifest: PluginManifest, record: PluginInstallationRecord) {
+ const result = validatePluginSettings(manifest.settings, { ...defaultPluginSettings(manifest.settings), ...record.settings });
+ if (!result.ok) throw new PluginSdkError('invalid_input', `The plugin settings do not match its manifest: ${result.errors.join('; ')}`);
+ return result.settings;
+}
+
+/** A JSON copy of log fields, or a marker when they are not JSON or too large. */
+function logFields(fields: Record | undefined): Record | undefined {
+ if (fields === undefined) return undefined;
+ try {
+ const text = JSON.stringify(fields);
+ if (text === undefined || !fields || typeof fields !== 'object') return { invalidFields: true };
+ if (Buffer.byteLength(text) > PLUGIN_LOG_LIMITS.fieldsBytes) return { truncatedFields: true };
+ return JSON.parse(text) as Record;
+ } catch { return { invalidFields: true }; }
+}
+
+/** The connection secret of the installation `guard` names, read in one transaction with that installation; refused once it was removed, replaced or disabled. */
+async function defaultConnectionSecret({ database, environment }: { database: Knex; environment: NodeJS.ProcessEnv }, pluginId: string, connection: string, guard: PluginStateWriteGuard): Promise {
+ const { PluginStore } = await import('./pluginStore.js');
+ const store = new PluginStore(database, environment);
+ return database.transaction(async trx => {
+ const authority = await requireInstallation(trx, pluginId, guard);
+ if (!authority.enabled) throw new PluginSdkError('unavailable', 'The plugin is disabled');
+ return store.connectionSecret(pluginId, connection, trx);
+ });
+}
+
+/**
+ * Build `ctx` for one handler invocation. Nothing in it holds a secret: a
+ * connection's credential is read only when `ctx.http(connection)` sends a
+ * request, injected there and scrubbed from the response. Log lines and
+ * anything returned to the caller pass the extension output redaction.
+ */
+export function buildPluginContext(target: PluginInstallationTarget, deps: PluginContextDeps, handler: string, signal: AbortSignal): BuiltPluginContext {
+ const { pluginId, manifest, record } = target;
+ const now = deps.now ?? Date.now;
+ const environment = deps.environment ?? process.env;
+ const credentials: string[] = [];
+ // Messages are kept past their limit (`retainForRedaction`) and cut only after the final redaction: see `clip`.
+ const logs: PluginLogLine[] = [];
+ // The extension output redaction matches one URL-encoded spelling only; the response scrubbing also catches `%2f` and other escapes first.
+ const redact = (value: T): T => redactExtensionOutput(scrubCredentialValues(value, credentials), credentials) as T;
+ // Redacted first, so a credential straddling the limit is replaced whole; a credential longer than the kept margin has its visible start withheld.
+ const clip = (text: string, limit: number): string => {
+ const redacted = redact(text);
+ if (redacted.length <= limit) return redacted;
+ const cut = redacted.slice(0, limit);
+ return cut.slice(0, cut.length - cutCredentialTail(cut, credentials));
+ };
+ let dropped = false;
+
+ const write = (level: PluginLogLevel, message: unknown, fields?: Record) => {
+ if (signal.aborted) return;
+ if (logs.length >= PLUGIN_LOG_LIMITS.lines) {
+ if (!dropped) { dropped = true; logs.push({ level: 'warn', message: `Log limit of ${PLUGIN_LOG_LIMITS.lines} lines reached; later lines were dropped`, at: now() }); }
+ return;
+ }
+ const text = retainForRedaction(String(message), PLUGIN_LOG_LIMITS.messageChars);
+ const json = logFields(fields);
+ logs.push({ level, message: redact(text), ...(json ? { fields: redact(json) } : {}), at: now() });
+ };
+ const log: PluginLogger = { debug: (m, f) => write('debug', m, f), info: (m, f) => write('info', m, f), warn: (m, f) => write('warn', m, f), error: (m, f) => write('error', m, f) };
+
+ const recordCall = async (call: PluginProprCallRecord) => {
+ const { outcome, ...detail } = call;
+ await deps.database('plugin_audit').insert({
+ plugin_id: pluginId, action: CALL_AUDIT_ACTIONS[outcome], actor: pluginActor(pluginId), at: now(),
+ detail: JSON.stringify({ handler, ...detail }),
+ });
+ if (outcome === 'refused') {
+ write('warn', `ctx.propr.${call.operation} refused: ${call.reason}`, { operation: call.operation, permission: call.permission, reason: call.reason ?? null, ...(call.repository ? { repository: call.repository } : {}) });
+ logger.warn({ pluginId, handler, operation: call.operation, reason: call.reason }, '[plugins] Handler call refused');
+ }
+ };
+
+ // The installation this run belongs to: a credential is read from it only, and a request is sent only while it is still installed and enabled.
+ const installation: PluginStateWriteGuard = { installedAt: record.installedAt, signal };
+ /** Refuses once this run's installation was uninstalled (or replaced by a reinstall), disabled or restricted, or the run ended. */
+ const authorize = installationAuthorizer(deps.database, pluginId, manifest, installation);
+ const http = (connection?: string): PluginHttpFetch => {
+ let credential: (() => Promise) | undefined;
+ if (connection !== undefined) {
+ const declared = manifest.connections.find(c => c.name === connection);
+ if (!declared) throw new PluginSdkError('connection_unavailable', `The plugin declares no connection ${String(connection).slice(0, 48)}`);
+ if (declared.type !== 'token') throw new PluginSdkError('connection_unavailable', `Connection ${connection} is ${declared.type}; ctx.http injects token connections only`);
+ credential = async () => {
+ const secret = await (deps.connectionSecret ?? ((id, name) => defaultConnectionSecret({ database: deps.database, environment }, id, name, installation)))(pluginId, connection);
+ if (!secret) throw new PluginSdkError('connection_unavailable', `Connection ${connection} is not set`);
+ credentials.push(secret);
+ return secret;
+ };
+ }
+ return createPluginHttp({
+ outboundHosts: manifest.outboundHosts, credential, lookup: deps.lookup, transport: deps.transport, signal,
+ authorize: credential ? authorize.request : undefined,
+ onDecision: ({ host, allowed, reason }) => write(allowed ? 'debug' : 'warn', allowed ? `ctx.http request to ${host}` : `ctx.http request to ${host} refused: ${reason}`, { host, ...(connection ? { connection } : {}) }),
+ });
+ };
+
+ // Once the run has ended (returned, failed or timed out) nothing the handler left running can act through ctx.
+ const live = (run: (...args: A) => Promise) => async (...args: A): Promise => {
+ if (signal.aborted) throw new PluginSdkError('unavailable', 'This handler run has ended');
+ return run(...args);
+ };
+ const liveMethods = (api: T): T => Object.freeze(Object.fromEntries(Object.entries(api).map(([name, run]) => [name, live(run as (...args: unknown[]) => Promise)]))) as T;
+
+ const ctx: PluginContext = {
+ pluginId,
+ settings: Object.freeze(structuredClone(handlerSettings(manifest, record))),
+ repositories: record.repositories ? [...record.repositories] : null,
+ // Its permissions and repository limit are this installation's: every call (and, by default, every GitHub request and queued task) requires it to
+ // still be installed and enabled, and to still grant the call's permission on its repository.
+ propr: liveMethods(scopedProprClient({
+ pluginId, grantedPermissions: effectivePluginPermissions(manifest, record), repositories: record.repositories,
+ backend: deps.backend ?? githubPluginProprBackend({ database: deps.database, signal, authorize: authorize.call }), record: recordCall,
+ onRecordFailure: (call, error) => logger.error({ pluginId, handler, operation: call.operation, callId: call.callId, outcome: call.outcome, error: error instanceof Error ? error.message : String(error) }, '[plugins] Could not record the outcome of an attempted handler call'),
+ authorize: authorize.call,
+ })),
+ http: connection => live(http(connection)),
+ // State is tied to this installation: once it is uninstalled (or replaced by a reinstall) every operation is refused.
+ state: liveMethods(pluginStateStore(deps.database, pluginId, now, { installedAt: record.installedAt, signal })),
+ log: Object.freeze(log),
+ signal,
+ };
+ // Lines logged before a credential was acquired were redacted without it: redact every line again with all the credentials of this run, then cut it.
+ const finalLogs = (): PluginLogLine[] => logs.map(line => {
+ const redacted = redact(line);
+ return { ...redacted, message: clip(redacted.message, PLUGIN_LOG_LIMITS.messageChars) };
+ });
+ return { ctx: Object.freeze(ctx), finalLogs, redact, clip };
+}
diff --git a/packages/core/src/plugins/pluginContributions.ts b/packages/core/src/plugins/pluginContributions.ts
new file mode 100644
index 0000000000..7d80799ec0
--- /dev/null
+++ b/packages/core/src/plugins/pluginContributions.ts
@@ -0,0 +1,267 @@
+import { randomUUID } from 'node:crypto';
+import type { Knex } from 'knex';
+import {
+ EXTENSION_LIBRARY_LIMITS, EXTENSION_LIBRARY_VERSION, extensionLibraryInputSchema, extensionPackageSecretKey, PLUGIN_LIBRARY_HREF, PLUGIN_TEMPLATE_CATALOG_HREF,
+ type ExtensionLibraryItems, type ExtensionLibraryRevision, type ExtensionLibraryServer, type ExtensionLibrarySkill, type ExtensionManagedBy,
+ type ExtensionSkillBundle, type PluginContributionViews, type PluginInstallationRecord, type PluginManifest, type PluginMcpServer,
+} from '@propr/shared';
+import { ExtensionError, UNCHANGED_CREDENTIALS, type ExtensionLibraryStore } from '../extensions/extensionProfileStore.js';
+import { credentialOwner } from '../extensions/extensionCredentials.js';
+import { discardStaleOAuthConnections } from '../extensions/extensionOAuthState.js';
+import type { ManagedAcknowledgements } from '../extensions/extensionPackageLibrary.js';
+import { validateSkillFiles } from '../extensions/skillBundles.js';
+import { officialPluginAssets, type PluginAssetSource } from './pluginAssets.js';
+import { PluginError, pendingAcknowledgements } from './pluginState.js';
+
+/** What the sync applies: the installation (null once uninstalled) and the values of its token connections. */
+export interface PluginSyncInput {
+ pluginId: string;
+ manifest: PluginManifest | null;
+ record: PluginInstallationRecord | null;
+ /** Stored `token` connection values by connection name. Core-internal; never returned. */
+ connections: Record;
+ /** Pre-registered OAuth clients by `oauth` connection name. The secret is core-internal; never returned. */
+ oauthClients?: Record;
+ actor: string;
+ /** The plugin's repository limit changed: managed items take it as their repository limit. */
+ resetRepositories?: boolean;
+ /**
+ * Only deactivate the existing managed items (a disable): the manifest is
+ * not applied, no asset is read and nothing is added or removed, so a
+ * disable never depends on an upgrade being applicable.
+ */
+ deactivateOnly?: boolean;
+}
+
+/** The pre-registered client an administrator configured for a plugin's `oauth` connection. */
+export interface PluginOAuthClient { clientId: string; clientSecret?: string }
+
+type Item = ExtensionLibraryServer | ExtensionLibrarySkill;
+const ownedBy = (pluginId: string) => (item: Item): boolean => item.managedBy?.plugin === pluginId;
+const MAX_NAME = 48;
+
+/**
+ * Managed items of a plugin as library items: what an installed plugin
+ * contributes (MCP servers and skills) kept in sync with its manifest and
+ * installation. Items are written through the extension library's own
+ * revisions, so resolution, the launch fence, package installation and
+ * verification, write authorization and the gateway treat them exactly like
+ * any other library item. Their credentials are the plugin's connections,
+ * sealed into the library revision like administrator-entered ones.
+ */
+export class PluginContributions {
+ constructor(readonly library: ExtensionLibraryStore, readonly assets: PluginAssetSource = officialPluginAssets) {}
+
+ /**
+ * Bring the library in line with one plugin inside `trx`: create, update,
+ * activate or deactivate its managed items, or remove them on uninstall.
+ * Writes nothing when nothing changed. An uninstall or a manifest that drops
+ * an item (a server, or a skill whose files changed and so gets a new
+ * bundle) is refused while an automation adds that item explicitly. A
+ * disable (`deactivateOnly`) only deactivates the items as they are.
+ */
+ async sync(trx: Knex.Transaction, input: PluginSyncInput): Promise {
+ const head = await this.library.head(trx);
+ const { snapshot } = head;
+ const owned = ownedBy(input.pluginId);
+ const previous = { servers: snapshot.servers.filter(owned), skills: snapshot.skills.filter(owned) };
+ if (!input.record && !previous.servers.length && !previous.skills.length) return;
+ const applies = Boolean(input.record && input.manifest && !input.deactivateOnly);
+ const desired = applies
+ ? await this.desired(trx, snapshot, input as PluginSyncInput & { manifest: PluginManifest; record: PluginInstallationRecord })
+ // Disabled, or removed from the registry by an upgrade: keep the items as they are, never apply them.
+ : input.record ? { servers: previous.servers.map(deactivate), skills: previous.skills.map(deactivate), secrets: null, acknowledgements: {} } : { servers: [], skills: [], secrets: {}, acknowledgements: {} };
+ await this.refuseReferencedRemoval(trx, input, previous, desired);
+ const items = composeItems(snapshot, input.pluginId, desired);
+ let secrets: Record | typeof UNCHANGED_CREDENTIALS;
+ if (desired.secrets === null) {
+ // Deactivating keeps every credential: the sealed bytes carry over without
+ // decryption, so a disable works even when they cannot be decrypted (a raw
+ // restore under another encryption root).
+ if (unchanged(snapshot, items)) return;
+ secrets = UNCHANGED_CREDENTIALS;
+ } else {
+ const stored = this.library.unseal(head);
+ secrets = { ...withoutManaged(stored, previous.servers), ...desired.secrets };
+ if (unchanged(snapshot, items, { stored, secrets }, desired.acknowledgements)) return;
+ }
+ let written: ExtensionLibraryRevision;
+ try { written = await this.library.writeRevision(trx, input.actor, head, items, secrets, new Map(), 'carry', desired.acknowledgements); }
+ catch (error) { throw error instanceof ExtensionError ? new PluginError(error.status, error.message) : error; }
+ await discardStaleOAuthConnections(trx, written.instanceId, written.servers, input.actor);
+ }
+
+ private async desired(trx: Knex.Transaction, snapshot: ExtensionLibraryRevision, input: PluginSyncInput & { manifest: PluginManifest; record: PluginInstallationRecord }) {
+ const { manifest, record, pluginId } = input;
+ const owned = ownedBy(pluginId);
+ const pending = new Set(pendingAcknowledgements(manifest, record.acknowledgements).map(item => item.server));
+ const names = new ManagedNames(pluginId, snapshot.servers);
+ const limits = (existing: Item | undefined) => ({
+ enabled: existing?.enabled ?? true,
+ repositories: !existing || input.resetRepositories ? [...(record.repositories ?? [])] : existing.repositories,
+ agents: existing?.agents ?? [], autoApply: existing?.autoApply ?? true,
+ });
+ const servers: ExtensionLibraryServer[] = []; const secrets: Record = {}; const acknowledgements: ManagedAcknowledgements = {};
+ for (const server of manifest.contributes.mcpServers) {
+ const existing = snapshot.servers.find(s => owned(s) && s.managedBy!.item === server.name);
+ const name = existing?.name ?? names.allocate(server.name);
+ const active = record.enabled && !pending.has(server.name);
+ const managedBy: ExtensionManagedBy = { plugin: pluginId, item: server.name, active };
+ const client = preregisteredClient(manifest, server, input.oauthClients ?? {});
+ servers.push(libraryServer(manifest, server, name, { ...limits(existing), managedBy: { ...managedBy, ...oauthRegistrations(manifest, server) } }, client));
+ for (const [key, connection] of credentialConnections(server, name)) if (input.connections[connection]) secrets[key] = input.connections[connection];
+ if (client?.clientSecret) secrets[name] = client.clientSecret;
+ if (server.kind === 'package' && server.secretEnv.length) {
+ const recorded = Object.hasOwn(record.acknowledgements, server.name) && !pending.has(server.name) ? record.acknowledgements[server.name] : undefined;
+ acknowledgements[name] = recorded ? { actor: recorded.actor, at: recorded.at, package: recorded.package, version: recorded.version, secretEnv: [...recorded.secretEnv] } : undefined;
+ }
+ }
+ const skills: ExtensionLibrarySkill[] = [];
+ for (const skill of manifest.contributes.skills) {
+ const existing = snapshot.skills.find(s => owned(s) && s.managedBy!.item === skill.name);
+ const bundleId = await this.bundleFor(trx, snapshot, input, skill, existing);
+ skills.push({ bundleId, ...limits(existing), managedBy: { plugin: pluginId, item: skill.name, active: record.enabled } });
+ }
+ return { servers, skills, secrets, acknowledgements };
+ }
+
+ /** The skill bundle of a plugin skill: the current one while its files are unchanged, otherwise a new bundle. */
+ // eslint-disable-next-line max-params -- the transaction, library, sync input, skill and existing item are explicit inputs
+ private async bundleFor(trx: Knex.Transaction, snapshot: ExtensionLibraryRevision, input: PluginSyncInput & { manifest: PluginManifest }, skill: { name: string; path: string }, existing: ExtensionLibrarySkill | undefined): Promise {
+ let validated: ReturnType;
+ try { validated = validateSkillFiles(await this.assets.skillFiles(input.manifest, skill.path)); }
+ catch { throw new PluginError(409, `Skill ${skill.name} of the plugin is unavailable or invalid`); }
+ const current = existing && snapshot.skillBundles.find(b => b.id === existing.bundleId);
+ if (current && current.digest === validated.digest) return current.id;
+ const descriptor: ExtensionSkillBundle = {
+ id: randomUUID(), instanceId: snapshot.instanceId, digest: validated.digest, name: validated.name, files: validated.files.length, bytes: validated.bytes,
+ source: { kind: 'plugin', plugin: input.pluginId, version: input.manifest.version, path: skill.path }, requirements: validated.requirements, createdAt: Date.now(), auditActor: input.actor,
+ };
+ await trx('extension_skill_bundles').insert({ id: descriptor.id, instance_id: descriptor.instanceId, descriptor: JSON.stringify(descriptor), files_json: JSON.stringify(validated.files) });
+ return descriptor.id;
+ }
+
+ /** Managed items the change drops while an automation still adds them explicitly refuse it, naming the automations. */
+ private async refuseReferencedRemoval(trx: Knex.Transaction, input: PluginSyncInput, previous: { servers: ExtensionLibraryServer[]; skills: ExtensionLibrarySkill[] }, desired: { servers: ExtensionLibraryServer[]; skills: ExtensionLibrarySkill[] }): Promise {
+ // Automations reference skills by bundle id: a skill whose files changed gets a new bundle, so its old bundle id is removed too.
+ const servers = previous.servers.filter(s => !desired.servers.some(d => d.name === s.name)).map(s => s.name);
+ const skills = previous.skills.filter(s => !desired.skills.some(d => d.bundleId === s.bundleId)).map(s => s.bundleId);
+ const using = await this.library.automationsAdding({ servers, skills }, trx);
+ if (!using.length) return;
+ const what = input.record ? 'before upgrading it' : 'before uninstalling it';
+ throw new PluginError(409, `Automations add items of plugin ${input.pluginId} explicitly; remove them from ${using.map(a => `"${a.name}" (${[...a.servers, ...a.skills].join(', ')})`).join(', ')} ${what}`);
+ }
+}
+
+/** Library names for new managed servers: the manifest name, or `-` when that is taken. */
+class ManagedNames {
+ private readonly taken: Set;
+ constructor(private readonly pluginId: string, servers: readonly ExtensionLibraryServer[]) { this.taken = new Set(servers.map(s => s.name)); }
+ allocate(name: string): string {
+ const prefixed = `${this.pluginId}-${name}`.slice(0, MAX_NAME);
+ const chosen = !this.taken.has(name) ? name : !this.taken.has(prefixed) ? prefixed : null;
+ if (!chosen) throw new PluginError(409, `The extension library already has servers named ${name} and ${prefixed}; rename one before enabling this plugin`);
+ this.taken.add(chosen);
+ return chosen;
+ }
+}
+
+type Limits = Pick & { managedBy: ExtensionManagedBy };
+/** The library item of a manifest server under its library name, with the pre-registered OAuth client configured for its connection. */
+// eslint-disable-next-line max-params -- the manifest, server, library name, limits and configured client are explicit inputs
+function libraryServer(manifest: PluginManifest, server: PluginMcpServer, name: string, limits: Limits, client: PluginOAuthClient | null): ExtensionLibraryServer {
+ if (server.kind === 'https') {
+ const connection = server.authentication === 'oauth' ? manifest.connections.find(c => c.name === server.connection) : undefined;
+ const scopes = connection?.type === 'oauth' ? connection.scopes : undefined;
+ return { kind: 'https', name, url: server.url, enabled: limits.enabled, tools: [...server.tools], capability: server.capability, authentication: server.authentication, ...(scopes ? { scopes: [...scopes] } : {}), ...(client ? { clientId: client.clientId } : {}), repositories: limits.repositories, agents: limits.agents, autoApply: limits.autoApply, managedBy: limits.managedBy };
+ }
+ return {
+ name, enabled: limits.enabled, tools: [...server.tools], capability: server.capability, kind: 'package', ecosystem: server.ecosystem, package: server.package, version: server.version,
+ integrity: server.integrity, ...(server.bin ? { bin: server.bin } : {}), args: [...server.args], env: server.env.map(e => ({ ...e })), secretEnv: server.secretEnv.map(e => e.name),
+ repositories: limits.repositories, agents: limits.agents, autoApply: limits.autoApply, managedBy: limits.managedBy,
+ };
+}
+
+/** Sealed-credential keys of a managed server and the plugin connection each comes from. */
+function credentialConnections(server: PluginMcpServer, name: string): [string, string][] {
+ if (server.kind === 'package') return server.secretEnv.map(secret => [extensionPackageSecretKey(name, secret.name), secret.connection]);
+ return server.authentication === 'bearer' && server.connection ? [[name, server.connection]] : [];
+}
+
+/** The pre-registered client of an `oauth` server's connection, when one is configured and the connection permits pre-registration. */
+function preregisteredClient(manifest: PluginManifest, server: PluginMcpServer, clients: Record): PluginOAuthClient | null {
+ if (server.kind !== 'https' || server.authentication !== 'oauth' || !server.connection || !Object.hasOwn(clients, server.connection)) return null;
+ const connection = manifest.connections.find(c => c.name === server.connection);
+ return connection?.type === 'oauth' && connection.registrations.includes('preregistered') ? clients[server.connection] : null;
+}
+
+/** The registration methods an `oauth` server's plugin connection permits, carried on its marker for sign-in. */
+function oauthRegistrations(manifest: PluginManifest, server: PluginMcpServer): Pick {
+ if (server.kind !== 'https' || server.authentication !== 'oauth') return {};
+ const connection = manifest.connections.find(c => c.name === server.connection);
+ return connection?.type === 'oauth' ? { registrations: [...connection.registrations] } : {};
+}
+
+const deactivate = (item: T): T => (item.managedBy ? { ...item, managedBy: { ...item.managedBy, active: false } } : item);
+const withoutManaged = (secrets: Record, servers: readonly ExtensionLibraryServer[]) => Object.fromEntries(Object.entries(secrets).filter(([key]) => !servers.some(s => s.name === credentialOwner(key))));
+
+/** The next library items: other items unchanged and in place, the plugin's items replaced in place, new ones appended. */
+function composeItems(snapshot: ExtensionLibraryRevision, pluginId: string, desired: { servers: ExtensionLibraryServer[]; skills: ExtensionLibrarySkill[] }): ExtensionLibraryItems {
+ const owned = ownedBy(pluginId);
+ const place = (current: readonly T[], next: readonly T[], same: (a: T, b: T) => boolean): T[] => {
+ const result: T[] = [];
+ for (const item of current) {
+ if (!owned(item)) { result.push(item); continue; }
+ const replacement = next.find(n => same(n, item));
+ if (replacement) result.push(replacement);
+ }
+ return [...result, ...next.filter(n => !result.includes(n))];
+ };
+ const parsed = extensionLibraryInputSchema.safeParse({
+ version: EXTENSION_LIBRARY_VERSION,
+ servers: place(snapshot.servers, desired.servers, (a, b) => a.managedBy?.item === b.managedBy?.item),
+ skills: place(snapshot.skills, desired.skills, (a, b) => a.managedBy?.item === b.managedBy?.item),
+ });
+ if (!parsed.success) throw new PluginError(409, libraryProblem(parsed.error.issues));
+ return parsed.data;
+}
+
+/** Why the next library items do not validate: the failing item field, or the library's capacity. */
+function libraryProblem(issues: readonly { path: readonly PropertyKey[]; message: string }[]): string {
+ const item = issues.find(issue => issue.path.length > 2);
+ if (item) return `The extension library cannot hold this plugin's items: ${item.path.map(String).join('.')}: ${item.message}`;
+ return `The extension library cannot hold this plugin's items; it holds at most ${EXTENSION_LIBRARY_LIMITS.servers} servers and ${EXTENSION_LIBRARY_LIMITS.bundles} skills with unique names`;
+}
+
+/** Whether the next items, and the next credentials when they are compared, equal the base revision's. */
+function unchanged(snapshot: ExtensionLibraryRevision, items: ExtensionLibraryItems, credentials?: { stored: Record; secrets: Record }, acknowledgements: ManagedAcknowledgements = {}): boolean {
+ const sorted = (value: Record) => JSON.stringify(Object.entries(value).sort(([a], [b]) => (a < b ? -1 : 1)));
+ if (JSON.stringify({ servers: snapshot.servers, skills: snapshot.skills }) !== JSON.stringify({ servers: items.servers, skills: items.skills })) return false;
+ if (credentials && sorted(credentials.stored) !== sorted(credentials.secrets)) return false;
+ return Object.entries(acknowledgements).every(([name, value]) => JSON.stringify(value) === JSON.stringify(snapshot.acknowledgements?.[name]));
+}
+
+/** What a plugin contributes and where its managed items are in the library. */
+// eslint-disable-next-line max-params -- the manifest, plugin id, library, template ids and availability are explicit inputs
+export function pluginContributionViews(manifest: PluginManifest | null, pluginId: string, library: Pick | null, templateIds: Map, templatesAvailable: boolean): PluginContributionViews {
+ const owned = ownedBy(pluginId);
+ const servers = library?.servers.filter(owned) ?? []; const skills = library?.skills.filter(owned) ?? [];
+ const active = (item: Item) => item.managedBy?.active === true;
+ return {
+ mcpServers: (manifest?.contributes.mcpServers ?? []).map(server => {
+ const item = servers.find(s => s.managedBy?.item === server.name);
+ return { name: server.name, kind: server.kind, managed: item ? { kind: 'server', name: item.name, enabled: item.enabled, active: active(item) } : null, href: PLUGIN_LIBRARY_HREF };
+ }),
+ skills: (manifest?.contributes.skills ?? []).map(skill => {
+ const item = skills.find(s => s.managedBy?.item === skill.name);
+ return { name: skill.name, managed: item ? { kind: 'skill', bundleId: item.bundleId, enabled: item.enabled, active: active(item) } : null, href: PLUGIN_LIBRARY_HREF };
+ }),
+ automationTemplates: (manifest?.contributes.automationTemplates ?? []).map(template => ({ id: template.id, catalogId: templateIds.get(template.id) ?? template.id, available: templatesAvailable, href: PLUGIN_TEMPLATE_CATALOG_HREF })),
+ };
+}
+
+/** The managed servers of a plugin that use one of its `oauth` connections, by library name. */
+export function oauthServersOf(manifest: PluginManifest, pluginId: string, library: Pick, connection: string): ExtensionLibraryServer[] {
+ const items = manifest.contributes.mcpServers.filter(s => s.kind === 'https' && s.authentication === 'oauth' && s.connection === connection).map(s => s.name);
+ return library.servers.filter(s => s.managedBy?.plugin === pluginId && items.includes(s.managedBy.item) && s.kind === 'https' && s.authentication === 'oauth');
+}
diff --git a/packages/core/src/plugins/pluginDeliveries.ts b/packages/core/src/plugins/pluginDeliveries.ts
new file mode 100644
index 0000000000..0a1180b8dd
--- /dev/null
+++ b/packages/core/src/plugins/pluginDeliveries.ts
@@ -0,0 +1,291 @@
+import type { Knex } from 'knex';
+import {
+ PLUGIN_ACTIVITY_LIMIT, PLUGIN_DEGRADED_AFTER_FAILURES, PLUGIN_DELIVERY_RETENTION_DAYS, PLUGIN_LOG_LIMITS, PLUGIN_PAUSE_AFTER_FAILURES,
+ type PluginActivity, type PluginAuditAction, type PluginDeliveryKind, type PluginDeliveryStatus, type PluginDeliveryView, type PluginHealthView, type PluginLogLine,
+} from '@propr/shared';
+import logger from '../utils/logger.js';
+import { PluginError } from './pluginState.js';
+
+/**
+ * Activity records of plugin event deliveries (`plugin_deliveries`) and the
+ * delivery health of each installation (`plugin_dispatch`). A record holds
+ * the event's `seq` and type, the handler, the outcome, timing, the attempt
+ * count and the latest attempt's redacted error and log lines: never the
+ * event payload, a handler result or a credential.
+ */
+
+/** A `plugin_dispatch` row as stored. */
+export interface PluginDispatchRow {
+ plugin_id: string; installed_at: number | string; start_seq: number | string;
+ lease_owner: string | null; lease_expires_at: number | string | null;
+ consecutive_failures: number; last_error: string | null; last_error_at: number | string | null;
+ degraded_at: number | string | null; paused_at: number | string | null; updated_at: number | string;
+ /** The Degraded notifications still owed, oldest first (a JSON list of `PluginDegradedNotice`), or null. */
+ degraded_notice?: string | null;
+}
+/** A `plugin_deliveries` row as stored. */
+export interface PluginDeliveryRow {
+ id: number; plugin_id: string; delivery_id: string; kind: PluginDeliveryKind; repository: string; event_seq: number | string;
+ event_type: string | null; handler: string | null; status: PluginDeliveryStatus; attempt: number; manual: number | boolean; next_attempt_at: number | string | null;
+ created_at: number | string; started_at: number | string | null; finished_at: number | string | null; duration_ms: number | null;
+ timed_out: number | boolean; error: string | null; logs: string;
+}
+
+/** Log lines kept per delivery record, and their serialized size. */
+const STORED_LOG_LINES = 100;
+const STORED_LOG_BYTES = 64 * 1024;
+const ERROR_CHARS = 1_000;
+
+const num = (value: number | string | null | undefined): number | null => (value === null || value === undefined ? null : Number(value));
+
+/** The stable id of one event's delivery to one handler; handlers de-duplicate on it. */
+export const pluginDeliveryId = (seq: number, handler: string) => `evt-${seq}-${handler}`;
+
+/** Consecutive failed attempts that pause deliveries: `PROPR_PLUGIN_PAUSE_AFTER_FAILURES`, at least the Degraded threshold. */
+export function pluginPauseAfterFailures(environment: NodeJS.ProcessEnv = process.env): number {
+ const value = Number.parseInt(environment.PROPR_PLUGIN_PAUSE_AFTER_FAILURES ?? '', 10);
+ return Number.isSafeInteger(value) && value >= PLUGIN_DEGRADED_AFTER_FAILURES ? value : PLUGIN_PAUSE_AFTER_FAILURES;
+}
+
+/** Log lines as stored: at most `STORED_LOG_LINES` lines and `STORED_LOG_BYTES`, with a marker when lines were dropped. */
+export function storedLogs(logs: readonly PluginLogLine[]): string {
+ const kept: PluginLogLine[] = [];
+ let bytes = 2;
+ for (const line of logs.slice(0, STORED_LOG_LINES)) {
+ const size = Buffer.byteLength(JSON.stringify(line)) + 1;
+ if (bytes + size > STORED_LOG_BYTES) break;
+ kept.push(line); bytes += size;
+ }
+ if (kept.length < logs.length) kept.push({ level: 'warn', message: `${logs.length - kept.length} more log lines were not kept`, at: kept.at(-1)?.at ?? 0 });
+ return JSON.stringify(kept);
+}
+
+export const clipError = (error: string | null) => (error === null ? null : error.slice(0, ERROR_CHARS));
+
+function parseLogs(text: string): PluginLogLine[] {
+ try {
+ const value: unknown = JSON.parse(text);
+ return Array.isArray(value) ? value.filter((line): line is PluginLogLine => Boolean(line) && typeof line === 'object' && typeof (line as PluginLogLine).message === 'string')
+ .map(line => ({ ...line, message: line.message.slice(0, PLUGIN_LOG_LIMITS.messageChars) })) : [];
+ } catch { return []; }
+}
+
+export function deliveryView(row: PluginDeliveryRow): PluginDeliveryView {
+ return {
+ id: Number(row.id), deliveryId: row.delivery_id, kind: row.kind, repository: row.repository, eventSeq: Number(row.event_seq),
+ eventType: row.event_type, handler: row.handler, status: row.status, attempt: Number(row.attempt),
+ nextAttemptAt: row.status === 'pending' || row.status === 'retrying' ? num(row.next_attempt_at) : null,
+ createdAt: Number(row.created_at), startedAt: num(row.started_at), finishedAt: num(row.finished_at),
+ durationMs: row.duration_ms === null ? null : Number(row.duration_ms), timedOut: Boolean(row.timed_out),
+ error: row.error, logs: parseLogs(row.logs),
+ };
+}
+
+export function healthView(row: PluginDispatchRow | undefined, pauseAfterFailures: number): PluginHealthView {
+ const failures = Number(row?.consecutive_failures ?? 0);
+ const pausedAt = num(row?.paused_at);
+ const degradedAt = num(row?.degraded_at);
+ return {
+ state: pausedAt !== null ? 'paused' : degradedAt !== null ? 'degraded' : 'healthy',
+ consecutiveFailures: failures, pauseAfterFailures,
+ lastError: row?.last_error ?? null, lastErrorAt: num(row?.last_error_at), degradedAt, pausedAt,
+ };
+}
+
+// eslint-disable-next-line max-params -- the transaction, plugin, action, actor, time and detail are explicit audit inputs
+const audit = (trx: Knex, pluginId: string, action: PluginAuditAction, actor: string, at: number, detail: Record) =>
+ trx('plugin_audit').insert({ plugin_id: pluginId, action, actor, at, detail: JSON.stringify(detail) });
+
+const installedAtOf = async (database: Knex, pluginId: string): Promise => {
+ const row = await database('plugin_installations').where({ plugin_id: pluginId }).first('installed_at') as { installed_at: number | string } | undefined;
+ if (!row) throw new PluginError(404, 'The plugin is not installed');
+ return Number(row.installed_at);
+};
+/** The dispatch row of the installation as it is now; a row left by an earlier installation does not count. */
+async function currentDispatch(database: Knex, pluginId: string): Promise {
+ const installedAt = await installedAtOf(database, pluginId);
+ const row = await database('plugin_dispatch').where({ plugin_id: pluginId }).first() as PluginDispatchRow | undefined;
+ return row && Number(row.installed_at) === installedAt ? row : undefined;
+}
+
+/** The installation's delivery health and its newest delivery records, newest first. */
+export async function listPluginActivity(database: Knex, pluginId: string, options: { limit?: number; environment?: NodeJS.ProcessEnv } = {}): Promise {
+ const row = await currentDispatch(database, pluginId);
+ const limit = Math.min(Math.max(Math.trunc(options.limit ?? PLUGIN_ACTIVITY_LIMIT), 1), PLUGIN_ACTIVITY_LIMIT);
+ const rows = await database('plugin_deliveries').where({ plugin_id: pluginId }).orderBy('id', 'desc').limit(limit).select() as PluginDeliveryRow[];
+ return { health: healthView(row, pluginPauseAfterFailures(options.environment)), deliveries: rows.map(deliveryView) };
+}
+
+/**
+ * Retry a failed delivery: it gets one more attempt as soon as the dispatcher
+ * runs (after a Resume while deliveries are paused). The event is read from
+ * the journal again; the delivery id stays the same.
+ */
+// eslint-disable-next-line max-params -- the database, plugin, record id, actor and time are explicit inputs
+export async function retryPluginDelivery(database: Knex, pluginId: string, id: unknown, actor: string, now: number = Date.now()): Promise {
+ const recordId = typeof id === 'string' && /^[1-9][0-9]{0,15}$/.test(id) ? Number(id) : typeof id === 'number' && Number.isSafeInteger(id) && id > 0 ? id : null;
+ if (recordId === null) throw new PluginError(400, 'Invalid delivery id');
+ return database.transaction(async trx => {
+ await installedAtOf(trx, pluginId);
+ const row = await trx('plugin_deliveries').where({ plugin_id: pluginId, id: recordId }).first() as PluginDeliveryRow | undefined;
+ if (!row) throw new PluginError(404, 'Delivery not found');
+ if (row.kind !== 'event' || row.status !== 'failed') throw new PluginError(409, 'Only a failed event delivery can be retried');
+ // A manual retry: one attempt, then final; `manual` lets a dispatcher recover it after a crash mid-attempt.
+ await trx('plugin_deliveries').where({ id: recordId, status: 'failed' }).update({ status: 'pending', manual: true, next_attempt_at: now });
+ await audit(trx, pluginId, 'retry_delivery', actor, now, { deliveryId: row.delivery_id, eventSeq: Number(row.event_seq), handler: row.handler });
+ return deliveryView({ ...row, status: 'pending', next_attempt_at: now });
+ });
+}
+
+/** Resume paused deliveries: the failure count starts over, Degraded clears and waiting retries are due now. */
+export async function resumePluginDeliveries(database: Knex, pluginId: string, actor: string, options: { now?: number; environment?: NodeJS.ProcessEnv } = {}): Promise {
+ const now = options.now ?? Date.now();
+ return database.transaction(async trx => {
+ const row = await currentDispatch(trx, pluginId);
+ if (!row || row.paused_at === null) throw new PluginError(409, 'Deliveries of this plugin are not paused');
+ const update = { paused_at: null, degraded_at: null, consecutive_failures: 0, updated_at: now };
+ await trx('plugin_dispatch').where({ plugin_id: pluginId }).update(update);
+ await trx('plugin_deliveries').where({ plugin_id: pluginId, status: 'retrying' }).update({ next_attempt_at: now });
+ await audit(trx, pluginId, 'resume_deliveries', actor, now, { consecutiveFailures: Number(row.consecutive_failures), lastError: row.last_error });
+ return healthView({ ...row, ...update }, pluginPauseAfterFailures(options.environment));
+ });
+}
+
+/** The highest journal `seq` ever handed out: where a new subscription starts. */
+export async function journalHead(database: Knex): Promise {
+ const max = await database('repository_events').max({ seq: 'seq' }).first() as { seq: number | null } | undefined;
+ let sequence = 0;
+ try {
+ const rows = await database.raw("SELECT seq FROM sqlite_sequence WHERE name = 'repository_events'") as Array<{ seq: number }>;
+ sequence = Number(rows[0]?.seq ?? 0);
+ } catch { /* no AUTOINCREMENT bookkeeping */ }
+ return Math.max(Number(max?.seq ?? 0), sequence);
+}
+
+/**
+ * Start the installation's subscriptions at the journal's current end: on
+ * install and when it is enabled again. Nothing earlier is delivered, so
+ * history is never replayed. Delivery records and health start over.
+ */
+export async function restartPluginDispatch(trx: Knex, pluginId: string, installedAt: number, now: number): Promise {
+ const start = await journalHead(trx);
+ await trx('plugin_delivery_cursors').where({ plugin_id: pluginId }).delete();
+ await trx('plugin_dispatch').insert({
+ plugin_id: pluginId, installed_at: installedAt, start_seq: start, lease_owner: null, lease_expires_at: null,
+ consecutive_failures: 0, last_error: null, last_error_at: null, degraded_at: null, paused_at: null, updated_at: now,
+ }).onConflict('plugin_id').merge();
+ // Degraded notifications still owed (`degraded_notice`, not merged) are kept: they are sent once they can be.
+ // Deliveries that never finished would otherwise run after the restart: they belong to before it.
+ await trx('plugin_deliveries').where({ plugin_id: pluginId }).whereIn('status', ['pending', 'running', 'retrying'])
+ .update({ status: 'failed', manual: false, next_attempt_at: null, error: 'Not delivered: the plugin was disabled' });
+}
+
+/** Delete the dispatch state and delivery records of an installation (uninstall). */
+export async function deletePluginDispatch(trx: Knex, pluginId: string): Promise {
+ await trx('plugin_dispatch').where({ plugin_id: pluginId }).delete();
+ await trx('plugin_delivery_cursors').where({ plugin_id: pluginId }).delete();
+ return trx('plugin_deliveries').where({ plugin_id: pluginId }).delete();
+}
+
+/**
+ * Delete final delivery records older than the retention period. A record of
+ * an event still in the journal and ahead of its repository's cursor (the
+ * cursor of the lower-cased name, or the installation's start before one
+ * exists) is kept: the event is read again (a handler after it is retrying,
+ * or deliveries are paused), and the record is what makes a handler that
+ * already succeeded or failed for good not run again with its attempts reset.
+ */
+export async function prunePluginDeliveries(database: Knex, options: { olderThanDays?: number; now?: () => number } = {}): Promise {
+ const cutoff = (options.now ?? Date.now)() - (options.olderThanDays ?? PLUGIN_DELIVERY_RETENTION_DAYS) * 24 * 60 * 60 * 1000;
+ return database('plugin_deliveries').where('created_at', '<', cutoff).whereIn('status', ['succeeded', 'failed', 'skipped'])
+ .whereNot(needed => {
+ void needed.where('plugin_deliveries.kind', 'event')
+ .whereExists(q => { void q.select(1).from('repository_events as e').whereRaw('?? = ??', ['e.seq', 'plugin_deliveries.event_seq']); })
+ .whereExists(q => {
+ void q.select(1).from('plugin_dispatch as d').whereRaw('?? = ??', ['d.plugin_id', 'plugin_deliveries.plugin_id'])
+ .whereRaw('?? > COALESCE((SELECT ?? FROM ?? AS c WHERE ?? = ?? AND ?? = lower(??)), ??)', [
+ 'plugin_deliveries.event_seq', 'c.cursor_seq', 'plugin_delivery_cursors', 'c.plugin_id', 'plugin_deliveries.plugin_id',
+ 'c.repository', 'plugin_deliveries.repository', 'd.start_seq',
+ ]);
+ });
+ })
+ .delete();
+}
+
+export interface PluginDegradedNotice { pluginId: string; installedAt: number; degradedAt: number; consecutiveFailures: number; lastError: string | null }
+export type PluginDegradedNotifier = (notice: PluginDegradedNotice) => Promise;
+
+/** One Inbox notification to every durable administrator when an installation becomes Degraded. */
+export function notifyAdministratorsOfDegradedPlugin(database: Knex): PluginDegradedNotifier {
+ return async ({ pluginId, installedAt, degradedAt, consecutiveFailures, lastError }) => {
+ const rows = await database('instance_members').distinct('github_user_id').where({ role: 'admin' }) as { github_user_id?: unknown }[];
+ const recipients = rows.flatMap(row => (typeof row.github_user_id === 'string' ? [{ userId: row.github_user_id, pushEnabled: true }] : []));
+ if (!recipients.length) return;
+ const { createNotificationEvent } = await import('../services/notificationService.js');
+ await createNotificationEvent({
+ deduplicationKey: `plugin-degraded:${pluginId}:${installedAt}:${degradedAt}`,
+ kind: 'system_failure', severity: 'warning',
+ target: { type: 'system_failure', component: 'plugins.dispatcher', correlationId: pluginId },
+ title: `Plugin ${pluginId} is degraded`,
+ body: `${consecutiveFailures} event deliveries to plugin ${pluginId} failed in a row.${lastError ? ` Last error: ${lastError.slice(0, 300)}` : ''} See its Activity on the Plugins page.`,
+ actions: ['dismiss'], metadata: { plugin: pluginId },
+ }, recipients);
+ };
+}
+
+/** Run a notifier without letting it fail the caller; whether it succeeded. */
+export async function notifySafely(notify: PluginDegradedNotifier, notice: PluginDegradedNotice): Promise {
+ try { await notify(notice); return true; } catch (error) {
+ logger.warn({ pluginId: notice.pluginId, error: error instanceof Error ? error.message : String(error) }, '[plugins] Could not notify administrators of a degraded plugin');
+ return false;
+ }
+}
+
+const isNotice = (value: unknown): value is PluginDegradedNotice => {
+ const notice = value as Partial | null;
+ return Boolean(notice) && typeof notice!.pluginId === 'string' && typeof notice!.installedAt === 'number' && typeof notice!.degradedAt === 'number';
+};
+/** The notices owed, oldest first: `degraded_notice` holds a JSON list (a single notice, as an earlier copy stored it, counts as one). */
+function owedNotices(text: string | null | undefined): PluginDegradedNotice[] {
+ if (!text) return [];
+ try {
+ const value: unknown = JSON.parse(text);
+ return (Array.isArray(value) ? value : [value]).filter(isNotice);
+ } catch { return []; }
+}
+const sameNotice = (a: PluginDegradedNotice, b: PluginDegradedNotice) => a.pluginId === b.pluginId && a.installedAt === b.installedAt && a.degradedAt === b.degradedAt;
+const storedNotices = (notices: readonly PluginDegradedNotice[]) => (notices.length ? JSON.stringify(notices) : null);
+
+/** `degraded_notice` with `notice` queued after the notices still owed: an unsent notice is never written over. */
+export function owedNoticesWith(text: string | null | undefined, notice: PluginDegradedNotice): string {
+ return JSON.stringify([...owedNotices(text).filter(owed => !sameNotice(owed, notice)), notice]);
+}
+
+/**
+ * Send the Degraded notifications still owed (`degraded_notice`, a queue
+ * appended to in the transaction that made the installation Degraded), oldest
+ * first. Each is removed from the queue only once its notifier succeeded, in
+ * a transaction that reads the queue as it is then, so a notice queued while
+ * the others were being sent stays owed, and a failed or interrupted send is
+ * tried again; the notification's deduplication key keeps a repeat, or two
+ * dispatchers sending it, to one Inbox entry. Returns whether every one went out.
+ */
+export async function sendPendingDegradedNotices(database: Knex, notify: PluginDegradedNotifier, pluginId?: string): Promise {
+ const rows = await database('plugin_dispatch').whereNotNull('degraded_notice').modify(q => { if (pluginId) void q.where({ plugin_id: pluginId }); })
+ .select('plugin_id', 'degraded_notice') as { plugin_id: string; degraded_notice: string }[];
+ let sent = true;
+ for (const row of rows) {
+ const delivered: PluginDegradedNotice[] = [];
+ for (const notice of owedNotices(row.degraded_notice)) {
+ if (await notifySafely(notify, notice)) delivered.push(notice); else sent = false;
+ }
+ await database.transaction(async trx => {
+ const current = await trx('plugin_dispatch').where({ plugin_id: row.plugin_id }).first('degraded_notice') as { degraded_notice: string | null } | undefined;
+ if (!current?.degraded_notice) return;
+ const remaining = owedNotices(current.degraded_notice).filter(owed => !delivered.some(done => sameNotice(done, owed)));
+ const stored = storedNotices(remaining);
+ if (stored !== current.degraded_notice) await trx('plugin_dispatch').where({ plugin_id: row.plugin_id }).update({ degraded_notice: stored });
+ });
+ }
+ return sent;
+}
diff --git a/packages/core/src/plugins/pluginDispatcher.ts b/packages/core/src/plugins/pluginDispatcher.ts
new file mode 100644
index 0000000000..63f7ec5d02
--- /dev/null
+++ b/packages/core/src/plugins/pluginDispatcher.ts
@@ -0,0 +1,496 @@
+import { randomUUID } from 'node:crypto';
+import type { Knex } from 'knex';
+import {
+ PLUGIN_DEGRADED_AFTER_FAILURES, PLUGIN_DELIVERY_RETRY_DELAYS_MS,
+ type PluginHandlerRun, type PluginManifest, type PluginSubscription, type RepositoryEvent,
+} from '@propr/shared';
+import logger from '../utils/logger.js';
+import { listRepositoryEvents, subscribeRepositoryEventAppends } from '../services/repositoryEventJournal.js';
+import { officialPluginCatalog } from './pluginCatalog.js';
+import { effectivePluginPermissions, type PluginInstallationTarget } from './pluginContext.js';
+import { runPluginHandler, type RunPluginHandlerOptions } from './pluginHandlerRunner.js';
+import { installationRecord, type PluginInstallationRow } from './pluginState.js';
+import {
+ clipError, journalHead, notifyAdministratorsOfDegradedPlugin, owedNoticesWith, pluginDeliveryId, pluginPauseAfterFailures, restartPluginDispatch, sendPendingDegradedNotices, storedLogs,
+ type PluginDegradedNotice, type PluginDegradedNotifier, type PluginDeliveryRow, type PluginDispatchRow,
+} from './pluginDeliveries.js';
+
+/**
+ * Delivers repository journal events to the handlers plugins subscribe to.
+ *
+ * Each installation reads the journal from its own durable cursor, kept per
+ * repository (case-insensitive: every spelling of a name is one stream, keyed
+ * by its lower-case form): events of one repository reach one plugin in `seq` order, one
+ * at a time, while another repository's events go on. Delivery is at least
+ * once, with a stable delivery id. A failed attempt is retried after 1 min,
+ * 5 min, 30 min and 2 h; then the delivery is marked failed and the next event
+ * follows. Only the worker holding an installation's lease delivers to it.
+ *
+ * The dispatcher only reads the journal. Nothing that appends an event waits
+ * for it, so a handler that fails, hangs or crashes never fails or delays the
+ * task, review, merge or queue step that produced the event.
+ */
+export interface PluginDispatcherOptions extends Omit {
+ database: Knex;
+ /** This dispatcher's lease owner name; unique per process by default. */
+ owner?: string;
+ /** How long a lease lasts without renewal; longer than a handler's time limit. */
+ leaseMs?: number;
+ /** Waits before each retry; the delivery fails after `retryDelaysMs.length + 1` attempts. */
+ retryDelaysMs?: readonly number[];
+ degradedAfterFailures?: number;
+ /** Defaults to `PROPR_PLUGIN_PAUSE_AFTER_FAILURES` or `PLUGIN_PAUSE_AFTER_FAILURES`. */
+ pauseAfterFailures?: number;
+ /** Interval between journal reads when no in-process append wakes the dispatcher. */
+ pollMs?: number;
+ /** Events read per repository and pass. */
+ batchSize?: number;
+ /** Runs one handler attempt; defaults to `runPluginHandler`. */
+ run?: typeof runPluginHandler;
+ /** Told once when an installation becomes Degraded; defaults to an Inbox notification to administrators. */
+ notify?: PluginDegradedNotifier;
+}
+
+export interface PluginDispatchPass {
+ /** Attempts made in this pass. */
+ attempts: number;
+ /** Installations this dispatcher held the lease of. */
+ leased: string[];
+}
+
+const DEFAULT_LEASE_MS = 2 * 60_000;
+const DEFAULT_POLL_MS = 5_000;
+const DEFAULT_BATCH = 50;
+const WAKE_DELAY_MS = 200;
+/** Wait before a pass tries again to send Degraded notifications that failed. */
+const NOTICE_RETRY_MS = 60_000;
+
+const lower = (value: string) => value.toLowerCase();
+/** The event value a filter key names: an event field or `payload.`; a missing value is null. */
+function filterValue(event: RepositoryEvent, key: string): unknown {
+ if (key.startsWith('payload.')) {
+ const name = key.slice('payload.'.length);
+ return Object.hasOwn(event.payload, name) ? event.payload[name] : null;
+ }
+ return (event as unknown as Record)[key] ?? null;
+}
+/** Whether a subscription takes this event: its `.`, and every filter key equal to one of its values. */
+export function subscriptionMatches(subscription: PluginSubscription, event: RepositoryEvent): boolean {
+ if (subscription.on !== `${event.domain}.${event.type}`) return false;
+ return Object.entries(subscription.filter ?? {}).every(([key, expected]) => {
+ const actual = filterValue(event, key);
+ return (Array.isArray(expected) ? expected : [expected]).some(option => (key === 'repository' && typeof actual === 'string' && typeof option === 'string'
+ ? lower(actual) === lower(option) : actual === option));
+ });
+}
+
+/** One installation's delivery in a pass: the target is refreshed before each attempt; `attempts` counts the handlers started. */
+interface Installation { target: PluginInstallationTarget; state: PluginDispatchRow; attempts: number }
+class LeaseLost extends Error {}
+/** The repository waits (for a retry); the installation's other repositories go on. */
+class Blocked extends Error {}
+/** The installation's pass ends: paused, disabled, out of grant, restarted or changed under it. The next pass starts from the current state. */
+class Stopped extends Error {}
+
+const UNFINISHED = ['pending', 'running', 'retrying'] as const;
+const inScope = (repositories: readonly string[] | null | undefined, repository: string) => !repositories || repositories.map(lower).includes(lower(repository));
+const deliversEvents = (target: PluginInstallationTarget) => target.record.enabled && effectivePluginPermissions(target.manifest, target.record).includes('events.read');
+
+export class PluginDispatcher {
+ readonly owner: string;
+ private readonly database: Knex;
+ private readonly now: () => number;
+ private readonly delays: readonly number[];
+ private readonly pauseAfter: number;
+ private readonly degradedAfter: number;
+ private timer: NodeJS.Timeout | undefined;
+ /** When the scheduled pass is due (wall clock). */
+ private timerDue = 0;
+ private unsubscribe: (() => void) | undefined;
+ private running: Promise | undefined;
+ private again = false;
+ /** Polling is on (between `start` and `stop`). */
+ private polling = false;
+ /** `stop` was called: no further attempt starts. */
+ private closed = false;
+ /** When a pass next sends owed Degraded notifications (after one failed). */
+ private noticesDue = 0;
+
+ constructor(private readonly options: PluginDispatcherOptions) {
+ this.database = options.database;
+ this.owner = options.owner ?? `dispatcher-${process.pid}-${randomUUID()}`;
+ this.now = options.now ?? Date.now;
+ this.delays = options.retryDelaysMs ?? PLUGIN_DELIVERY_RETRY_DELAYS_MS;
+ this.degradedAfter = Math.max(1, options.degradedAfterFailures ?? PLUGIN_DEGRADED_AFTER_FAILURES);
+ this.pauseAfter = Math.max(this.degradedAfter, options.pauseAfterFailures ?? pluginPauseAfterFailures(options.environment));
+ }
+
+ /** Poll the journal and react at once to appends in this process. */
+ start(): void {
+ if (this.polling || this.closed) return;
+ this.polling = true;
+ this.unsubscribe = subscribeRepositoryEventAppends(() => this.wake(WAKE_DELAY_MS));
+ this.wake(0);
+ }
+
+ /** Stop polling, wait for the pass under way and hand the leases back. */
+ async stop(): Promise {
+ this.polling = false; this.closed = true;
+ clearTimeout(this.timer); this.timer = undefined;
+ this.unsubscribe?.(); this.unsubscribe = undefined;
+ await this.running?.catch(() => undefined);
+ await this.database('plugin_dispatch').where({ lease_owner: this.owner }).update({ lease_owner: null, lease_expires_at: null }).catch(() => undefined);
+ }
+
+ /** Schedule a pass in `delayMs`, unless one is due sooner: appends arriving without pause never push a pass back. */
+ private wake(delayMs: number): void {
+ if (!this.polling) return;
+ if (this.running) { this.again = true; return; }
+ const due = Date.now() + delayMs;
+ if (this.timer && this.timerDue <= due) return;
+ clearTimeout(this.timer);
+ this.timerDue = due;
+ this.timer = setTimeout(() => { void this.loop(); }, delayMs);
+ this.timer.unref?.();
+ }
+
+ private async loop(): Promise {
+ this.timer = undefined;
+ await this.pass().catch(() => undefined);
+ const again = this.again; this.again = false;
+ this.wake(again ? 0 : this.options.pollMs ?? DEFAULT_POLL_MS);
+ }
+
+ /** One pass over every enabled installation with subscriptions; passes never overlap. Never throws. */
+ pass(): Promise {
+ if (this.running) return this.running.then(() => this.pass());
+ const run = this.passOnce().finally(() => { this.running = undefined; });
+ this.running = run;
+ return run;
+ }
+
+ private async passOnce(): Promise {
+ const result: PluginDispatchPass = { attempts: 0, leased: [] };
+ // Notifications owed by any installation, paused ones included.
+ if (this.now() >= this.noticesDue) await this.sendNotices();
+ let installations: Installation[];
+ try { installations = await this.installations(); } catch (error) {
+ logger.warn({ error: error instanceof Error ? error.message : String(error) }, '[plugins] Could not read plugin installations for event delivery');
+ return result;
+ }
+ // Installations deliver side by side; each one's own events stay in order.
+ await Promise.all(installations.map(async installation => {
+ const { pluginId } = installation.target;
+ try {
+ if (!await this.acquire(pluginId)) return;
+ result.leased.push(pluginId);
+ await this.deliver(installation);
+ } catch (error) {
+ if (!(error instanceof LeaseLost) && !(error instanceof Stopped)) logger.warn({ pluginId, error: error instanceof Error ? error.message : String(error) }, '[plugins] Event delivery pass failed');
+ } finally {
+ result.attempts += installation.attempts;
+ }
+ }));
+ return result;
+ }
+
+ private async installations(): Promise {
+ const manifests = await (this.options.catalog ?? officialPluginCatalog)();
+ const rows = await this.database('plugin_installations').where({ enabled: true }).select() as PluginInstallationRow[];
+ const found: Installation[] = [];
+ for (const row of rows) {
+ const manifest: PluginManifest | undefined = manifests.find(m => m.id === row.plugin_id);
+ if (!manifest?.contributes.subscriptions.length) continue;
+ const target = { pluginId: row.plugin_id, manifest, record: installationRecord(row) };
+ // Subscriptions need events.read, granted and still asked for.
+ if (!deliversEvents(target)) continue;
+ const state = await this.state(row.plugin_id, target.record.installedAt);
+ // Uninstalled, reinstalled or disabled since the read: the next pass starts from the installation as it is then.
+ if (!state || state.paused_at !== null) continue;
+ found.push({ target, state, attempts: 0 });
+ }
+ return found;
+ }
+
+ /**
+ * The dispatch state of the installation read as `installedAt`; an
+ * installation without one (or with an earlier installation's) starts at the
+ * journal's end. State is only created or replaced in one transaction with
+ * the check that the installation is still that one, enabled, and that the
+ * state it replaces is still not its own: an installation read before an
+ * uninstall, reinstall or disable never writes over its replacement's state
+ * (that would move the replacement's start past events it must receive).
+ * Undefined when the installation changed since it was read.
+ */
+ private async state(pluginId: string, installedAt: number): Promise {
+ const read = (database: Knex) => database('plugin_dispatch').where({ plugin_id: pluginId }).first() as Promise;
+ const row = await read(this.database);
+ if (row && Number(row.installed_at) === installedAt) return row;
+ return this.database.transaction(async trx => {
+ const current = await trx('plugin_installations').where({ plugin_id: pluginId }).first('installed_at', 'enabled') as Pick | undefined;
+ if (!current || Number(current.installed_at) !== installedAt || !current.enabled) return undefined;
+ const existing = await read(trx);
+ if (!existing) {
+ await trx('plugin_dispatch').insert({ plugin_id: pluginId, installed_at: installedAt, start_seq: await journalHead(trx), consecutive_failures: 0, updated_at: this.now() });
+ } else if (Number(existing.installed_at) !== installedAt) {
+ await restartPluginDispatch(trx, pluginId, installedAt, this.now());
+ }
+ return read(trx);
+ });
+ }
+
+ /** Take or renew the installation's lease: free, expired or already ours. */
+ private async acquire(pluginId: string): Promise {
+ const now = this.now();
+ const updated = await this.database('plugin_dispatch').where({ plugin_id: pluginId })
+ .where(q => { void q.whereNull('lease_owner').orWhere('lease_owner', this.owner).orWhereNull('lease_expires_at').orWhere('lease_expires_at', '<', now); })
+ .update({ lease_owner: this.owner, lease_expires_at: now + (this.options.leaseMs ?? DEFAULT_LEASE_MS) });
+ return updated === 1;
+ }
+ private async renew(pluginId: string): Promise {
+ if (this.closed || !await this.acquire(pluginId)) throw new LeaseLost();
+ }
+
+ /** Deliver what is due for one installation. Ends with `Stopped` once it may no longer deliver (paused, for one). */
+ private async deliver(installation: Installation): Promise {
+ await this.manualRetries(installation);
+ for (const repository of await this.repositories(installation)) { // canonical (lower-case) names
+ if (!inScope(installation.target.record.repositories, repository)) continue;
+ try { await this.deliverRepository(installation, repository); } catch (error) {
+ if (!(error instanceof Blocked)) throw error;
+ }
+ }
+ }
+
+ /**
+ * The repositories to visit: those with events after the installation's
+ * start, with unfinished deliveries, and those whose journal floor moved
+ * past the start and past what their cursor has recorded, so a repository
+ * that retention emptied still has its gap recorded. Each is named once, in
+ * lower case, whatever spellings its events use.
+ */
+ private async repositories(installation: Installation): Promise {
+ const { pluginId } = installation.target;
+ const start = Number(installation.state.start_seq);
+ type Floor = { repository: string; floor_seq: number | string };
+ const [events, unfinished, floors, cursors] = await Promise.all([
+ this.database('repository_events').where('seq', '>', start).distinct('repository'),
+ this.database('plugin_deliveries').where({ plugin_id: pluginId, kind: 'event' }).whereIn('status', UNFINISHED).distinct('repository'),
+ this.database('repository_event_floors').where('floor_seq', '>', start + 1).select('repository', 'floor_seq'),
+ this.database('plugin_delivery_cursors').where({ plugin_id: pluginId }).select('repository', 'floor_seq'),
+ ]) as [{ repository: string }[], { repository: string }[], Floor[], Floor[]];
+ const recorded = new Map(cursors.map(c => [c.repository, Number(c.floor_seq)]));
+ const moved = floors.filter(f => Number(f.floor_seq) > (recorded.get(lower(f.repository)) ?? 0));
+ return [...new Set([...events, ...unfinished, ...moved].map(r => lower(r.repository)))].sort();
+ }
+
+ /** Administrator retries: one attempt each, out of the stream's order; one a crashed worker left `running` is attempted again. */
+ private async manualRetries(installation: Installation): Promise {
+ const { pluginId } = installation.target;
+ const rows = await this.database('plugin_deliveries').where({ plugin_id: pluginId, kind: 'event', manual: true })
+ .where(q => { void q.where(p => { void p.where('status', 'pending').where('next_attempt_at', '<=', this.now()); }).orWhere('status', 'running'); })
+ .orderBy('id').select() as PluginDeliveryRow[];
+ for (const row of rows) {
+ const [event] = await listRepositoryEvents(this.database, { repository: row.repository, afterSeq: Number(row.event_seq) - 1, limit: 1 });
+ if (!event || event.seq !== Number(row.event_seq)) {
+ await this.fenced(installation, trx => trx('plugin_deliveries').where({ id: row.id, status: row.status, attempt: row.attempt })
+ .update({ status: 'failed', manual: false, next_attempt_at: null, error: 'The event is no longer in the journal' }));
+ continue;
+ }
+ await this.attempt(installation, row, event);
+ }
+ }
+
+ /** Deliver one repository's events (every spelling, `repository` is the lower-case name) in order, until one waits for a retry or the batch is done. */
+ private async deliverRepository(installation: Installation, repository: string): Promise {
+ const { pluginId, manifest } = installation.target;
+ const cursor = await this.cursor(installation, repository);
+ const events = await listRepositoryEvents(this.database, { repository, anyCase: true, afterSeq: cursor.seq, limit: this.options.batchSize ?? DEFAULT_BATCH });
+ for (const event of events) {
+ const subscriptions = manifest.contributes.subscriptions.filter(s => subscriptionMatches(s, event));
+ for (const subscription of subscriptions) {
+ const row = await this.delivery(installation, event, subscription.handler);
+ if (row.status === 'succeeded' || row.status === 'failed' || row.status === 'skipped') continue;
+ if (row.status !== 'running' && Number(row.next_attempt_at ?? 0) > this.now()) throw new Blocked();
+ const status = await this.attempt(installation, row, event);
+ if (status !== 'succeeded' && status !== 'failed') throw new Blocked();
+ }
+ await this.fenced(installation, trx => trx('plugin_delivery_cursors').where({ plugin_id: pluginId, repository }).where('cursor_seq', '<', event.seq).update({ cursor_seq: event.seq }));
+ }
+ }
+
+ /**
+ * Run a cursor or record write in one transaction with the check that this
+ * dispatcher still holds the lease of the installation it read: the same
+ * installation and journal start. A restart (enabled again) or a reinstall
+ * since the read ends the pass instead of writing state of the earlier one.
+ */
+ private async fenced(installation: Installation, write: (trx: Knex.Transaction) => Promise): Promise {
+ const { state } = installation;
+ return this.database.transaction(async trx => {
+ const current = await trx('plugin_dispatch').where({ plugin_id: state.plugin_id, lease_owner: this.owner, installed_at: state.installed_at, start_seq: state.start_seq }).first();
+ if (!current) throw new Stopped();
+ return write(trx);
+ });
+ }
+
+ /** The repository's cursor, created at the installation's start; records a gap once when retention removed events not yet delivered. */
+ private async cursor(installation: Installation, repository: string): Promise<{ seq: number }> {
+ const { pluginId } = installation.target;
+ const where = { plugin_id: pluginId, repository };
+ let row = await this.database('plugin_delivery_cursors').where(where).first() as { cursor_seq: number | string; floor_seq: number | string } | undefined;
+ if (!row) {
+ const start = Number(installation.state.start_seq);
+ // Only while the start read is still the installation's: a restart since then (enabled again) deleted the cursors and moved the start.
+ row = await this.fenced(installation, async trx => {
+ await trx('plugin_delivery_cursors').insert({ ...where, cursor_seq: start, floor_seq: 1 }).onConflict(['plugin_id', 'repository']).ignore();
+ return await trx('plugin_delivery_cursors').where(where).first() as { cursor_seq: number | string; floor_seq: number | string };
+ });
+ }
+ const seq = Number(row.cursor_seq);
+ // The highest floor of any spelling: completeness of the stream ends there.
+ const floors = await this.database('repository_event_floors').whereRaw('repository = ? COLLATE NOCASE', [repository]).max({ floor: 'floor_seq' }).first() as { floor: number | string | null } | undefined;
+ const floor = Number(floors?.floor ?? 1);
+ if (floor > Number(row.floor_seq)) await this.fenced(installation, async trx => {
+ if (seq < floor - 1) {
+ const now = this.now();
+ await trx('plugin_deliveries').insert({
+ plugin_id: pluginId, delivery_id: `gap-${seq + 1}-${floor - 1}-${repository}`.slice(0, 255), kind: 'gap', repository, event_seq: seq + 1,
+ event_type: null, handler: null, status: 'skipped', attempt: 0, next_attempt_at: null, created_at: now, finished_at: now,
+ error: `Events after ${seq} up to ${floor - 1} may have left the journal (retention) before they were delivered`, logs: '[]',
+ }).onConflict(['plugin_id', 'delivery_id']).ignore();
+ }
+ // Unfinished deliveries of events retention removed are final now; retained events below the floor still go out.
+ await trx('plugin_deliveries').where({ plugin_id: pluginId, kind: 'event' }).whereRaw('repository = ? COLLATE NOCASE', [repository])
+ .whereIn('status', UNFINISHED).where('event_seq', '<', floor)
+ .whereNotExists(q => { void q.select(1).from('repository_events').whereRaw('?? = ??', ['repository_events.seq', 'plugin_deliveries.event_seq']); })
+ .update({ status: 'skipped', manual: false, next_attempt_at: null, finished_at: this.now(), error: 'The event left the journal (retention) before it was delivered' });
+ await trx('plugin_delivery_cursors').where(where).update({ floor_seq: floor });
+ });
+ return { seq };
+ }
+
+ /** The delivery record of one event to one handler, created pending on first sight. */
+ private async delivery(installation: Installation, event: RepositoryEvent, handler: string): Promise {
+ const { pluginId } = installation.target;
+ const deliveryId = pluginDeliveryId(event.seq, handler);
+ await this.fenced(installation, trx => trx('plugin_deliveries').insert({
+ plugin_id: pluginId, delivery_id: deliveryId, kind: 'event', repository: event.repository, event_seq: event.seq,
+ event_type: `${event.domain}.${event.type}`, handler, status: 'pending', attempt: 0, next_attempt_at: this.now(), created_at: this.now(), logs: '[]',
+ }).onConflict(['plugin_id', 'delivery_id']).ignore());
+ return await this.database('plugin_deliveries').where({ plugin_id: pluginId, delivery_id: deliveryId }).first() as PluginDeliveryRow;
+ }
+
+ /**
+ * Before each attempt: the lease is still ours and the installation, as it
+ * is now, may receive this event. Paused, disabled, reinstalled, restarted
+ * (enabled again) or without events.read ends the pass. A repository outside
+ * the current limit ends an ordinary pass (the next one skips it) and fails
+ * a manual retry. The target used for the attempt is the current one. An
+ * administrator change landing between this check and the handler's start
+ * is a race no check can close; the next attempt sees it.
+ */
+ private async admit(installation: Installation, row: PluginDeliveryRow, event: RepositoryEvent): Promise {
+ const { pluginId, manifest } = installation.target;
+ await this.renew(pluginId);
+ const [dispatch, current] = await Promise.all([
+ this.database('plugin_dispatch').where({ plugin_id: pluginId }).first() as Promise,
+ this.database('plugin_installations').where({ plugin_id: pluginId }).first() as Promise,
+ ]);
+ if (!dispatch || !current || dispatch.paused_at !== null || dispatch.lease_owner !== this.owner) throw new Stopped();
+ const { state } = installation;
+ if (Number(dispatch.installed_at) !== Number(state.installed_at) || Number(dispatch.start_seq) !== Number(state.start_seq)) throw new Stopped();
+ const target = { pluginId, manifest, record: installationRecord(current) };
+ if (target.record.installedAt !== installation.target.record.installedAt || !deliversEvents(target)) throw new Stopped();
+ installation.target = target;
+ const manual = Boolean(row.manual);
+ if (!manual && event.seq <= Number(state.start_seq)) throw new Stopped();
+ if (inScope(target.record.repositories, event.repository)) return true;
+ if (!manual) throw new Stopped();
+ await this.fenced(installation, trx => trx('plugin_deliveries').where({ id: row.id, status: row.status, attempt: row.attempt }).update({
+ status: 'failed', manual: false, next_attempt_at: null, finished_at: this.now(), error: "Not delivered: the repository is outside the plugin's repository limit",
+ }));
+ return false;
+ }
+
+ /** One attempt: mark it running, run the handler, then record the outcome and the installation's health while still holding the lease. Ends the pass once it pauses the installation. */
+ private async attempt(installation: Installation, row: PluginDeliveryRow, event: RepositoryEvent): Promise {
+ if (!await this.admit(installation, row, event)) return null;
+ const { pluginId } = installation.target;
+ const manual = Boolean(row.manual);
+ const attempt = Number(row.attempt) + 1;
+ // The claim is fenced: only while this dispatcher still holds the lease of the installation and start it read,
+ // the installation is still that one and enabled, and the record is unchanged. The lease is renewed in the same
+ // write, so a worker suspended since `admit` (its lease taken over meanwhile) never starts the handler.
+ const marked = await this.fenced(installation, async trx => {
+ const enabled = await trx('plugin_installations').where({ plugin_id: pluginId, installed_at: installation.target.record.installedAt, enabled: true }).first('plugin_id');
+ if (!enabled) return 0;
+ const now = this.now();
+ await trx('plugin_dispatch').where({ plugin_id: pluginId, lease_owner: this.owner }).update({ lease_expires_at: now + (this.options.leaseMs ?? DEFAULT_LEASE_MS) });
+ return trx('plugin_deliveries').where({ id: row.id, status: row.status, attempt: row.attempt }).update({ status: 'running', attempt, started_at: now, finished_at: null });
+ });
+ if (marked !== 1) throw new Stopped();
+ installation.attempts++;
+ let run: PluginHandlerRun;
+ try {
+ run = await (this.options.run ?? runPluginHandler)(installation.target, row.handler!, { kind: 'event', event, deliveryId: row.delivery_id, attempt }, { ...this.options, database: this.database });
+ } catch (error) {
+ // The runner never throws; a replacement that does still only fails this attempt.
+ run = { outcome: 'failed', timedOut: false, durationMs: 0, error: error instanceof Error ? error.message : 'The handler failed', logs: [] };
+ }
+ const now = this.now();
+ const exhausted = manual || attempt > this.delays.length;
+ const status = run.outcome === 'succeeded' ? 'succeeded' : exhausted ? 'failed' : 'retrying';
+ const health: { notice: boolean; paused: boolean } = { notice: false, paused: false };
+ await this.database.transaction(async trx => {
+ // A dispatcher that lost the lease while the handler ran records nothing: the lease holder delivers it again, with the same id.
+ const state = await trx('plugin_dispatch').where({ plugin_id: pluginId, lease_owner: this.owner }).first() as PluginDispatchRow | undefined;
+ if (!state) throw new LeaseLost();
+ await trx('plugin_deliveries').where({ id: row.id }).update({
+ status, manual: false, finished_at: now, duration_ms: run.durationMs, timed_out: run.timedOut, error: clipError(run.error), logs: storedLogs(run.logs),
+ next_attempt_at: status === 'retrying' ? now + this.delays[attempt - 1] : null,
+ });
+ Object.assign(health, await this.recordHealth(trx, state, run, now));
+ });
+ if (health.notice) await this.sendNotices(pluginId);
+ if (health.paused) throw new Stopped();
+ return status;
+ }
+
+ /** Send the Degraded notifications owed (of one installation); after a failure, passes try again after `NOTICE_RETRY_MS`. */
+ private async sendNotices(pluginId?: string): Promise {
+ const sent = await sendPendingDegradedNotices(this.database, this.options.notify ?? notifyAdministratorsOfDegradedPlugin(this.database), pluginId)
+ .catch((error: unknown) => {
+ logger.warn({ pluginId, error: error instanceof Error ? error.message : String(error) }, '[plugins] Could not read the Degraded notifications owed');
+ return false;
+ });
+ if (!sent) this.noticesDue = this.now() + NOTICE_RETRY_MS;
+ }
+
+ /**
+ * Count the attempt into the installation's health; returns whether it just
+ * became Degraded and whether it is paused now. Becoming Degraded queues the
+ * notification owed in the same write, behind any earlier one not sent yet,
+ * so a failed send or a crash before it leaves it to be sent again.
+ */
+ private async recordHealth(trx: Knex.Transaction, state: PluginDispatchRow, run: PluginHandlerRun, now: number): Promise<{ notice: boolean; paused: boolean }> {
+ const where = { plugin_id: state.plugin_id };
+ if (run.outcome === 'succeeded') {
+ await trx('plugin_dispatch').where(where).update({ consecutive_failures: 0, degraded_at: null, updated_at: now });
+ return { notice: false, paused: state.paused_at !== null };
+ }
+ const failures = Number(state.consecutive_failures) + 1;
+ const becomesDegraded = state.degraded_at === null && failures >= this.degradedAfter;
+ const error = clipError(run.error);
+ const notice: PluginDegradedNotice = { pluginId: state.plugin_id, installedAt: Number(state.installed_at), degradedAt: now, consecutiveFailures: failures, lastError: error };
+ await trx('plugin_dispatch').where(where).update({
+ consecutive_failures: failures, last_error: error, last_error_at: now, updated_at: now,
+ // Queued after any notice still owed from an earlier transition (`state` was read in this transaction).
+ ...(becomesDegraded ? { degraded_at: now, degraded_notice: owedNoticesWith(state.degraded_notice, notice) } : {}),
+ ...(state.paused_at === null && failures >= this.pauseAfter ? { paused_at: now } : {}),
+ });
+ const paused = state.paused_at !== null || failures >= this.pauseAfter;
+ if (paused && state.paused_at === null) logger.warn({ pluginId: state.plugin_id, failures }, '[plugins] Event deliveries paused after repeated failures');
+ return { notice: becomesDegraded, paused };
+ }
+}
diff --git a/packages/core/src/plugins/pluginHandlerRunner.ts b/packages/core/src/plugins/pluginHandlerRunner.ts
new file mode 100644
index 0000000000..c698f35806
--- /dev/null
+++ b/packages/core/src/plugins/pluginHandlerRunner.ts
@@ -0,0 +1,138 @@
+import type { Knex } from 'knex';
+import {
+ PLUGIN_HANDLER_TIMEOUT_MS,
+ type PluginHandler, type PluginHandlerInput, type PluginHandlerModule, type PluginHandlerRun, type PluginJsonValue, type PluginLogLine, type PluginManifest,
+} from '@propr/shared';
+import logger from '../utils/logger.js';
+import { officialPluginCatalog, type PluginCatalogSource } from './pluginCatalog.js';
+import { buildPluginContext, retainForRedaction, type PluginContextDeps, type PluginInstallationTarget } from './pluginContext.js';
+import { installationRecord, type PluginInstallationRow } from './pluginState.js';
+
+/** The handlers module of a plugin (its `handlers.ts`), or null when it has none. */
+export type PluginHandlerSource = (pluginId: string) => Promise;
+
+interface PluginRegistryHandlers { loadPluginHandlers(id: string): Promise }
+// Not a literal import, like the catalog: Core runs where the registry package is absent.
+const REGISTRY_MODULE = '@propr/plugins';
+
+/** The handlers of the official plugin registry (`@propr/plugins`). */
+export const officialPluginHandlers: PluginHandlerSource = async pluginId => {
+ const registry = await import(REGISTRY_MODULE) as PluginRegistryHandlers;
+ return registry.loadPluginHandlers(pluginId);
+};
+
+export interface RunPluginHandlerOptions extends PluginContextDeps {
+ /** Where handler modules come from; defaults to the official registry. */
+ handlers?: PluginHandlerSource;
+ /** The manifests, when the installation is given by plugin id; defaults to the official catalog. */
+ catalog?: PluginCatalogSource;
+ /** Defaults to `PLUGIN_HANDLER_TIMEOUT_MS`; tests shorten it. */
+ timeoutMs?: number;
+}
+
+/** A plugin id (its installation is read from the database) or an installation already loaded. */
+export type PluginInstallationRef = string | PluginInstallationTarget;
+
+const HANDLER_NAME = /^[A-Za-z_$][A-Za-z0-9_$]{0,63}$/;
+const ERROR_CHARS = 1_000;
+const RESULT_BYTES = 16 * 1024;
+
+class HandlerTimeout extends Error { constructor(ms: number) { super(`The handler did not finish within ${Math.round(ms / 1000)} seconds`); } }
+
+async function loadTarget(installation: PluginInstallationRef, database: Knex, catalog: PluginCatalogSource): Promise {
+ if (typeof installation !== 'string') return installation;
+ const manifest: PluginManifest | undefined = (await catalog()).find(m => m.id === installation);
+ if (!manifest) throw new Error(`Plugin ${installation.slice(0, 48)} is not in this ProPR's registry`);
+ const row = await database('plugin_installations').where({ plugin_id: installation }).first() as PluginInstallationRow | undefined;
+ if (!row) throw new Error(`Plugin ${installation} is not installed`);
+ return { pluginId: installation, manifest, record: installationRecord(row) };
+}
+
+async function loadHandler(target: PluginInstallationTarget, name: string, source: PluginHandlerSource): Promise {
+ if (!target.record.enabled) throw new Error(`Plugin ${target.pluginId} is not enabled`);
+ if (!HANDLER_NAME.test(name)) throw new Error('Invalid handler name');
+ const module = await source(target.pluginId);
+ const handler = module && Object.hasOwn(module, name) ? module[name] : undefined;
+ if (typeof handler !== 'function') throw new Error(`Plugin ${target.pluginId} has no handler ${name}`);
+ return handler;
+}
+
+/** The handler's return value as JSON, or an error when it is not JSON or too large. */
+function resultValue(value: unknown): PluginJsonValue | undefined {
+ if (value === undefined) return undefined;
+ let text: string | undefined;
+ try { text = JSON.stringify(value); } catch { /* not JSON */ }
+ if (text === undefined) throw new Error('The handler returned a value that is not JSON');
+ if (Buffer.byteLength(text) > RESULT_BYTES) throw new Error(`The handler returned more than ${RESULT_BYTES} bytes`);
+ return JSON.parse(text) as PluginJsonValue;
+}
+
+const HANDLER_FAILED = 'The handler failed';
+
+/**
+ * The message of whatever the run failed with. A handler may throw any value: one
+ * that cannot be converted to a string (`Object.create(null)`, an own non-callable
+ * `toString`), an `Error` whose `message` is not a string or is a throwing getter,
+ * or a proxy whose traps throw. None of them may escape the runner's catch block,
+ * so a value that does not yield a string falls back to a constant message.
+ * Kept past ERROR_CHARS: `finish` cuts it only after redacting it, so a credential straddling the limit is redacted whole.
+ */
+function message(error: unknown): string {
+ let text: unknown;
+ try { text = error instanceof Error ? error.message : String(error); } catch { return HANDLER_FAILED; }
+ return typeof text === 'string' && text ? retainForRedaction(text, ERROR_CHARS) : HANDLER_FAILED;
+}
+
+/** Whether the run failed on its time limit; never throws, whatever the handler threw. */
+function isTimeout(error: unknown): boolean {
+ try { return error instanceof HandlerTimeout; } catch { return false; }
+}
+
+/** Run the handler, failing it once it runs past `timeoutMs`; the signal tells the handler to stop. */
+async function withTimeout(run: () => Promise, timeoutMs: number, controller: AbortController): Promise {
+ let timer: NodeJS.Timeout | undefined;
+ try {
+ return await Promise.race([
+ Promise.resolve().then(run),
+ new Promise((_, reject) => {
+ timer = setTimeout(() => { const error = new HandlerTimeout(timeoutMs); controller.abort(error); reject(error); }, timeoutMs);
+ timer.unref();
+ }),
+ ]);
+ } finally { clearTimeout(timer); }
+}
+
+/**
+ * Run one handler of an installed, enabled plugin: load it from the registry,
+ * build its `ctx`, apply the time limit and return the outcome, duration,
+ * error message and log lines. Never throws; every failure, a timeout
+ * included, is a `failed` outcome. Messages, logs and the result are redacted.
+ */
+export async function runPluginHandler(installation: PluginInstallationRef, handlerName: string, input: PluginHandlerInput, options: RunPluginHandlerOptions): Promise {
+ const started = performance.now();
+ const controller = new AbortController();
+ let finalLogs = (): PluginLogLine[] => [];
+ let redact = (value: T): T => value;
+ let clip = (text: string, limit: number): string => text.slice(0, limit);
+ const pluginId = typeof installation === 'string' ? installation : installation?.pluginId;
+ const finish = (run: Omit): PluginHandlerRun => {
+ const durationMs = Math.round(performance.now() - started);
+ // Ends the run for anything the handler left running: ctx refuses further calls and log lines.
+ if (!controller.signal.aborted) controller.abort(new Error('The handler run has ended'));
+ // Lines logged (and errors raised) before a credential was acquired were redacted without it: redact them again with all the credentials of this run, then cut them.
+ const result: PluginHandlerRun = { ...run, error: run.error === null ? null : clip(run.error, ERROR_CHARS), durationMs, logs: finalLogs() };
+ try { logger.info({ pluginId, handler: handlerName, outcome: result.outcome, timedOut: result.timedOut, durationMs }, '[plugins] Handler run'); } catch { /* logging never fails a run */ }
+ return result;
+ };
+ try {
+ const target = await loadTarget(installation, options.database, options.catalog ?? officialPluginCatalog);
+ const handler = await loadHandler(target, handlerName, options.handlers ?? officialPluginHandlers);
+ const built = buildPluginContext(target, options, handlerName, controller.signal);
+ finalLogs = built.finalLogs; redact = built.redact; clip = built.clip;
+ const value = await withTimeout(() => handler(input, built.ctx), options.timeoutMs ?? PLUGIN_HANDLER_TIMEOUT_MS, controller);
+ const result = resultValue(value);
+ return finish({ outcome: 'succeeded', timedOut: false, error: null, ...(result === undefined ? {} : { result: redact(result) }) });
+ } catch (error) {
+ return finish({ outcome: 'failed', timedOut: isTimeout(error), error: message(error) });
+ }
+}
diff --git a/packages/core/src/plugins/pluginHttp.ts b/packages/core/src/plugins/pluginHttp.ts
new file mode 100644
index 0000000000..fb5f692bf0
--- /dev/null
+++ b/packages/core/src/plugins/pluginHttp.ts
@@ -0,0 +1,429 @@
+import dns from 'node:dns/promises';
+import https from 'node:https';
+import { isIP } from 'node:net';
+import {
+ PLUGIN_HTTP_LIMITS, PluginSdkError,
+ type PluginHttpFetch, type PluginHttpRequestInit, type PluginHttpResponse,
+} from '@propr/shared';
+import { extensionPublicAddress, extensionReservedHostname, type ExtensionLookup } from '../extensions/endpointPolicy.js';
+
+/** One vetted request: the URL as the handler wrote it, the checked address to dial and the final headers. */
+export interface PluginHttpTransportRequest {
+ url: URL; address: string; family: number;
+ method: string; headers: Record; body?: Buffer;
+ timeoutMs: number; maxResponseBytes: number;
+ /** Aborted when the handler run ends; the transport stops sending and receiving then. */
+ signal?: AbortSignal;
+}
+export interface PluginHttpTransportResponse { status: number; headers: Record; body: Buffer }
+/** Sends one vetted request. Throws `PluginSdkError` (`response_too_large`, `http_failed`). */
+export type PluginHttpTransport = (request: PluginHttpTransportRequest) => Promise;
+
+export interface PluginHttpOptions {
+ /** The manifest's `outboundHosts`: exact hostnames, nothing else is reachable. */
+ outboundHosts: readonly string[];
+ /** The credential to inject as `Authorization: Bearer`, resolved on the first request; null for none. */
+ credential?: () => Promise;
+ /**
+ * Called before every request is sent, after its lookups: throws to refuse
+ * it (the installation the credential belongs to was removed or replaced).
+ */
+ authorize?: () => Promise;
+ lookup?: ExtensionLookup;
+ transport?: PluginHttpTransport;
+ /** Every request decision (`allowed` false when refused), for the handler log. */
+ onDecision?: (decision: { host: string; allowed: boolean; reason?: string }) => void;
+ /** The handler run's signal: once aborted no request starts, and one in flight is cancelled. */
+ signal?: AbortSignal;
+}
+
+const METHODS = new Set(['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE']);
+/** Headers ProPR sets itself or that would bypass the policy. */
+const RESERVED_HEADERS = /^(host|content-length|connection|keep-alive|transfer-encoding|te|trailer|upgrade|proxy-.*|expect)$/i;
+const HEADER_NAME = /^[!#$%&'*+.^_`|~0-9A-Za-z-]{1,128}$/;
+const HEADER_VALUE = /^[\t\x20-\x7e\x80-\xff]{0,8192}$/;
+
+const defaultLookup: ExtensionLookup = host => dns.lookup(host, { all: true, verbatim: true });
+
+/**
+ * One spelling of a credential. `anyCase`: the spelling reads the same in any
+ * letter case (its hex encoding: `Buffer.from(hex, 'hex')` accepts `AB`, `ab`
+ * and `aB` alike), so it is matched with ASCII letters folded; every other
+ * spelling (the secret itself, its base64) is matched exactly.
+ */
+export interface CredentialSpelling { text: string; anyCase: boolean }
+
+/** Every spelling of a credential (alone and as the header value sent) a response could echo back. */
+function credentialSpellings(secret: string): CredentialSpelling[] {
+ const encodings = (value: string): CredentialSpelling[] => {
+ // Inside a JSON string: quotes, backslashes and control characters escaped, `/` optionally too.
+ const json = JSON.stringify(value).slice(1, -1);
+ const exact = [value, json, json.split('/').join('\\/'), Buffer.from(value).toString('base64'), Buffer.from(value).toString('base64url'), encodeURIComponent(value)];
+ return [...exact.map(text => ({ text, anyCase: false })), { text: Buffer.from(value).toString('hex'), anyCase: true }];
+ };
+ return uniqueSpellings([...encodings(`Bearer ${secret}`), ...encodings(secret)]);
+}
+/** Distinct spellings, longest first: the header value's encodings are not made of the secret's. */
+function uniqueSpellings(spellings: readonly CredentialSpelling[]): CredentialSpelling[] {
+ const seen = new Map();
+ for (const spelling of spellings) if (spelling.text) seen.set(`${spelling.anyCase ? 'i' : 'x'}:${spelling.text}`, spelling);
+ return [...seen.values()].sort((a, b) => b.text.length - a.text.length);
+}
+
+/** `text` with its ASCII letters lowercased and nothing else changed: the same length, so an index in it is an index in `text`. */
+const foldAscii = (text: string) => text.replace(/[A-Z]+/g, run => run.toLowerCase());
+/** The haystacks a spelling is searched in: `text` itself, or its ASCII-folded copy for an any-case spelling. */
+function haystacks(text: string): (spelling: CredentialSpelling) => string {
+ let folded: string | undefined;
+ return spelling => (spelling.anyCase ? (folded ??= foldAscii(text)) : text);
+}
+/** Where `text` holds any spelling: `[start, end)` ranges, overlapping ones included. */
+function spellingRanges(text: string, spellings: readonly CredentialSpelling[]): [number, number][] {
+ const haystack = haystacks(text);
+ const ranges: [number, number][] = [];
+ for (const spelling of spellings) {
+ const source = haystack(spelling);
+ for (let at = source.indexOf(spelling.text); at >= 0; at = source.indexOf(spelling.text, at + 1)) ranges.push([at, at + spelling.text.length]);
+ }
+ return ranges;
+}
+/** Whether `text` holds any spelling (an any-case one in any letter case). */
+function holdsSpelling(text: string, spellings: readonly CredentialSpelling[]): boolean {
+ const haystack = haystacks(text);
+ return spellings.some(spelling => haystack(spelling).includes(spelling.text));
+}
+/** `text` with each range replaced by `[REDACTED]`; overlapping ranges (the header value holds the secret) become one replacement. */
+function redactRanges(text: string, ranges: [number, number][]): string {
+ if (!ranges.length) return text;
+ let scrubbed = ''; let cursor = 0;
+ for (const [start, end] of ranges.sort((a, b) => a[0] - b[0])) {
+ if (start >= cursor) scrubbed += `${text.slice(cursor, start)}[REDACTED]`;
+ cursor = Math.max(cursor, end);
+ }
+ return scrubbed + text.slice(cursor);
+}
+
+/** One percent-escaped ASCII character, its hex digits in either case: `%2f`, `%2F`, `%61`. */
+const ASCII_PERCENT_ESCAPE = /%[0-7][0-9a-fA-F]/g;
+
+/**
+ * `text` with every spelling replaced also where some of its characters are
+ * percent-escaped, whatever the case of the hex digits (`%2f` as well as the
+ * `%2F` of `encodeURIComponent`, or an escaped letter): `decodeURIComponent`
+ * recovers each of them. Connection secrets are printable ASCII, so only
+ * escapes of ASCII characters can spell one. The text is decoded once and each
+ * match is mapped back to the characters and escapes it was decoded from.
+ */
+function scrubPercentEscaped(text: string, spellings: readonly CredentialSpelling[]): string {
+ // The decoded position of each escaped character: a decoded index maps back past two more characters per escape before it.
+ const escapes: number[] = [];
+ const decoded = text.replace(ASCII_PERCENT_ESCAPE, (escape, offset: number) => {
+ escapes.push(offset - 2 * escapes.length);
+ return String.fromCharCode(parseInt(escape.slice(1), 16));
+ });
+ if (!escapes.length) return text;
+ const original = (index: number) => {
+ let low = 0; let high = escapes.length;
+ while (low < high) { const middle = (low + high) >> 1; if (escapes[middle] < index) low = middle + 1; else high = middle; }
+ return index + 2 * low;
+ };
+ return redactRanges(text, spellingRanges(decoded, spellings).map(([start, end]) => [original(start), original(end)]));
+}
+/** `text` with every spelling replaced: as written (an any-case spelling in any letter case), then where percent-escaped. */
+const scrub = (text: string, spellings: readonly CredentialSpelling[]) => spellings.length
+ ? scrubPercentEscaped(redactRanges(text, spellingRanges(text, spellings)), spellings)
+ : text;
+
+const JSON_ESCAPE = /\\(u[0-9a-fA-F]{4}|["\\/bfnrt])/g;
+const SHORT_ESCAPES: Record = { b: '\b', f: '\f', n: '\n', r: '\r', t: '\t' };
+const jsonUnescapeOnce = (value: string) => value.replace(JSON_ESCAPE, (_, escape: string) => escape.length === 5 ? String.fromCharCode(parseInt(escape.slice(1), 16)) : SHORT_ESCAPES[escape] ?? escape);
+/**
+ * Percent escapes as `decodeURIComponent` and `unescape` decode them, hex
+ * digits in either case (`%2f`, `%2F`, `%u002F`). Each byte becomes one
+ * character: both agree on the ASCII characters a secret is made of.
+ */
+const PERCENT_ESCAPE = /%(?:u[0-9a-fA-F]{4}|[0-9a-fA-F]{2})/g;
+const percentDecodeOnce = (value: string) => value.replace(PERCENT_ESCAPE, escape => String.fromCharCode(parseInt(escape.slice(escape[1] === 'u' ? 2 : 1), 16)));
+/** The decodings a handler can apply to text, in any order and any number of times. */
+const DECODINGS = [jsonUnescapeOnce, percentDecodeOnce];
+/** Rounds of decoding checked; each nesting level (a JSON document inside a JSON string, a value URL-encoded twice) takes one. */
+const UNESCAPE_ROUNDS = 32;
+/** Distinct partly decoded texts followed per round (the decodings taken in different orders); more counts as holding a credential. */
+const DECODED_VARIANTS = 8;
+
+/**
+ * Whether `text` (already scrubbed of literal spellings) holds a credential
+ * once its JSON and percent escapes are decoded, once or repeatedly and in any
+ * order: `\uXXXX` escapes in HTML or a header, a serialized JSON document
+ * inside a JSON string, a value URL-encoded twice or a percent escape inside a
+ * JSON string. Text still decoding after the last round could hide one deeper,
+ * so counts too. `fold` is applied to each decoded text before comparing
+ * (header names compare case-insensitively).
+ */
+function revealsCredential(text: string, spellings: readonly CredentialSpelling[], fold: (value: string) => string = value => value): boolean {
+ let variants = [text];
+ for (let round = 0; round < UNESCAPE_ROUNDS; round++) {
+ // Every decoding shortens the text, so no variant comes back.
+ const next = new Set(variants.flatMap(variant => DECODINGS.map(decode => decode(variant)).filter(decoded => decoded !== variant)));
+ if (!next.size) return false;
+ if (next.size > DECODED_VARIANTS) return true;
+ if ([...next].some(variant => holdsSpelling(fold(variant), spellings))) return true;
+ variants = [...next];
+ }
+ return true;
+}
+
+/**
+ * Scrub every decoded string (keys included) of a parsed JSON value; `changed`
+ * when one held a credential. A string that holds one only once decoded again
+ * (serialized JSON inside the string, a value URL-encoded twice) is withheld
+ * whole.
+ */
+function scrubJson(value: unknown, spellings: readonly CredentialSpelling[]): { value: unknown; changed: boolean } {
+ let changed = false;
+ const clean = (text: string) => {
+ const literal = scrub(text, spellings);
+ const scrubbed = revealsCredential(literal, spellings) ? '[REDACTED]' : literal;
+ if (scrubbed !== text) changed = true;
+ return scrubbed;
+ };
+ const walk = (item: unknown): unknown => {
+ if (typeof item === 'string') return clean(item);
+ if (Array.isArray(item)) return item.map(walk);
+ if (item && typeof item === 'object') return Object.fromEntries(Object.entries(item).map(([key, entry]) => [clean(key), walk(entry)]));
+ return item;
+ };
+ const scrubbed = walk(value);
+ return { value: scrubbed, changed };
+}
+
+/**
+ * The response text with every credential spelling replaced, also where it is
+ * percent-escaped in either case. A JSON body is also checked after decoding
+ * (an escaped credential such as `a\"b` decodes to the credential) and
+ * re-serialized scrubbed when a decoded string held one. Any body (HTML, text
+ * or JSON) that still holds the credential once its JSON and percent escapes
+ * are decoded is withheld entirely: a handler could recover it by decoding the
+ * escaped part itself.
+ */
+function scrubbedBody(raw: string, spellings: readonly CredentialSpelling[]): string {
+ const text = scrub(raw, spellings);
+ if (!spellings.length) return text;
+ let body = text;
+ let parsed: unknown;
+ let json = true;
+ try { parsed = JSON.parse(text); } catch { json = false; }
+ if (json) {
+ try {
+ const result = scrubJson(parsed, spellings);
+ if (result.changed) body = JSON.stringify(result.value);
+ } catch {
+ // Too deeply nested to check: refuse rather than return what could decode to the credential.
+ throw new PluginSdkError('http_failed', 'The response could not be checked for credentials');
+ }
+ }
+ return revealsCredential(body, spellings) ? '[REDACTED]' : body;
+}
+
+/**
+ * A response header with every credential spelling replaced, also where it is
+ * percent-escaped in either case. A value that still holds one once its JSON
+ * and percent escapes are decoded (a JSON-valued header escaping it as
+ * `\uXXXX`, also inside a longer, joined or re-encoded value, or a value
+ * URL-encoded twice) is withheld entirely: a handler could recover it with
+ * `JSON.parse` or `decodeURIComponent`.
+ */
+function scrubbedHeader(raw: string, spellings: readonly CredentialSpelling[]): string {
+ const text = scrub(raw, spellings);
+ if (!spellings.length) return text;
+ return revealsCredential(text, spellings) ? '[REDACTED]' : text;
+}
+
+/**
+ * `value` (a log line, its JSON fields, an error message or a JSON result) with
+ * the credentials in `secrets` scrubbed from every string and key as a response
+ * is: every spelling replaced, also percent-escaped in either case, and a
+ * string that still decodes to one withheld whole.
+ */
+export function scrubCredentialValues(value: T, secrets: readonly string[]): T {
+ const spellings = uniqueSpellings(secrets.filter(Boolean).flatMap(credentialSpellings));
+ return spellings.length ? scrubJson(value, spellings).value as T : value;
+}
+
+const lowerCase = (value: string) => value.toLowerCase();
+/**
+ * Whether a response header name (lowercased, as the handler sees it) holds a
+ * credential. Names are case-insensitive and arrive folded to lowercase (Node
+ * folds them), so an echoed credential, its base64 or its `%2F`-style URL
+ * encoding, is compared case-insensitively, also once JSON and percent escapes
+ * are decoded.
+ * A name cannot be scrubbed in place without colliding with others, so a
+ * header whose name holds one is dropped.
+ */
+function nameRevealsCredential(name: string, spellings: readonly CredentialSpelling[]): boolean {
+ if (!spellings.length) return false;
+ const folded = spellings.map(spelling => ({ ...spelling, text: lowerCase(spelling.text) }));
+ return holdsSpelling(name, folded) || revealsCredential(name, folded, lowerCase);
+}
+
+/** The error a request gets once the handler run has ended. */
+const ended = () => new PluginSdkError('unavailable', 'This handler run has ended');
+function checkLive(signal: AbortSignal | undefined): void {
+ if (signal?.aborted) throw ended();
+}
+/** Settle with `work`, or reject as soon as the handler run ends. */
+function untilEnded(work: Promise, signal: AbortSignal | undefined): Promise {
+ if (!signal) return work;
+ checkLive(signal);
+ return new Promise((resolve, reject) => {
+ const abort = () => reject(ended());
+ signal.addEventListener('abort', abort, { once: true });
+ work.then(resolve, reject).finally(() => signal.removeEventListener('abort', abort));
+ });
+}
+
+/** Check scheme, credentials in the URL, port and host against the policy. */
+function vetUrl(value: unknown, outboundHosts: readonly string[]): URL {
+ if (typeof value !== 'string' || value.length > 8192) throw new PluginSdkError('invalid_input', 'The URL must be text of at most 8192 characters');
+ let url: URL;
+ try { url = new URL(value); } catch { throw new PluginSdkError('invalid_input', 'Invalid URL'); }
+ if (url.protocol !== 'https:') throw new PluginSdkError('insecure_url', `Only https:// URLs are allowed, not ${url.protocol}`);
+ if (url.username || url.password) throw new PluginSdkError('invalid_input', 'URLs must not carry credentials');
+ if (url.port && url.port !== '443') throw new PluginSdkError('host_not_allowed', 'Only the default HTTPS port is allowed');
+ const host = url.hostname.toLowerCase();
+ if (isIP(host.replace(/^\[|\]$/g, '')) || !outboundHosts.includes(host)) throw new PluginSdkError('host_not_allowed', `${host} is not one of the plugin's outbound hosts`);
+ if (extensionReservedHostname(host)) throw new PluginSdkError('private_address', `${host} is a local name`);
+ return url;
+}
+
+/** Resolve and require every address to be public, like extension endpoints. */
+async function resolvePublic(host: string, lookup: ExtensionLookup): Promise<{ address: string; family: number }> {
+ let timer: NodeJS.Timeout | undefined;
+ let addresses: Awaited>;
+ try {
+ addresses = await Promise.race([
+ lookup(host),
+ new Promise((_, reject) => { timer = setTimeout(() => reject(new PluginSdkError('http_failed', `DNS lookup of ${host} timed out`)), PLUGIN_HTTP_LIMITS.timeoutMs); timer.unref(); }),
+ ]);
+ } catch (error) {
+ throw error instanceof PluginSdkError ? error : new PluginSdkError('http_failed', `${host} could not be resolved`);
+ } finally { clearTimeout(timer); }
+ if (!addresses.length || !addresses.every(a => extensionPublicAddress(a.address))) throw new PluginSdkError('private_address', `${host} must resolve exclusively to public addresses`);
+ return addresses[0];
+}
+
+function requestHeaders(init: PluginHttpRequestInit): Record {
+ const supplied = init.headers ?? {};
+ if (typeof supplied !== 'object' || Array.isArray(supplied) || Object.keys(supplied).length > PLUGIN_HTTP_LIMITS.headers) throw new PluginSdkError('invalid_input', `headers must be an object of at most ${PLUGIN_HTTP_LIMITS.headers} entries`);
+ const headers: Record = {};
+ for (const [name, value] of Object.entries(supplied)) {
+ if (!HEADER_NAME.test(name) || typeof value !== 'string' || !HEADER_VALUE.test(value)) throw new PluginSdkError('invalid_input', `Invalid header ${name.slice(0, 64)}`);
+ if (RESERVED_HEADERS.test(name)) throw new PluginSdkError('invalid_input', `Header ${name} is set by ProPR`);
+ headers[name.toLowerCase()] = value;
+ }
+ return headers;
+}
+
+function requestBody(init: PluginHttpRequestInit, headers: Record): Buffer | undefined {
+ if (init.body !== undefined && init.json !== undefined) throw new PluginSdkError('invalid_input', 'Pass either body or json, not both');
+ let body: Buffer | undefined;
+ if (init.json !== undefined) {
+ body = Buffer.from(JSON.stringify(init.json));
+ headers['content-type'] ??= 'application/json';
+ } else if (init.body !== undefined) {
+ if (typeof init.body !== 'string') throw new PluginSdkError('invalid_input', 'body must be text');
+ body = Buffer.from(init.body);
+ }
+ if (body && body.length > PLUGIN_HTTP_LIMITS.requestBytes) throw new PluginSdkError('invalid_input', `Request bodies are at most ${PLUGIN_HTTP_LIMITS.requestBytes} bytes`);
+ return body;
+}
+
+/** The default transport: dial only the vetted address with the original TLS identity; no redirects, proxies, cookies or decompression. */
+export const httpsPluginTransport: PluginHttpTransport = request => new Promise((resolve, reject) => {
+ const host = request.url.hostname;
+ const failed = (message = `Request to ${host} failed`) => reject(new PluginSdkError('http_failed', message));
+ if (request.signal?.aborted) { reject(ended()); return; }
+ const outgoing = https.request({
+ host: request.address, family: request.family, port: 443, path: `${request.url.pathname}${request.url.search}`,
+ method: request.method, agent: false, servername: host,
+ lookup: (_host, _options, callback) => callback(null, request.address, request.family),
+ headers: { ...request.headers, host: request.url.host, ...(request.body ? { 'content-length': String(request.body.length) } : {}) },
+ }, response => {
+ const chunks: Buffer[] = []; let bytes = 0;
+ response.on('data', (chunk: Buffer) => {
+ bytes += chunk.length;
+ if (bytes > request.maxResponseBytes) { response.destroy(); reject(new PluginSdkError('response_too_large', `Responses are at most ${request.maxResponseBytes} bytes`)); } else chunks.push(chunk);
+ });
+ response.on('error', () => failed());
+ response.on('end', () => {
+ const headers: Record = {};
+ for (const [name, value] of Object.entries(response.headers)) if (value !== undefined) headers[name] = Array.isArray(value) ? value.join(', ') : value;
+ resolve({ status: response.statusCode ?? 0, headers, body: Buffer.concat(chunks) });
+ });
+ });
+ const timer = setTimeout(() => { outgoing.destroy(); failed(`Request to ${host} timed out`); }, request.timeoutMs);
+ // The handler run ended: stop sending and receiving.
+ const abort = () => { outgoing.destroy(); reject(ended()); };
+ request.signal?.addEventListener('abort', abort, { once: true });
+ outgoing.on('close', () => { clearTimeout(timer); request.signal?.removeEventListener('abort', abort); });
+ outgoing.on('error', () => failed());
+ outgoing.end(request.body);
+});
+
+function bufferedResponse(url: URL, response: PluginHttpTransportResponse, spellings: readonly CredentialSpelling[]): PluginHttpResponse {
+ const text = scrubbedBody(response.body.toString('utf8'), spellings);
+ // A header whose name echoes the credential is dropped: the handler can read names as well as values.
+ const headers = Object.fromEntries(Object.entries(response.headers)
+ .map(([name, value]) => [name.toLowerCase(), value] as const)
+ .filter(([name]) => !nameRevealsCredential(name, spellings))
+ .map(([name, value]) => [name, scrubbedHeader(value, spellings)]));
+ return {
+ status: response.status, ok: response.status >= 200 && response.status < 300, url: url.toString(), headers,
+ text: async () => text,
+ json: async () => {
+ try { return JSON.parse(text) as T; } catch { throw new PluginSdkError('http_failed', 'The response is not JSON'); }
+ },
+ };
+}
+
+/**
+ * `ctx.http`: outbound HTTPS to the manifest's `outboundHosts` only, through
+ * the extension endpoint policy (public addresses only, the checked address
+ * is the one dialed), with bounded request and response sizes and time. A
+ * credential is injected as `Authorization: Bearer` and scrubbed from the
+ * response, so the handler never sees it.
+ */
+export function createPluginHttp(options: PluginHttpOptions): PluginHttpFetch {
+ const lookup = options.lookup ?? defaultLookup;
+ const transport = options.transport ?? httpsPluginTransport;
+ let credential: Promise | undefined;
+ return async (value, init = {}) => {
+ // Only the hostname is ever logged: a refused URL may carry credentials or a query.
+ let host = '(invalid URL)';
+ try { host = new URL(String(value)).hostname || host; } catch { /* stays invalid */ }
+ try {
+ const url = vetUrl(value, options.outboundHosts);
+ const method = (init.method ?? 'GET').toUpperCase();
+ if (!METHODS.has(method)) throw new PluginSdkError('invalid_input', `Method ${String(init.method).slice(0, 16)} is not allowed`);
+ const headers = requestHeaders(init);
+ const body = requestBody(init, headers);
+ const { signal } = options;
+ checkLive(signal);
+ const { address, family } = await untilEnded(resolvePublic(host, lookup), signal);
+ credential ??= options.credential ? options.credential() : Promise.resolve(null);
+ const secret = await untilEnded(credential, signal);
+ if (secret) headers.authorization = `Bearer ${secret}`;
+ if (options.authorize) await untilEnded(options.authorize(), signal);
+ // The run may have ended during the lookups: nothing is sent for an ended handler.
+ checkLive(signal);
+ options.onDecision?.({ host, allowed: true });
+ const response = await untilEnded(transport({ url, address, family, method, headers, body, timeoutMs: PLUGIN_HTTP_LIMITS.timeoutMs, maxResponseBytes: PLUGIN_HTTP_LIMITS.responseBytes, signal }), signal);
+ checkLive(signal);
+ return bufferedResponse(url, response, secret ? credentialSpellings(secret) : []);
+ } catch (error) {
+ const refused = error instanceof PluginSdkError ? error : new PluginSdkError('http_failed', `Request to ${host} failed`);
+ options.onDecision?.({ host, allowed: false, reason: refused.code });
+ throw refused;
+ }
+ };
+}
diff --git a/packages/core/src/plugins/pluginProprBackend.ts b/packages/core/src/plugins/pluginProprBackend.ts
new file mode 100644
index 0000000000..07ffd0845b
--- /dev/null
+++ b/packages/core/src/plugins/pluginProprBackend.ts
@@ -0,0 +1,165 @@
+import type { Knex } from 'knex';
+import { PLUGIN_PROPR_OPERATIONS, PluginSdkError, type PluginIssueSummary, type PluginProprOperation, type PluginPullRequestSummary, type PluginTaskSummary, type RepositoryEventDomain } from '@propr/shared';
+import { listRepositoryEvents } from '../services/repositoryEventJournal.js';
+import { generateCorrelationId } from '../utils/logger.js';
+import type { IssueJobData } from '../queue/taskQueue.types.js';
+import type { PluginCommentTarget, PluginProprBackend, PluginProprCallScope } from './pluginProprClient.js';
+
+/** The part of the issue task queue the default enqueue uses. */
+export interface PluginIssueQueue {
+ add(name: string, data: IssueJobData, options: Record): Promise;
+}
+
+/** The part of an authenticated Octokit the backend uses. */
+export interface PluginGitHubClient {
+ request(route: string, parameters: Record): Promise<{ data: unknown }>;
+}
+export interface GitHubPluginBackendOptions {
+ database: Knex;
+ /** The ProPR GitHub App client; defaults to the configured one. */
+ github?: () => Promise;
+ /**
+ * Queue an issue task job; defaults to adding it to `issueQueue`, as the task API does.
+ * Receives the handler run's signal: a job must not be added once it is aborted.
+ */
+ enqueueIssueTask?: (data: IssueJobData, jobId: string, signal?: AbortSignal) => Promise;
+ /** The issue task queue the default enqueue adds to; defaults to the configured one. */
+ issueQueue?: () => Promise;
+ /** The handler run's signal: once aborted no further GitHub request or queued task starts, and requests in flight are cancelled. */
+ signal?: AbortSignal;
+ /**
+ * Called right before every GitHub request is sent and every task job is
+ * added, after the App client or the queue is initialized, with the
+ * permission and repository the operation acts under: throws to refuse it
+ * (the plugin's installation was removed, replaced, disabled or restricted
+ * meanwhile).
+ */
+ authorize?: (call: PluginProprCallScope) => Promise