Skip to content

Commit 468b068

Browse files
committed
feat(automation): ADR-0096 runtime re-materialization of declarative connectors (#2977)
Provider-bound declarative `connectors:` instances materialized only at boot, so a connector published from Studio (or a dev reload) did not become dispatchable until a restart. `materializeDeclaredConnectors` is now a reconcile against the declared set, run at boot (fatal) and on `metadata:reloaded` (soft): - Adds newly-declared instances, tears down removed / newly-`enabled:false` ones (calling their `close`, e.g. an MCP connection), and re-materializes only those whose signature — a stable hash of provider + providerConfig + auth + identity — changed. An unchanged instance is left untouched (no needless MCP reconnect on an unrelated metadata reload). - Boot keeps the "fail loudly" contract (throws → fatal bootstrap). Reload is soft: unknown provider / unresolvable credentialRef / factory failure / name conflict is logged and the entry skipped, never crashing the live server; a changed instance's old connector keeps serving until its replacement materializes successfully. Tracking (`materializedConnectorClosers` array → `materializedConnectors` Map) carries the signature + close per name. 6 new reload tests (add/remove/disable/ unchanged/changed/soft-failure); service-automation suite green (285 tests). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Pbmw3pMqNJfPbkvwh9Fkjs
1 parent 96a14d0 commit 468b068

4 files changed

Lines changed: 340 additions & 76 deletions

File tree

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
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.

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: 143 additions & 0 deletions
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();
@@ -282,3 +346,82 @@ describe('ADR-0096 — declarative connector materialization', () => {
282346
).rejects.toThrow(/duplicate declarative connector instance name 'dup'/);
283347
});
284348
});
349+
350+
describe('ADR-0096 — runtime re-materialization on metadata:reloaded (F1)', () => {
351+
it('materializes an instance published after boot', async () => {
352+
const { factory, calls } = makeClosableProvider();
353+
const { engine, reload, kernel } = await bootReloadable([], { providerFactory: factory });
354+
expect(engine.getRegisteredConnectors()).not.toContain('billing');
355+
356+
await reload([providerConnector('billing')]);
357+
expect(engine.getRegisteredConnectors()).toContain('billing');
358+
expect(engine.getConnectorOrigin('billing')).toBe('declarative');
359+
expect(calls).toHaveLength(1);
360+
await kernel.shutdown();
361+
});
362+
363+
it('unregisters and tears down an instance removed after boot', async () => {
364+
const { factory, closed } = makeClosableProvider();
365+
const { engine, reload, kernel } = await bootReloadable([providerConnector('billing')], { providerFactory: factory });
366+
expect(engine.getRegisteredConnectors()).toContain('billing');
367+
368+
await reload([]); // billing deleted from the stack
369+
expect(engine.getRegisteredConnectors()).not.toContain('billing');
370+
expect(closed).toEqual(['billing']); // close() ran on teardown
371+
await kernel.shutdown();
372+
});
373+
374+
it('tears down an instance newly marked enabled:false', async () => {
375+
const { factory, closed } = makeClosableProvider();
376+
const { engine, reload, kernel } = await bootReloadable([providerConnector('billing')], { providerFactory: factory });
377+
await reload([providerConnector('billing', { enabled: false })]);
378+
expect(engine.getRegisteredConnectors()).not.toContain('billing');
379+
expect(closed).toEqual(['billing']);
380+
await kernel.shutdown();
381+
});
382+
383+
it('leaves an UNCHANGED instance untouched (no reconnect on unrelated reload)', async () => {
384+
const { factory, calls, closed } = makeClosableProvider();
385+
const { reload, kernel } = await bootReloadable(
386+
[providerConnector('billing', { providerConfig: { baseUrl: 'https://x' } })],
387+
{ providerFactory: factory },
388+
);
389+
expect(calls).toHaveLength(1);
390+
391+
// A reload that does not change billing's inputs must not re-invoke the factory.
392+
await reload([providerConnector('billing', { providerConfig: { baseUrl: 'https://x' } })]);
393+
expect(calls).toHaveLength(1);
394+
expect(closed).toEqual([]);
395+
await kernel.shutdown();
396+
});
397+
398+
it('re-materializes a CHANGED instance and tears down the old connection', async () => {
399+
const { factory, calls, closed } = makeClosableProvider();
400+
const { engine, reload, kernel } = await bootReloadable(
401+
[providerConnector('billing', { providerConfig: { baseUrl: 'https://old' } })],
402+
{ providerFactory: factory },
403+
);
404+
expect(calls).toHaveLength(1);
405+
406+
await reload([providerConnector('billing', { providerConfig: { baseUrl: 'https://new' } })]);
407+
expect(calls).toHaveLength(2); // re-materialized
408+
expect(closed).toEqual(['billing']); // old connection torn down
409+
expect(engine.getRegisteredConnectors()).toContain('billing');
410+
expect((calls[1].providerConfig as { baseUrl?: string }).baseUrl).toBe('https://new');
411+
await kernel.shutdown();
412+
});
413+
414+
it('reload is soft: an unknown-provider entry is skipped, not fatal, and other connectors survive', async () => {
415+
const { factory } = makeClosableProvider();
416+
const { engine, reload, kernel } = await bootReloadable([providerConnector('billing')], { providerFactory: factory });
417+
418+
// A bad publish must NOT crash the running server (no throw), and the
419+
// healthy connector keeps serving.
420+
await expect(
421+
reload([providerConnector('billing'), providerConnector('ghost', { provider: 'nonexistent' })]),
422+
).resolves.toBeUndefined();
423+
expect(engine.getRegisteredConnectors()).toContain('billing');
424+
expect(engine.getRegisteredConnectors()).not.toContain('ghost');
425+
await kernel.shutdown();
426+
});
427+
});

0 commit comments

Comments
 (0)