diff --git a/.changeset/adr-0096-runtime-rematerialization.md b/.changeset/adr-0096-runtime-rematerialization.md new file mode 100644 index 000000000..79e8a2560 --- /dev/null +++ b/.changeset/adr-0096-runtime-rematerialization.md @@ -0,0 +1,26 @@ +--- +'@objectstack/service-automation': minor +--- + +feat(connectors): ADR-0096 runtime re-materialization of declarative connectors (#2977 follow-up) + +Provider-bound declarative `connectors:` instances (ADR-0096) previously +materialized only at boot — a connector published from Studio while the server +ran did not become dispatchable until a restart. `materializeDeclaredConnectors` +is now a **reconcile** run both at boot and on `metadata:reloaded`: + +- **Add** newly-declared instances, **tear down** removed / newly-`enabled:false` + ones (calling their `close`, e.g. an MCP connection), and **re-materialize** + only instances whose signature — a stable hash of `provider` + `providerConfig` + + `auth` + identity — changed. An unchanged MCP instance is never needlessly + reconnected on an unrelated metadata reload. +- **Boot stays fatal** ("fail loudly"): unknown provider / invalid providerConfig + / unresolvable credentialRef / name conflict aborts startup. **Reload is soft**: + the same problems are logged and the offending entry skipped, so a bad publish + never crashes a running server; a changed instance's old connector keeps + serving until its replacement materializes successfully. + +Also: `ConnectorDescriptor` (served by `GET /api/v1/automation/connectors`) now +carries an `origin` field (`'plugin' | 'declarative'`), so a designer can +distinguish a materialized declarative instance from a plugin-registered +connector. diff --git a/docs/adr/0096-declarative-connector-instances.md b/docs/adr/0096-declarative-connector-instances.md index b2b4432b3..5df3a7cf2 100644 --- a/docs/adr/0096-declarative-connector-instances.md +++ b/docs/adr/0096-declarative-connector-instances.md @@ -118,8 +118,22 @@ If a provider-bound instance and a plugin-registered connector share a `name`, b - **Open source:** the `rest` / `openapi` / `mcp` provider factories; **static** auth (`none` / `api-key` / `basic` / `bearer`); `credentialRef` resolved from **environment variables** (`defaultEnvCredentialResolver`) — the degraded-but-honest story for environments with no managed secrets service. - **Enterprise:** managed credential **vaulting** and OAuth2 authorization-code/refresh lifecycle (inject a vault-backed `CredentialResolver` via `AutomationServicePluginOptions.credentialResolver` — no change to the materialization path), plus per-tenant connection lifecycle. The open tier deliberately omits OAuth2 from `ConnectorInstanceAuthSchema`. +### Runtime reconcile (follow-up, landed) + +Materialization is no longer boot-only. `materializeDeclaredConnectors` is a +**reconcile** against the declared set, run both at boot (`fatal: true`) and on +`metadata:reloaded` (`fatal: false` — a Studio publish / dev reload). The reconcile +adds newly-declared instances, tears down removed / newly-`enabled:false` ones +(calling their `close`), and re-materializes only those whose signature (a stable +hash of `provider` + `providerConfig` + `auth` + identity) changed — an unchanged +MCP instance is never needlessly reconnected. On reload a bad entry (unknown +provider, unresolvable credentialRef, factory failure, name conflict) is **logged +and skipped**, never crashing the live server; a changed instance's old connector +keeps serving until its replacement materializes successfully. Boot keeps the +"fail loudly" contract. + ### Deliberate scope boundaries -- **Boot-time only.** Materialization runs once at boot. Re-materializing a provider-bound instance published at runtime (Studio) is a follow-up; the descriptor audit still re-runs on `metadata:reloaded`. - **`providerConfig.spec` (openapi)** accepts an inline document or an http(s) URL; resolving a `./file.json` ref relative to the stack is the stack loader's job, not the connector's. - **MCP credentials** ride the transport (ADR-0024); for an http transport a resolved `auth` is folded into the request headers. +- **MCP at boot** connects during materialization, so an unreachable server fails boot; a fail-soft "optional" marker for boot-time materialization is a possible future refinement (runtime reloads are already soft). diff --git a/packages/services/service-automation/src/connector-materialization.test.ts b/packages/services/service-automation/src/connector-materialization.test.ts index 9bce8d8b8..23e7f86ac 100644 --- a/packages/services/service-automation/src/connector-materialization.test.ts +++ b/packages/services/service-automation/src/connector-materialization.test.ts @@ -127,6 +127,70 @@ function automationOf(kernel: LiteKernel): AutomationEngine { return kernel.getService('automation') as AutomationEngine; } +/** + * A fake provider whose materialized connectors carry a `close`, so reload tests + * can assert teardown. Records the ctx of every invocation (`calls`) and the + * names whose `close()` ran (`closed`). + */ +function makeClosableProvider() { + const calls: ConnectorProviderContext[] = []; + const closed: string[] = []; + const factory: ConnectorProviderFactory = (ctx) => { + calls.push(ctx); + return { + def: { + name: ctx.name, + label: ctx.label, + type: 'api', + authentication: { type: 'none' }, + actions: [{ key: 'ping', label: 'Ping' }], + }, + handlers: { ping: async () => ({ ok: true }) }, + close: async () => { closed.push(ctx.name); }, + }; + }; + return { factory, calls, closed }; +} + +/** + * Boot with a MUTABLE declared set and expose a `reload(next)` that swaps the + * registry contents and fires `metadata:reloaded` — the runtime reconcile path + * (ADR-0096 F1). The harness's ctx is the shared kernel context, so + * `ctx.trigger('metadata:reloaded')` invokes the automation plugin's hook. + */ +async function bootReloadable( + initial: unknown[], + opts: { providerFactory: ConnectorProviderFactory; credentialResolver?: CredentialResolver }, +) { + const state = { declared: initial }; + let captured: any; + const kernel = new LiteKernel({ logger: { level: 'silent' } } as never); + kernel.use(new AutomationServicePlugin({ credentialResolver: opts.credentialResolver })); + const harness = { + name: 'test.harness', + type: 'standard' as const, + version: '1.0.0', + dependencies: ['com.objectstack.service-automation'], + async init(ctx: any) { + captured = ctx; + ctx.registerService('objectql', { + registry: { listItems: (type: string) => (type === 'connector' ? state.declared : []) }, + }); + ctx.getService('automation').registerConnectorProvider('fake', opts.providerFactory); + }, + async start() {}, + }; + kernel.use(harness as never); + await kernel.bootstrap(); + await flush(); + const reload = async (next: unknown[]) => { + state.declared = next; + await captured.trigger('metadata:reloaded'); + await flush(); + }; + return { kernel, engine: automationOf(kernel), reload }; +} + describe('ADR-0096 — declarative connector materialization', () => { it('materializes a provider-bound instance into a live, listed connector', async () => { const { factory, calls } = makeFakeProvider(); @@ -139,7 +203,9 @@ describe('ADR-0096 — declarative connector materialization', () => { const desc = engine.getConnectorDescriptors().find((d) => d.name === 'billing'); expect(desc).toBeDefined(); expect(desc?.actions.map((a) => a.key)).toEqual(['ping']); - // Origin is 'declarative', not 'plugin'. + // Origin is 'declarative', not 'plugin' — surfaced on the descriptor so a + // designer can distinguish a materialized instance from a plugin connector. + expect(desc?.origin).toBe('declarative'); expect(engine.getConnectorOrigin('billing')).toBe('declarative'); // The factory saw the declared identity. expect(calls[0]?.name).toBe('billing'); @@ -282,3 +348,82 @@ describe('ADR-0096 — declarative connector materialization', () => { ).rejects.toThrow(/duplicate declarative connector instance name 'dup'/); }); }); + +describe('ADR-0096 — runtime re-materialization on metadata:reloaded (F1)', () => { + it('materializes an instance published after boot', async () => { + const { factory, calls } = makeClosableProvider(); + const { engine, reload, kernel } = await bootReloadable([], { providerFactory: factory }); + expect(engine.getRegisteredConnectors()).not.toContain('billing'); + + await reload([providerConnector('billing')]); + expect(engine.getRegisteredConnectors()).toContain('billing'); + expect(engine.getConnectorOrigin('billing')).toBe('declarative'); + expect(calls).toHaveLength(1); + await kernel.shutdown(); + }); + + it('unregisters and tears down an instance removed after boot', async () => { + const { factory, closed } = makeClosableProvider(); + const { engine, reload, kernel } = await bootReloadable([providerConnector('billing')], { providerFactory: factory }); + expect(engine.getRegisteredConnectors()).toContain('billing'); + + await reload([]); // billing deleted from the stack + expect(engine.getRegisteredConnectors()).not.toContain('billing'); + expect(closed).toEqual(['billing']); // close() ran on teardown + await kernel.shutdown(); + }); + + it('tears down an instance newly marked enabled:false', async () => { + const { factory, closed } = makeClosableProvider(); + const { engine, reload, kernel } = await bootReloadable([providerConnector('billing')], { providerFactory: factory }); + await reload([providerConnector('billing', { enabled: false })]); + expect(engine.getRegisteredConnectors()).not.toContain('billing'); + expect(closed).toEqual(['billing']); + await kernel.shutdown(); + }); + + it('leaves an UNCHANGED instance untouched (no reconnect on unrelated reload)', async () => { + const { factory, calls, closed } = makeClosableProvider(); + const { reload, kernel } = await bootReloadable( + [providerConnector('billing', { providerConfig: { baseUrl: 'https://x' } })], + { providerFactory: factory }, + ); + expect(calls).toHaveLength(1); + + // A reload that does not change billing's inputs must not re-invoke the factory. + await reload([providerConnector('billing', { providerConfig: { baseUrl: 'https://x' } })]); + expect(calls).toHaveLength(1); + expect(closed).toEqual([]); + await kernel.shutdown(); + }); + + it('re-materializes a CHANGED instance and tears down the old connection', async () => { + const { factory, calls, closed } = makeClosableProvider(); + const { engine, reload, kernel } = await bootReloadable( + [providerConnector('billing', { providerConfig: { baseUrl: 'https://old' } })], + { providerFactory: factory }, + ); + expect(calls).toHaveLength(1); + + await reload([providerConnector('billing', { providerConfig: { baseUrl: 'https://new' } })]); + expect(calls).toHaveLength(2); // re-materialized + expect(closed).toEqual(['billing']); // old connection torn down + expect(engine.getRegisteredConnectors()).toContain('billing'); + expect((calls[1].providerConfig as { baseUrl?: string }).baseUrl).toBe('https://new'); + await kernel.shutdown(); + }); + + it('reload is soft: an unknown-provider entry is skipped, not fatal, and other connectors survive', async () => { + const { factory } = makeClosableProvider(); + const { engine, reload, kernel } = await bootReloadable([providerConnector('billing')], { providerFactory: factory }); + + // A bad publish must NOT crash the running server (no throw), and the + // healthy connector keeps serving. + await expect( + reload([providerConnector('billing'), providerConnector('ghost', { provider: 'nonexistent' })]), + ).resolves.toBeUndefined(); + expect(engine.getRegisteredConnectors()).toContain('billing'); + expect(engine.getRegisteredConnectors()).not.toContain('ghost'); + await kernel.shutdown(); + }); +}); diff --git a/packages/services/service-automation/src/engine.ts b/packages/services/service-automation/src/engine.ts index c1ba5a63c..594c6ab6a 100644 --- a/packages/services/service-automation/src/engine.ts +++ b/packages/services/service-automation/src/engine.ts @@ -246,6 +246,14 @@ export interface ConnectorDescriptor { readonly description?: string; readonly icon?: string; readonly actions: ConnectorActionDescriptor[]; + /** + * How the connector reached the registry (ADR-0096 §4): `plugin` — registered + * by a connector plugin via `registerConnector`; `declarative` — materialized + * from a provider-bound `connectors:` stack entry at boot. Lets a designer + * distinguish a live declarative instance from a plugin connector (and both + * from an inert catalog descriptor, which never reaches this list). + */ + readonly origin: ConnectorOrigin; } // ─── Core Automation Engine ───────────────────────────────────────── @@ -926,12 +934,13 @@ export class AutomationEngine implements IAutomationService { * Handlers are omitted — they are runtime code, not metadata. */ getConnectorDescriptors(): ConnectorDescriptor[] { - return [...this.connectors.values()].map(({ def }) => ({ + return [...this.connectors.values()].map(({ def, origin }) => ({ name: def.name, label: def.label, type: def.type, description: def.description, icon: def.icon, + origin, actions: (def.actions ?? []).map((a) => ({ key: a.key, label: a.label, diff --git a/packages/services/service-automation/src/plugin.ts b/packages/services/service-automation/src/plugin.ts index 95fb0632e..620d4dcac 100644 --- a/packages/services/service-automation/src/plugin.ts +++ b/packages/services/service-automation/src/plugin.ts @@ -83,6 +83,46 @@ export type CredentialResolver = (ref: string) => string | undefined | Promise typeof process !== 'undefined' ? process.env?.[ref] : undefined; +/** + * Deterministic JSON stringify (keys sorted at every level) so a signature is + * stable regardless of authored key order — two materialization inputs that + * differ only in key order hash identically and don't trigger a needless + * re-materialize. + */ +function stableStringify(input: unknown): string { + if (input === null || typeof input !== 'object') return JSON.stringify(input) ?? 'null'; + if (Array.isArray(input)) return '[' + input.map(stableStringify).join(',') + ']'; + const obj = input as Record; + return '{' + Object.keys(obj).sort().map((k) => JSON.stringify(k) + ':' + stableStringify(obj[k])).join(',') + '}'; +} + +/** + * Stable signature of a provider-bound instance's materialization inputs + * (ADR-0096). Drives the `metadata:reloaded` reconcile: an unchanged signature + * means the live connector is left untouched (no MCP reconnect); a changed one + * triggers re-materialization. `auth` carries only the `credentialRef`, never a + * resolved secret, so hashing the declarative entry is safe. + */ +function connectorInstanceSignature(entry: { + provider?: unknown; + providerConfig?: unknown; + auth?: unknown; + label?: unknown; + description?: unknown; + icon?: unknown; + type?: unknown; +}): string { + return stableStringify({ + provider: entry.provider ?? null, + providerConfig: entry.providerConfig ?? null, + auth: entry.auth ?? null, + label: entry.label ?? null, + description: entry.description ?? null, + icon: entry.icon ?? null, + type: entry.type ?? null, + }); +} + /** * The shape of a declarative `connectors:` stack entry as it sits in the * ObjectQL metadata registry (registered by `registerApp` under kind @@ -188,12 +228,15 @@ export class AutomationServicePlugin implements Plugin { */ private syncedFlowNames = new Set(); /** - * Teardown callbacks for connectors this plugin materialized from - * provider-bound declarative entries (ADR-0096) — e.g. an MCP connection's - * `close`. Invoked in reverse on `destroy()` so no socket / child process - * leaks after the connectors are unregistered. + * Provider-bound declarative connectors this plugin has materialized + * (ADR-0096), keyed by connector name. `signature` is a stable hash of the + * entry's materialization inputs so a `metadata:reloaded` reconcile can tell + * an unchanged instance (skip — don't re-open its MCP connection) from a + * changed one (re-materialize). `close` is the optional teardown (e.g. an MCP + * connection), invoked on removal, replacement, and `destroy()` so no socket / + * child process leaks. */ - private materializedConnectorClosers: Array<{ name: string; close: () => void | Promise }> = []; + private materializedConnectors = new Map void | Promise }>(); constructor(options: AutomationServicePluginOptions = {}) { this.options = options; @@ -359,7 +402,7 @@ export class AutomationServicePlugin implements Plugin { // boot loudly — a metadata platform shipping a plausible-but-dead connector is // the worst failure mode. A start()-phase throw is fatal under both LiteKernel // and ObjectKernel (rollbackOnFailure defaults on), so the operator sees it. - await this.materializeDeclaredConnectors(ctx); + await this.materializeDeclaredConnectors(ctx, { fatal: true }); // ── Runtime re-bind: re-sync flow triggers on 'metadata:reloaded' ────── // Fires on two RUNTIME events (never on a cold boot — the kernel:ready @@ -376,9 +419,14 @@ export class AutomationServicePlugin implements Plugin { // flows that vanished so their jobs stop. ctx.hook('metadata:reloaded', async () => { await this.resyncFlowsFromProtocol(ctx); - // A Studio publish / dev reload can introduce new declarative - // connector entries — re-audit so the inert-descriptor warning - // stays current (see auditDeclaredConnectors). + // A Studio publish / dev reload can add, change, or remove declarative + // provider-bound connector instances — reconcile the live registry so + // a newly-published instance becomes dispatchable (and a removed one + // is torn down) without a restart (ADR-0096). Soft mode: a bad publish + // logs + skips rather than crashing the running server. + await this.materializeDeclaredConnectors(ctx, { fatal: false }); + // Re-audit so the inert-descriptor warning stays current for plain + // descriptors (see auditDeclaredConnectors). this.auditDeclaredConnectors(ctx); }); @@ -462,24 +510,30 @@ export class AutomationServicePlugin implements Plugin { } /** - * ADR-0096 — materialize every provider-bound declarative `connectors:` entry - * into a live, dispatchable connector at boot. For each entry that names a - * `provider`: - * 1. look up the provider factory the connector plugin registered - * (unknown provider ⇒ **hard boot error**, naming the plugin to install); - * 2. resolve `auth.credentialRef` through the credential resolver - * (unresolvable ⇒ **hard boot error**); - * 3. invoke the factory — the same `{ def, handlers }` bundle a plugin would - * register (invalid providerConfig / unreachable upstream ⇒ **hard error**); - * 4. register it under the **declared** name, tagged `declarative`, so a - * collision with a plugin-registered connector fails loudly (§4). + * ADR-0096 — reconcile the live connector registry against the declared + * provider-bound `connectors:` entries. For each enabled entry naming a + * `provider`: look up the provider factory, resolve `auth.credentialRef`, + * invoke the factory, and register the result under the **declared** name + * (tagged `declarative`). Entries that vanished (or were disabled) since the + * last reconcile are unregistered and torn down; unchanged entries are left + * alone (an unchanged MCP instance is NOT reconnected); changed entries are + * re-materialized. + * + * Two modes: + * - **Boot** (`fatal: true`, called from `start()`): any problem — unknown + * provider, invalid `providerConfig`, unresolvable `credentialRef`, name + * conflict, duplicate — **throws**, which is fatal to bootstrap under both + * LiteKernel and ObjectKernel (the ADR's "fail loudly" contract). + * - **Reload** (`fatal: false`, called from `metadata:reloaded`): the same + * problems are **logged and the offending entry is skipped**, so a bad + * Studio publish / dev reload never crashes a live server. A changed + * entry's old connector keeps serving until the new one materializes + * successfully. * - * Called from `start()` (not a `kernel:ready` hook) so a throw here is fatal - * to bootstrap under both LiteKernel and ObjectKernel — the ADR's "fail - * loudly" contract. Reads the same ObjectQL registry the descriptor audit - * uses; without one there is nothing declared, hence nothing to materialize. + * Reads the same ObjectQL registry the descriptor audit uses; without one + * there is nothing declared, hence nothing to reconcile. */ - private async materializeDeclaredConnectors(ctx: PluginContext): Promise { + private async materializeDeclaredConnectors(ctx: PluginContext, opts: { fatal: boolean }): Promise { const engine = this.engine; if (!engine) return; @@ -493,64 +547,74 @@ export class AutomationServicePlugin implements Plugin { return; // no registry — nothing declared } - const instances = declared - .map((c) => c as DeclaredConnectorItem) - .filter( - (c): c is DeclaredConnectorItem & { name: string; provider: string } => - typeof c?.name === 'string' && - c.name.length > 0 && - typeof c.provider === 'string' && - c.provider.length > 0, - ); - if (instances.length === 0) return; - - const resolver = this.options.credentialResolver ?? defaultEnvCredentialResolver; - const seen = new Set(); - let materialized = 0; - - for (const entry of instances) { - const { name, provider } = entry; - - // A deliberately-disabled instance is declared but not activated — - // the enabled:false marker, consistent with the descriptor contract. - if (entry.enabled === false) { - ctx.logger.info( - `[Automation] connector instance '${name}' (provider '${provider}') is enabled:false — declared but not materialized`, - ); + // Report a reconcile problem: fatal (boot) throws; soft (reload) logs. + const fail = (msg: string): void => { + if (opts.fatal) throw new Error(msg); + ctx.logger.error(msg); + }; + + // Build the desired set: enabled provider-bound instances, keyed by name. + // A descriptor (no `provider`) or an `enabled: false` instance is not + // desired — if we materialized it before, it is torn down below. + const desired = new Map(); + for (const raw of declared) { + const entry = raw as DeclaredConnectorItem; + if (typeof entry?.name !== 'string' || entry.name.length === 0) continue; + if (typeof entry.provider !== 'string' || entry.provider.length === 0) continue; + const bound = entry as DeclaredConnectorItem & { name: string; provider: string }; + if (bound.enabled === false) continue; + if (desired.has(bound.name)) { + fail(`[Automation] duplicate declarative connector instance name '${bound.name}' — connector names must be unique (ADR-0096).`); continue; } + desired.set(bound.name, { entry: bound, signature: connectorInstanceSignature(bound) }); + } - // Two instances declaring the same name is an authoring bug — the - // registry would otherwise silently keep one. - if (seen.has(name)) { - throw new Error( - `[Automation] duplicate declarative connector instance name '${name}' — connector names must be unique (ADR-0096).`, - ); - } - seen.add(name); + // 1. Remove connectors we previously materialized that are no longer + // desired (deleted from the stack, or newly `enabled: false`). + for (const [name, tracked] of [...this.materializedConnectors]) { + if (desired.has(name)) continue; + await this.dematerializeConnector(engine, name, tracked, ctx); + } + + // 2. Add new instances and re-materialize changed ones (signature diff). + const resolver = this.options.credentialResolver ?? defaultEnvCredentialResolver; + let changed = 0; + for (const [name, { entry, signature }] of desired) { + const existing = this.materializedConnectors.get(name); + if (existing && existing.signature === signature) continue; // unchanged — leave the live connector as-is - // §4 conflict, init-time case: a plugin already registered this name - // during init(). (Plugins that register in start() — after this runs — - // are caught by the origin-tagged registerConnector when they start.) - if (engine.getConnectorOrigin(name) === 'plugin') { - throw new Error( + const provider = entry.provider; + + // §4 conflict: the name is held by a plugin-registered connector (not + // one of ours). Never silently replace across origins. + if (!existing && engine.getConnectorOrigin(name) === 'plugin') { + fail( `[Automation] connector name conflict: declarative provider-bound instance '${name}' collides with a ` + `plugin-registered connector of the same name — there is no silent precedence (ADR-0096 §4). Rename one.`, ); + continue; } const factory = engine.getConnectorProvider(provider); if (!factory) { const installed = engine.getRegisteredConnectorProviders(); - throw new Error( + fail( `[Automation] connector instance '${name}' declares provider '${provider}', but no provider factory is registered. ` + `Install the connector plugin that supplies it (openapi → @objectstack/connector-openapi, mcp → @objectstack/connector-mcp, ` + `rest → @objectstack/connector-rest) in the stack's plugins: array. Installed providers: ` + `[${installed.join(', ') || 'none'}] (ADR-0096).`, ); + continue; } - const auth = await this.resolveInstanceAuth(entry.auth, resolver, name, provider); + let auth; + try { + auth = await this.resolveInstanceAuth(entry.auth, resolver, name, provider); + } catch (err) { + fail((err as Error).message); + continue; + } const providerCtx: ConnectorProviderContext = { name, @@ -566,10 +630,18 @@ export class AutomationServicePlugin implements Plugin { try { materialization = await factory(providerCtx); } catch (err) { - throw new Error( + fail( `[Automation] failed to materialize connector instance '${name}' via provider '${provider}': ` + `${(err as Error).message} (ADR-0096).`, ); + continue; + } + + // The new bundle is ready. Only NOW tear down the previous connection + // (on a change), so a failed re-materialization above left the old + // connector serving untouched. + if (existing?.close) { + try { await existing.close(); } catch { /* best-effort */ } } // The declared name is authoritative: register under it regardless of @@ -577,23 +649,36 @@ export class AutomationServicePlugin implements Plugin { // conflict rule all agree with the metadata the author wrote. const def = { ...materialization.def, name }; engine.registerConnector(def, materialization.handlers, 'declarative'); - if (materialization.close) { - this.materializedConnectorClosers.push({ name, close: materialization.close }); - } - materialized++; + this.materializedConnectors.set(name, { signature, close: materialization.close }); + changed++; ctx.logger.info( - `[Automation] materialized connector instance '${name}' via provider '${provider}' ` + + `[Automation] ${existing ? 're-' : ''}materialized connector instance '${name}' via provider '${provider}' ` + `(${def.actions?.length ?? 0} action(s))`, ); } - if (materialized > 0) { + if (changed > 0) { ctx.logger.info( - `[Automation] materialized ${materialized} provider-bound connector instance(s) (ADR-0096)`, + `[Automation] materialized ${changed} provider-bound connector instance(s) (ADR-0096)`, ); } } + /** Unregister and tear down one materialized declarative connector (ADR-0096). */ + private async dematerializeConnector( + engine: AutomationEngine, + name: string, + tracked: { close?: () => void | Promise }, + ctx: PluginContext, + ): Promise { + try { engine.unregisterConnector(name); } catch { /* ignore */ } + if (tracked.close) { + try { await tracked.close(); } catch { /* best-effort */ } + } + this.materializedConnectors.delete(name); + ctx.logger.info(`[Automation] unregistered declarative connector instance '${name}' (no longer declared)`); + } + /** * Resolve a declarative instance's `auth` into the static * {@link ResolvedConnectorAuth} a provider factory applies — dereferencing @@ -780,14 +865,15 @@ export class AutomationServicePlugin implements Plugin { // no socket / child process leaks. The engine (and its registry) is dropped // right after, so unregistering the connectors themselves is unnecessary. // Teardown failures never fail shutdown (swallowed). - for (const { close } of this.materializedConnectorClosers.reverse()) { + for (const [, tracked] of [...this.materializedConnectors].reverse()) { + if (!tracked.close) continue; try { - await close(); + await tracked.close(); } catch { /* best-effort */ } } - this.materializedConnectorClosers = []; + this.materializedConnectors.clear(); this.engine = undefined; } }