Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions middleware/packages/harness-orchestrator/src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1095,6 +1095,17 @@ export async function activate(
let reloadBus: ReloadBus | undefined;
if (graphPool) {
try {
// #796 — SECOND PASS, not the owner. `middleware/migrations/` is core's
// directory and core now applies it at boot, before any plugin
// activates and regardless of whether a provider key exists. This call
// used to be its only production trigger, which made core's schema a
// side effect of an LLM key being configured.
//
// It stays because it costs one SELECT against a current ledger (the
// migrator takes no lock when nothing is pending) and it keeps this
// plugin working when it is activated outside core's boot path — an
// embedding host, a fixture harness. It must never be the only caller
// again.
await runMultiOrchestratorMigrations(graphPool, (m) =>
ctx.log(`[harness-orchestrator] ${m}`),
);
Expand Down
49 changes: 49 additions & 0 deletions middleware/packages/plugin-api/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,55 @@ Versioning is SemVer over the **exported type surface**. Removing or narrowing
an exported type, or adding a required member to an interface a plugin
implements, is a major.

## 1.3.0 — 2026-08-20

Additive. Two shapes a plugin could not express before, both found by the
epic #470 P5 acceptance run against a real core: a dependency it can survive
the absence of, and a nav entry pointing at its own bundled UI when its id is
scoped. Every existing consumer keeps compiling — the new members are optional
or additive, and no existing member changed meaning.

### Added

- **`ctx.services.getOptional(name)`** (`<T>(name: string) => T | undefined`)
— the accessor for a capability declared under the new manifest field
`optional_requires:` (#795). Declaration-gated exactly like `get`, so an
undeclared name still throws `ServiceNotDeclaredError` and a typo cannot
quietly become `undefined`; what it adds is a call site that says absence is
survivable. `optional_requires:` entries use the same capability-ref syntax
as `requires:` and satisfy the same declaration gate, but are NOT an
activation dependency: the installer raises no
`install.missing_capability`, and the capability resolver neither demands
nor orders a provider for them.

The ordering consequence is part of the contract: with no activation edge,
an optional provider that IS installed may activate after its consumer.
Resolve optional services lazily, at first use, rather than caching the
result of one call during `activate()`.

- **`UiNavEntryInput.pluginUi`** (`true | undefined`) — ask the kernel to
render the canonical path to this plugin's own bundled UI instead of
supplying a literal `href` (#798). A scoped plugin id resolves only
percent-encoded (`/plugin-ui/%40acme%2Fwidget`), and percent-encoding is
precisely what the literal-`href` validator refuses — so a scoped plugin
previously had no spelling that both validated and worked. Supply exactly
one of `href` or `pluginUi: true`; supplying both, or neither, throws.

- **`ResolvedUiNavEntry.pluginUi`** (`true | undefined`) — set on entries
registered that way, so the web UI re-derives the href from `pluginId`
locally instead of trusting a percent-encoded string across a deployment
boundary.

### Changed

- **`UiNavEntryInput.href`** is now optional (`string | undefined`), because
a `pluginUi: true` entry supplies none. Widening an input field: every
plugin that passes an `href` today is unaffected.
- **`UiNavEntry.href`** stays required — the kernel resolves `pluginUi` to a
concrete path at registration, so a catalogued entry always has one.
- **`ServiceNotDeclaredError`**'s message now names `optional_requires:` as a
fix alongside `requires:` and `provides:`.

## 1.2.0 — 2026-08-20

Additive. A plugin may now be handed a Postgres pool and own tables in the
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1474,6 +1474,7 @@ constructor(pluginId: string, capability: string);
}
export interface ServicesAccessor {
get<T>(name: string): T | undefined;
getOptional<T>(name: string): T | undefined;
has(name: string): boolean;
provide<T>(name: string, impl: T | PerCallerFactory<T>): () => void;
replace<T>(name: string, impl: T | PerCallerFactory<T>): () => void;
Expand Down Expand Up @@ -1568,13 +1569,15 @@ readonly pluginId: string;
}
export interface UiNavEntryInput {
readonly navId: string;
readonly href: string;
readonly href?: string;
readonly pluginUi?: true;
readonly cluster?: string;
readonly order?: number;
readonly label: Readonly<Record<string, string>>;
}
export interface UiNavEntry extends UiNavEntryInput {
export interface UiNavEntry extends Omit<UiNavEntryInput, 'href'> {
readonly pluginId: string;
readonly href: string;
}
export interface ResolvedUiNavEntry {
readonly pluginId: string;
Expand All @@ -1583,6 +1586,7 @@ readonly href: string;
readonly cluster?: string;
readonly order: number;
readonly label: string;
readonly pluginUi?: true;
}
export interface NotificationsAccessor {
send(payload: NotificationPayload): Promise<NotificationDispatchResult>;
Expand Down
2 changes: 1 addition & 1 deletion middleware/packages/plugin-api/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@omadia/plugin-api",
"version": "1.2.0",
"version": "1.3.0",
"private": true,
"type": "module",
"main": "dist/index.js",
Expand Down
81 changes: 74 additions & 7 deletions middleware/packages/plugin-api/src/pluginContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -535,7 +535,8 @@ export class ServiceNotDeclaredError extends Error {
constructor(pluginId: string, capability: string) {
super(
`plugin '${pluginId}' called ctx.services.get('${capability}') but its manifest does not declare that capability — ` +
`add '${capability}@<major>' to the manifest's \`requires:\` list (or \`provides:\` if this plugin is the provider)`,
`add '${capability}@<major>' to the manifest's \`requires:\` list (or \`optional_requires:\` when absence is survivable, ` +
`or \`provides:\` if this plugin is the provider)`,
);
this.name = 'ServiceNotDeclaredError';
this.pluginId = pluginId;
Expand Down Expand Up @@ -580,6 +581,31 @@ export interface ServicesAccessor {
* not declare `name` — that is a manifest bug, not a missing provider, and
* the two must not be reported the same way. */
get<T>(name: string): T | undefined;
/**
* Resolve a capability the plugin declared as OPTIONAL
* (`optional_requires:` in the manifest), where "no provider installed"
* is a supported steady state rather than a misconfiguration.
*
* Declaration-gated on exactly the same terms as {@link get}: a name in
* neither `requires:`, `optional_requires:` nor `provides:` throws
* {@link ServiceNotDeclaredError}, because a typo must not silently
* become `undefined`.
*
* The difference from {@link get} is the contract it advertises, not the
* lookup. `get` is paired with `requires:`, which the installer and the
* boot loop both treat as a hard prerequisite — so a `get` that returns
* `undefined` normally means something upstream failed. `getOptional` is
* paired with `optional_requires:`, which neither gate enforces, so
* `undefined` here is an expected answer and the caller is expected to
* carry a degradation path for it.
*
* Note the ordering caveat that comes with optionality: an optional
* dependency contributes no activation edge, so a provider that IS
* installed may not have activated yet when the consumer's `activate()`
* runs. Resolve optional services lazily (at first use) rather than
* caching the result of a single call during activation.
*/
getOptional<T>(name: string): T | undefined;
/** Whether a provider is currently registered. Ungated — see the interface
* doc. */
has(name: string): boolean;
Expand Down Expand Up @@ -1001,6 +1027,12 @@ export interface UiRoutesAccessor {
* web-ui pages (a built-in package) has a nav entry and no uiRoute;
* a plugin serving its own HTML has both.
*
* Supply either a literal `href` (validated as a canonical in-app path)
* or `pluginUi: true`, which asks the kernel to render the canonical
* path to this plugin's own bundled UI — the only way a scoped plugin
* id can express a nav destination, since that path must be
* percent-encoded and a literal href may not be.
*
* Returns a dispose handle the plugin MUST call from its `close()`.
* The kernel additionally drops every entry by source on deactivate,
* so a leaked handle cannot outlive the plugin.
Expand Down Expand Up @@ -1044,11 +1076,32 @@ export interface UiNavEntryInput {
/** Stable id within the plugin. Combined with pluginId as the key. */
readonly navId: string;
/**
* Absolute in-app path (e.g. `/admin/dev-platform`). Must start with
* exactly one `/` — protocol-relative (`//host`) and scheme-bearing
* values are rejected so a manifest cannot point the nav off-origin.
* Absolute in-app path (e.g. `/admin/reports`). Must start with exactly
* one `/` — protocol-relative (`//host`) and scheme-bearing values are
* rejected so a manifest cannot point the nav off-origin. Segments are
* confined to the RFC 3986 unreserved set: no query, no fragment, no
* percent-encoding, no dot-segments.
*
* Mutually exclusive with {@link pluginUi}; exactly one of the two must
* be supplied.
*/
readonly href: string;
readonly href?: string;
/**
* Point the entry at THIS plugin's own bundled UI instead of a literal
* path, and let the kernel spell the URL.
*
* A plugin that ships a compiled SPA is served at `/p/<pluginId>/ui/`
* and embedded by the shell's host page at `/plugin-ui/<pluginId>`. For
* a scoped id like `@acme/widget` the only URL that resolves is the
* percent-encoded one (`%40acme%2Fwidget`) — and percent-encoding is
* exactly what the `href` validator refuses, deliberately, because a
* literal href has to be comparable to a core path by string equality.
*
* So the plugin states the intent and the kernel renders the canonical
* encoded path from the id it already knows. A plugin never hand-builds
* an encoded href, and the literal-href rule stays strict.
*/
readonly pluginUi?: true;
/**
* Optional cluster to nest under (e.g. `adminCluster`). Rendered as a
* top-level entry when omitted, or when the shell has no cluster by
Expand All @@ -1064,9 +1117,15 @@ export interface UiNavEntryInput {
readonly label: Readonly<Record<string, string>>;
}

/** Catalogue-resolved nav entry — pluginId injected by the kernel. */
export interface UiNavEntry extends UiNavEntryInput {
/**
* Catalogue-resolved nav entry — `pluginId` injected by the kernel, and
* `href` no longer optional: a `pluginUi: true` input is resolved to the
* canonical host-page path at registration, so every stored entry carries
* a concrete destination.
*/
export interface UiNavEntry extends Omit<UiNavEntryInput, 'href'> {
readonly pluginId: string;
readonly href: string;
}

/**
Expand All @@ -1081,6 +1140,14 @@ export interface ResolvedUiNavEntry {
readonly cluster?: string;
readonly order: number;
readonly label: string;
/**
* Present iff the entry was registered with `pluginUi: true`. The shell
* uses it to re-derive `href` from `pluginId` locally instead of
* trusting the transmitted string — the middleware is a separate
* deployable, and a percent-encoded href is the one shape the shell's
* own defensive href rule cannot check character by character.
*/
readonly pluginUi?: true;
}

/**
Expand Down
13 changes: 13 additions & 0 deletions middleware/src/api/admin-v1.ts
Original file line number Diff line number Diff line change
Expand Up @@ -514,6 +514,19 @@ export interface Plugin {
* to empty array). The kernel rejects boot if any `requires` has no
* matching `provides` across the installed plugin set. */
requires: string[];
/**
* Capabilities this plugin may use but can run without (#795). Same
* capability-ref syntax as {@link requires}, and the same effect on the
* `ctx.services` declaration gate — but NOT an activation dependency:
* the installer does not refuse the install when nothing provides one,
* the capability resolver neither orders nor demands a provider, and
* `ctx.services.getOptional(name)` simply answers `undefined`.
*
* Absent when the manifest declares none; read it as `?? []`. Surfaced
* on the install DTO so the consent UI can render these prerequisites
* as optional rather than as blockers.
*/
optional_requires?: string[];
/**
* Builder service-type declarations (OB — service-type auto-discovery).
* Integration plugins list every `ctx.services.provide(...)` surface they
Expand Down
4 changes: 4 additions & 0 deletions middleware/src/api/registry-v1.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,10 @@ export interface RegistryVersionEntry {
export interface RegistryManifestSummary {
provides?: string[];
requires?: string[];
/** Capabilities the plugin can run without (#795). Same capability-ref
* syntax as `requires`, but never a reason to refuse an install — the
* consent UI renders these as optional prerequisites. */
optional_requires?: string[];
depends_on?: string[];
/** Setup fields the operator must fill at install-time. Mirrors
* `PluginSetupField` but kept loose here to avoid a hard schema coupling. */
Expand Down
25 changes: 25 additions & 0 deletions middleware/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,7 @@ import { RefreshStore } from './auth/refreshStore.js';
import { EmailWhitelist } from './auth/whitelist.js';
import { resolveSessionSigningKey } from './auth/sessionSigningKey.js';
import { runAuthMigrations } from './auth/migrator.js';
import { runCoreMigrations } from './platform/coreMigrations.js';
import { runProfileStorageMigrations } from './profileStorage/migrator.js';
import { LiveProfileStorageService } from './profileStorage/liveProfileStorageService.js';
import { runProfileSnapshotMigrations } from './profileSnapshots/migrator.js';
Expand Down Expand Up @@ -1707,6 +1708,30 @@ async function main(): Promise<void> {
const ms365IntegrationId = 'de.byte5.integration.microsoft365';
const calendarAgentId = 'de.byte5.agent.calendar';

// #796 (epic #470 C9 / G3) — core's own base schema, applied by core,
// BEFORE any plugin activates and independent of every provider key.
//
// This used to be a side effect of the harness-orchestrator plugin
// activating, which returns early when no LLM provider is configured — so a
// deployment without an Anthropic key had no `_multi_orchestrator_migrations`
// ledger, no `plugin_public_path_grants` and no `plugin_sql_grants`, and
// therefore no way to record either operator consent. Nothing logged it,
// because no migration was ever attempted.
//
// Ordering is load-bearing, not tidiness: `ToolPluginRuntime` reads a plugin's
// SQL-grant row while building its context, so the grant tables have to exist
// by the time the line below runs. See `platform/coreMigrations.ts` for why it
// opens its own connection instead of waiting for `graphPool`.
const coreMigrations = await runCoreMigrations({
databaseUrl: process.env['DATABASE_URL'],
log: (m) => { console.log(m); },
});
console.log(
coreMigrations === 'no-database'
? '[middleware] core migrations SKIPPED — no DATABASE_URL (in-memory backend)'
: '[middleware] core migrations applied (middleware/migrations)',
);

// Activate tool / extension / integration plugins FIRST. Their
// activate() populates nativeToolRegistry + pluginRouteRegistry +
// serviceRegistry (incl. the MemoryStore provided by @omadia/memory
Expand Down
Loading
Loading