Skip to content

Commit 93fd58e

Browse files
authored
feat(automation): ADR-0096 runtime re-materialization + connector origin (#3001)
Provider-bound declarative connectors: entries now reconcile at boot and on metadata:reloaded (add / tear down / re-materialize by signature). ConnectorDescriptor carries origin ('plugin' | 'declarative'). Follow-up to ADR-0096 (#2977).
1 parent 084c618 commit 93fd58e

5 files changed

Lines changed: 358 additions & 78 deletions

File tree

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
---
2+
'@objectstack/service-automation': minor
3+
---
4+
5+
feat(connectors): ADR-0096 runtime re-materialization of declarative connectors (#2977 follow-up)
6+
7+
Provider-bound declarative `connectors:` instances (ADR-0096) previously
8+
materialized only at boot — a connector published from Studio while the server
9+
ran did not become dispatchable until a restart. `materializeDeclaredConnectors`
10+
is now a **reconcile** run both at boot and on `metadata:reloaded`:
11+
12+
- **Add** newly-declared instances, **tear down** removed / newly-`enabled:false`
13+
ones (calling their `close`, e.g. an MCP connection), and **re-materialize**
14+
only instances whose signature — a stable hash of `provider` + `providerConfig`
15+
+ `auth` + identity — changed. An unchanged MCP instance is never needlessly
16+
reconnected on an unrelated metadata reload.
17+
- **Boot stays fatal** ("fail loudly"): unknown provider / invalid providerConfig
18+
/ unresolvable credentialRef / name conflict aborts startup. **Reload is soft**:
19+
the same problems are logged and the offending entry skipped, so a bad publish
20+
never crashes a running server; a changed instance's old connector keeps
21+
serving until its replacement materializes successfully.
22+
23+
Also: `ConnectorDescriptor` (served by `GET /api/v1/automation/connectors`) now
24+
carries an `origin` field (`'plugin' | 'declarative'`), so a designer can
25+
distinguish a materialized declarative instance from a plugin-registered
26+
connector.

docs/adr/0096-declarative-connector-instances.md

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,8 +118,22 @@ If a provider-bound instance and a plugin-registered connector share a `name`, b
118118
- **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.
119119
- **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`.
120120

121+
### Runtime reconcile (follow-up, landed)
122+
123+
Materialization is no longer boot-only. `materializeDeclaredConnectors` is a
124+
**reconcile** against the declared set, run both at boot (`fatal: true`) and on
125+
`metadata:reloaded` (`fatal: false` — a Studio publish / dev reload). The reconcile
126+
adds newly-declared instances, tears down removed / newly-`enabled:false` ones
127+
(calling their `close`), and re-materializes only those whose signature (a stable
128+
hash of `provider` + `providerConfig` + `auth` + identity) changed — an unchanged
129+
MCP instance is never needlessly reconnected. On reload a bad entry (unknown
130+
provider, unresolvable credentialRef, factory failure, name conflict) is **logged
131+
and skipped**, never crashing the live server; a changed instance's old connector
132+
keeps serving until its replacement materializes successfully. Boot keeps the
133+
"fail loudly" contract.
134+
121135
### Deliberate scope boundaries
122136

123-
- **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`.
124137
- **`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.
125138
- **MCP credentials** ride the transport (ADR-0024); for an http transport a resolved `auth` is folded into the request headers.
139+
- **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).

packages/services/service-automation/src/connector-materialization.test.ts

Lines changed: 146 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,70 @@ function automationOf(kernel: LiteKernel): AutomationEngine {
127127
return kernel.getService('automation') as AutomationEngine;
128128
}
129129

130+
/**
131+
* A fake provider whose materialized connectors carry a `close`, so reload tests
132+
* can assert teardown. Records the ctx of every invocation (`calls`) and the
133+
* names whose `close()` ran (`closed`).
134+
*/
135+
function makeClosableProvider() {
136+
const calls: ConnectorProviderContext[] = [];
137+
const closed: string[] = [];
138+
const factory: ConnectorProviderFactory = (ctx) => {
139+
calls.push(ctx);
140+
return {
141+
def: {
142+
name: ctx.name,
143+
label: ctx.label,
144+
type: 'api',
145+
authentication: { type: 'none' },
146+
actions: [{ key: 'ping', label: 'Ping' }],
147+
},
148+
handlers: { ping: async () => ({ ok: true }) },
149+
close: async () => { closed.push(ctx.name); },
150+
};
151+
};
152+
return { factory, calls, closed };
153+
}
154+
155+
/**
156+
* Boot with a MUTABLE declared set and expose a `reload(next)` that swaps the
157+
* registry contents and fires `metadata:reloaded` — the runtime reconcile path
158+
* (ADR-0096 F1). The harness's ctx is the shared kernel context, so
159+
* `ctx.trigger('metadata:reloaded')` invokes the automation plugin's hook.
160+
*/
161+
async function bootReloadable(
162+
initial: unknown[],
163+
opts: { providerFactory: ConnectorProviderFactory; credentialResolver?: CredentialResolver },
164+
) {
165+
const state = { declared: initial };
166+
let captured: any;
167+
const kernel = new LiteKernel({ logger: { level: 'silent' } } as never);
168+
kernel.use(new AutomationServicePlugin({ credentialResolver: opts.credentialResolver }));
169+
const harness = {
170+
name: 'test.harness',
171+
type: 'standard' as const,
172+
version: '1.0.0',
173+
dependencies: ['com.objectstack.service-automation'],
174+
async init(ctx: any) {
175+
captured = ctx;
176+
ctx.registerService('objectql', {
177+
registry: { listItems: (type: string) => (type === 'connector' ? state.declared : []) },
178+
});
179+
ctx.getService('automation').registerConnectorProvider('fake', opts.providerFactory);
180+
},
181+
async start() {},
182+
};
183+
kernel.use(harness as never);
184+
await kernel.bootstrap();
185+
await flush();
186+
const reload = async (next: unknown[]) => {
187+
state.declared = next;
188+
await captured.trigger('metadata:reloaded');
189+
await flush();
190+
};
191+
return { kernel, engine: automationOf(kernel), reload };
192+
}
193+
130194
describe('ADR-0096 — declarative connector materialization', () => {
131195
it('materializes a provider-bound instance into a live, listed connector', async () => {
132196
const { factory, calls } = makeFakeProvider();
@@ -139,7 +203,9 @@ describe('ADR-0096 — declarative connector materialization', () => {
139203
const desc = engine.getConnectorDescriptors().find((d) => d.name === 'billing');
140204
expect(desc).toBeDefined();
141205
expect(desc?.actions.map((a) => a.key)).toEqual(['ping']);
142-
// Origin is 'declarative', not 'plugin'.
206+
// Origin is 'declarative', not 'plugin' — surfaced on the descriptor so a
207+
// designer can distinguish a materialized instance from a plugin connector.
208+
expect(desc?.origin).toBe('declarative');
143209
expect(engine.getConnectorOrigin('billing')).toBe('declarative');
144210
// The factory saw the declared identity.
145211
expect(calls[0]?.name).toBe('billing');
@@ -282,3 +348,82 @@ describe('ADR-0096 — declarative connector materialization', () => {
282348
).rejects.toThrow(/duplicate declarative connector instance name 'dup'/);
283349
});
284350
});
351+
352+
describe('ADR-0096 — runtime re-materialization on metadata:reloaded (F1)', () => {
353+
it('materializes an instance published after boot', async () => {
354+
const { factory, calls } = makeClosableProvider();
355+
const { engine, reload, kernel } = await bootReloadable([], { providerFactory: factory });
356+
expect(engine.getRegisteredConnectors()).not.toContain('billing');
357+
358+
await reload([providerConnector('billing')]);
359+
expect(engine.getRegisteredConnectors()).toContain('billing');
360+
expect(engine.getConnectorOrigin('billing')).toBe('declarative');
361+
expect(calls).toHaveLength(1);
362+
await kernel.shutdown();
363+
});
364+
365+
it('unregisters and tears down an instance removed after boot', async () => {
366+
const { factory, closed } = makeClosableProvider();
367+
const { engine, reload, kernel } = await bootReloadable([providerConnector('billing')], { providerFactory: factory });
368+
expect(engine.getRegisteredConnectors()).toContain('billing');
369+
370+
await reload([]); // billing deleted from the stack
371+
expect(engine.getRegisteredConnectors()).not.toContain('billing');
372+
expect(closed).toEqual(['billing']); // close() ran on teardown
373+
await kernel.shutdown();
374+
});
375+
376+
it('tears down an instance newly marked enabled:false', async () => {
377+
const { factory, closed } = makeClosableProvider();
378+
const { engine, reload, kernel } = await bootReloadable([providerConnector('billing')], { providerFactory: factory });
379+
await reload([providerConnector('billing', { enabled: false })]);
380+
expect(engine.getRegisteredConnectors()).not.toContain('billing');
381+
expect(closed).toEqual(['billing']);
382+
await kernel.shutdown();
383+
});
384+
385+
it('leaves an UNCHANGED instance untouched (no reconnect on unrelated reload)', async () => {
386+
const { factory, calls, closed } = makeClosableProvider();
387+
const { reload, kernel } = await bootReloadable(
388+
[providerConnector('billing', { providerConfig: { baseUrl: 'https://x' } })],
389+
{ providerFactory: factory },
390+
);
391+
expect(calls).toHaveLength(1);
392+
393+
// A reload that does not change billing's inputs must not re-invoke the factory.
394+
await reload([providerConnector('billing', { providerConfig: { baseUrl: 'https://x' } })]);
395+
expect(calls).toHaveLength(1);
396+
expect(closed).toEqual([]);
397+
await kernel.shutdown();
398+
});
399+
400+
it('re-materializes a CHANGED instance and tears down the old connection', async () => {
401+
const { factory, calls, closed } = makeClosableProvider();
402+
const { engine, reload, kernel } = await bootReloadable(
403+
[providerConnector('billing', { providerConfig: { baseUrl: 'https://old' } })],
404+
{ providerFactory: factory },
405+
);
406+
expect(calls).toHaveLength(1);
407+
408+
await reload([providerConnector('billing', { providerConfig: { baseUrl: 'https://new' } })]);
409+
expect(calls).toHaveLength(2); // re-materialized
410+
expect(closed).toEqual(['billing']); // old connection torn down
411+
expect(engine.getRegisteredConnectors()).toContain('billing');
412+
expect((calls[1].providerConfig as { baseUrl?: string }).baseUrl).toBe('https://new');
413+
await kernel.shutdown();
414+
});
415+
416+
it('reload is soft: an unknown-provider entry is skipped, not fatal, and other connectors survive', async () => {
417+
const { factory } = makeClosableProvider();
418+
const { engine, reload, kernel } = await bootReloadable([providerConnector('billing')], { providerFactory: factory });
419+
420+
// A bad publish must NOT crash the running server (no throw), and the
421+
// healthy connector keeps serving.
422+
await expect(
423+
reload([providerConnector('billing'), providerConnector('ghost', { provider: 'nonexistent' })]),
424+
).resolves.toBeUndefined();
425+
expect(engine.getRegisteredConnectors()).toContain('billing');
426+
expect(engine.getRegisteredConnectors()).not.toContain('ghost');
427+
await kernel.shutdown();
428+
});
429+
});

packages/services/service-automation/src/engine.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,14 @@ export interface ConnectorDescriptor {
246246
readonly description?: string;
247247
readonly icon?: string;
248248
readonly actions: ConnectorActionDescriptor[];
249+
/**
250+
* How the connector reached the registry (ADR-0096 §4): `plugin` — registered
251+
* by a connector plugin via `registerConnector`; `declarative` — materialized
252+
* from a provider-bound `connectors:` stack entry at boot. Lets a designer
253+
* distinguish a live declarative instance from a plugin connector (and both
254+
* from an inert catalog descriptor, which never reaches this list).
255+
*/
256+
readonly origin: ConnectorOrigin;
249257
}
250258

251259
// ─── Core Automation Engine ─────────────────────────────────────────
@@ -926,12 +934,13 @@ export class AutomationEngine implements IAutomationService {
926934
* Handlers are omitted — they are runtime code, not metadata.
927935
*/
928936
getConnectorDescriptors(): ConnectorDescriptor[] {
929-
return [...this.connectors.values()].map(({ def }) => ({
937+
return [...this.connectors.values()].map(({ def, origin }) => ({
930938
name: def.name,
931939
label: def.label,
932940
type: def.type,
933941
description: def.description,
934942
icon: def.icon,
943+
origin,
935944
actions: (def.actions ?? []).map((a) => ({
936945
key: a.key,
937946
label: a.label,

0 commit comments

Comments
 (0)