Skip to content

Commit 6b9c1ca

Browse files
fix(service-automation): an installed package's connectors materialize again after a restart (#28591) (#28639)
Fixes #28591 Clause-②: no Metadata-protocol: none ## 维护者速读 - 改了什么:automation 服务在 `kernel:bootstrapped` 再读一次连接器声明;只有当它与 `start()` 里那次启动 reconcile 读到的不同,才以非致命方式再 reconcile 一次。重启后,已安装包的连接器重新上线。 - 为什么改:重启后 install-local 在自己的 `kernel:ready` 里才重新注册已安装的包,晚于 automation 在 `start()` 里唯一一次 reconcile,且不广播 reload。包还在、流程还能绑定,但连接器从 `GET /api/v1/automation/connectors` 消失,调用它的流程在派发时失败。 - 风险与回滚:只有在 `start()` 之后又有插件注册了连接器时,启动才多一次 reconcile;普通启动不多跑。这次 reconcile 用重载语义:配置错的条目记 ERROR 并跳过,不让启动失败。回滚就是 revert 本 PR,行为退回到「重启后已安装包的连接器缺席」。 - 先看:`packages/services/service-automation/src/plugin.ts`(`reconcileConnectorsRegisteredDuringBoot` 和它的 `kernel:bootstrapped` 钩子,以及 `declaredInstanceFingerprint`);`packages/services/service-automation/src/connector-boot-registered-reconcile.test.ts`;`packages/qa/dogfood/test/install-local-restart-connectors.dogfood.test.ts`。 - 两分钟验证:`pnpm --filter @objectstack/dogfood exec vitest run --maxWorkers=2 test/install-local-restart-connectors.dogfood.test.ts`,预期 `Tests 5 passed (5)`;同一命令在 `main` 上是 1 failed(重启后连接器不在列表里)。 - 需要你做的:无,席位落地。 ## What changes **Measured first, at BASE `d94dd3a6`.** Two `bootShowcase` boots over one install-ledger directory. The first boot ran `POST /api/v1/marketplace/install-local` with an inline manifest that declares a provider-bound `openapi` connector with an INLINE document, and one run-by-name flow. Then the second boot read the listings. - `GET /automation/connectors` before the restart listed the connector `ready`. - After the restart, `GET /marketplace/install-local` listed the package, and `GET /automation/rstconn_manual_probe` answered 200, so **the package's flows survive a restart**. - The connector was absent: `["showcase_status_api","showcase_status_openapi","showcase_mcp_tools","rest","slack"]`. **How flows learn about rehydrated packages, and why connectors did not.** The boot log of the second boot shows the order: 1. `[Automation] materialized 3 provider-bound connector instance(s)`: the boot reconcile in `start()`. 2. `[MarketplaceInstallLocal] rehydrated com.dogfood.restartconn@0.1.0`: the install-local `kernel:ready` handler. 3. `[Automation] Bound 33 flow(s) from the protocol at kernel:ready`: 32 on the first boot. This is automation's own `kernel:ready` flow sync, and it reads the protocol's view after the rehydrate. Flows therefore learn by reading in a consumer boot hook that runs after the producer's. Connectors had no such read. They are reconciled only in `start()` and on `metadata:reloaded`, and the rehydrate announces no reload. **The fix takes the same road for connectors: a consumer-side boot read, region-local to `service-automation`.** - `AutomationServicePlugin.start()` registers a `kernel:bootstrapped` hook. The kernel fires that hook after every `kernel:ready` handler has settled, and its docs name it for "reconcile work that consumes data produced by a later-starting plugin's `kernel:ready` handler". So the hook does not depend on plugin order. The flow sync's `kernel:ready` read does depend on it. In the unit test the producer's `kernel:ready` handler runs after automation's, and the new hook still materializes the connector. - `reconcileConnectorsRegisteredDuringBoot` reads the registry declaration (`post: false`, no `sys_metadata` read). It compares that declaration with the `declaredInstanceFingerprint` of the declaration the last reconcile read: the sorted name and `connectorInstanceSignature` of each enabled, provider-bound entry. It runs a `fatal: false` reconcile only when the two differ. **A boot with no installs runs no second reconcile.** The unit control shows exactly one `reconcileDeclaredConnectors` call, `{ fatal: true }`. In the dogfood log, the first boot prints one `materialized … instance(s)` line. - **Reload semantics.** A configuration fault in a connector registered during `kernel:ready` is logged at `error` and that entry is skipped, as on install. The boot completes. A reconcile run that throws anyway is reported once at `warn`: a connector that is not live is a functional degradation. - Unchanged instances are left alone: the host connectors are not re-materialized on the second boot, because the signatures are unchanged. **Why not candidate (a), one `metadata:reloaded` announced by the rehydrate.** It would fire inside `kernel:ready`, ahead of handlers that have not run yet. It would also re-run every reload consumer on every boot with installs: ObjectQL's hook and action re-sync and schema sync, the flow re-sync, i18n, and security invalidation. Flows do not need that. The `announceHotInstall` docblock records the rehydrate's choice not to announce. Option (b) changes one consumer and leaves `marketplace-install-local-plugin.ts` untouched. - `content/docs/automation/connectors.mdx`, "Failure modes at a glance": one row. A bad entry of an installed package is logged and skipped at the install and at a restart. Before, the table read "hard boot error" for every configuration fault outside a runtime reload. - `.changeset/28591-rehydrated-package-connectors.md`: `@objectstack/service-automation` patch. No `packages/spec` change. ## Tests - `packages/qa/dogfood/test/install-local-restart-connectors.dogfood.test.ts` (new, permanent). It runs two boots over one ledger and asserts five things: the pre-restart precondition; the package listed after the restart and not marked `notLoaded`; the connector `ready` with its `GetThings` action after the restart; the flow served by the engine after the restart; and the host's `showcase_status_openapi` still listed (control). - `packages/services/service-automation/src/connector-boot-registered-reconcile.test.ts` (new, 4 cases): - an entry registered in a `kernel:ready` handler is listed after `bootstrap()` and dispatched end-to-end through a flow `connector_action`, and the host entry's factory runs once; - control: a boot that registers nothing after `start()` runs ONE reconcile; - such a boot runs `[{fatal:true},{fatal:false}]`; - an unknown-provider entry in the batch is logged at `error` and skipped, the boot completes, and its sibling materializes. Runs at head `84e3f0ab`, after the second merge of `origin/main`: <details><summary>Restart dogfood case: BASE red, fix green</summary> - BASE `d94dd3a6` (the fix not in the tree, service-automation built from BASE): `pnpm --filter @objectstack/dogfood exec vitest run --maxWorkers=2 test/install-local-restart-connectors.dogfood.test.ts`. Result: `Tests 1 failed | 4 passed (5)`. The failure was `after the restart, the package's connector is listed ready …` with `AssertionError: ["showcase_status_api","showcase_status_openapi","showcase_mcp_tools","rest","slack"]: expected undefined to be defined`. The flow and host-connector cases were green, so flows survive a restart. - Fix `63d2ffde`, then `84e3f0ab` after rebuilding: same command, `Tests 5 passed (5)`. On the second boot the log prints `rehydrated com.dogfood.restartconn@0.1.0`, then `Bound 33 flow(s) … at kernel:ready`, then `materialized connector instance 'rstconn_fixture' via provider 'openapi' (1 action(s))`, all before `Bootstrap complete`. The first boot (empty ledger) prints a single `materialized 3 provider-bound connector instance(s)` before `Bootstrap complete`: no second reconcile. </details> <details><summary>Unit suite, typecheck, and the one ablation</summary> - `pnpm --filter @objectstack/service-automation test` (the whole suite, not file-selected): `Test Files 251 passed (251)`, `Tests 3177 passed (3177)` at `84e3f0ab`. It was 251 and 3173 at `c5d3ce33`. - `pnpm --filter @objectstack/service-automation typecheck` (tsc plus `check:test-typecheck`): exit 0. `pnpm --filter @objectstack/dogfood typecheck`: exit 0. With `tsc --listFiles`, each program holds its new test file (1 hit each). - Ablation, once, at committed HEAD `bcbb7ad8` (plugin.ts blob `e344a65d`). `node scripts/ablation-replace.mjs` swapped the anchor `await this.reconcileConnectorsRegisteredDuringBoot(ctx);` for `void ctx; /* ABLATED_28591 */`. The anchor count went 1 to 0 and the blob went `e344a65d` to `05a767c8`. The unit file imports `./plugin.js` from source, so no build sits in the path. Result: `Tests 3 failed | 1 passed (4)`. All three cases that need the second reconcile went red, and the plain-boot control stayed green. Restored: blob equals HEAD `e344a65d` and `git diff HEAD` is empty. </details> ## Acceptance notes - "Actions dispatchable" is proven in the unit layer: a flow `connector_action` runs against the rehydrated-shape entry. The dogfood case asserts the listed `ready` state and the action key. Its document's server is `127.0.0.1:9`, so a real dispatch there would only measure an unreachable upstream. - Flows of an installed package survive a restart in the composition measured here, because the install-local `kernel:ready` handler ran before automation's. That order is not declared anywhere. The connector half now uses `kernel:bootstrapped` and does not depend on it. Whether `os serve`'s composition orders the two the same way was not measured. ## Verification - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`, with no paths: 5-path change set against merge base `a9c6eac6f`, 91 commands. Each was run at `84e3f0ab` with its exit code recorded before any pipe, and all 91 exited 0. `--ran`: `91 derived, 91 run, 0 NOT-MEASURED, 0 UNRUN`. - `check:skill-examples` and `check:dual-build-cjs-loads` read the dists of 7 packages outside this diff. They were built (turbo, 75/75 tasks) before the run. - `check-platform-checklist` is not owed: no export was renamed or deleted. `test:repo` is declared to CI: no repo-project test was touched. - Control-byte self-scan of the five changed files: `grep -naP` exit 1 (no match). --- _Generated by [Claude Code](https://claude.ai/code/session_017xRBcVBKAFk1ntXSjvPt7U)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 49d60d3 commit 6b9c1ca

5 files changed

Lines changed: 434 additions & 0 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@objectstack/service-automation': patch
3+
---
4+
5+
fix(service-automation): a connector an installed package declares is live again after the runtime restarts
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
After a restart, the install-local rehydrate registers every installed package again, but in its `kernel:ready` handler. That is after the automation service's boot reconcile in `start()`, and the rehydrate announces no reload. The package was listed by `GET /api/v1/marketplace/install-local` and its flows bound, but its provider-bound connector instances were absent from `GET /api/v1/automation/connectors` until some unrelated metadata reload. Every flow step that called them failed at dispatch.
11+
12+
The automation service now reads the connector declaration once more at `kernel:bootstrapped`, which fires after every `kernel:ready` handler has settled, whatever the plugin order. It reconciles only when that declaration differs from the one the boot reconcile read. A boot that registers no connector after `start()` runs no second reconcile. This reconcile uses reload semantics: a configuration fault in a connector registered during `kernel:ready` (an unknown provider, an invalid `providerConfig`) is logged at `error` and that entry is skipped. It does not fail the boot. This is the same treatment the entry gets when it is installed into a running server.

‎content/docs/automation/connectors.mdx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -874,6 +874,7 @@ retry):
874874
| A `connector_action` input value whose shape the parameter's style has no encoding for (an array of objects for `form`, an object for a `content`-typed parameter) | **The step fails before dispatch** naming the parameter, and nothing is sent |
875875
| A `connector_action` input that, once evaluated, breaks the action's `inputSchema` (a missing `required` parameter, a wrong `type`) | **The step fails before dispatch** with `VALIDATION_ERROR`, naming each parameter path, and nothing is sent; a `fault` edge or `try_catch` can route it. Supply the parameter or send the declared type: text is never converted, so cast it in a CEL envelope (`int()`, `double()`, `bool()`). `GET /api/v1/automation/connectors` lists the schema; `os validate` does not judge the input against the `inputSchema` yet |
876876
| A bad entry arrives via **runtime reload** (a Studio publish, a direct active save through `PUT /api/v1/meta/connector/:name`, or a delete, made on any replica — peers reconcile on [cluster lane 2](/docs/kernel/cluster#62-the-cluster-invalidation-family); or a dev reload) | Logged and **skipped**, never crashing the live server; a changed instance's old connector keeps serving until its replacement materializes |
877+
| A bad entry belongs to a package **installed into the runtime** (`POST /api/v1/marketplace/install-local`), at the install or at a restart that registers the package again | Logged and **skipped**, as on a runtime reload: the boot completes and the package's other instances materialize |
877878

878879
## Catalog descriptors: entries without a `provider`
879880

Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// GOLDEN REGRESSION — a connector an install-local package declares is live
4+
// again after the runtime restarts on the same install ledger.
5+
//
6+
// ## What was measured before the fix
7+
//
8+
// Two showcase boots over one install-ledger directory. The first boot
9+
// installed an inline manifest declaring a provider-bound `openapi` connector
10+
// whose `providerConfig.spec` is an INLINE document, plus one flow. The
11+
// connector was listed `ready` on `GET /automation/connectors`. After the
12+
// second boot, `GET /marketplace/install-local` listed the package and the
13+
// engine served its flow (`GET /automation/NAME` 200), but the connector was
14+
// absent from `GET /automation/connectors`.
15+
//
16+
// Flows survive because the engine binds them in its own `kernel:ready`
17+
// handler, from the protocol's flow view, and the install-local rehydrate
18+
// registers the ledger's packages in an earlier `kernel:ready` handler.
19+
// Declared connectors are reconciled only in `start()`, before
20+
// `kernel:ready`, and again on `metadata:reloaded` — and the rehydrate
21+
// announced no reload, so nothing read the rehydrated connector entries.
22+
//
23+
// ## What this file pins
24+
//
25+
// 1. before the restart (precondition): the install answers 200 and the
26+
// connector is listed `ready` with the document's one operation;
27+
// 2. after the restart: the package is listed by the install-local GET;
28+
// 3. after the restart: the connector is listed `ready` with the same
29+
// operation;
30+
// 4. after the restart: the package's flow is served by the engine;
31+
// 5. after the restart: a connector the HOST declares (the showcase's
32+
// `showcase_status_openapi`) is still listed (control).
33+
//
34+
// Boots fixture stacks of its own (the install-local plugin mounted), so it
35+
// stays out of `SHARED_SHOWCASE`.
36+
37+
import { mkdtempSync, rmSync } from 'node:fs';
38+
import { tmpdir } from 'node:os';
39+
import { join } from 'node:path';
40+
41+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
42+
import { type VerifyStack } from '@objectstack/verify';
43+
import { MarketplaceInstallLocalPlugin } from '@objectstack/cloud-connection';
44+
import { bootShowcase } from './showcase-boot.js';
45+
46+
const ROUTE = '/marketplace/install-local';
47+
const PACKAGE_ID = 'com.dogfood.restartconn';
48+
const CONNECTOR = 'rstconn_fixture';
49+
const FLOW = 'rstconn_manual_probe';
50+
const HOST_CONNECTOR = 'showcase_status_openapi';
51+
/** A one-operation OpenAPI 3 document; nothing is dispatched, so its server answers nothing. */
52+
const DOCUMENT = {
53+
openapi: '3.0.3',
54+
info: { title: 'Restart connector fixture', version: '1.0.0' },
55+
servers: [{ url: 'http://127.0.0.1:9/' }],
56+
paths: { '/v1/things': { get: { operationId: 'GetThings', responses: { '200': { description: 'ok' } } } } },
57+
};
58+
59+
const INSTALL_BODY = {
60+
manifest: {
61+
id: PACKAGE_ID,
62+
namespace: 'rstconn',
63+
name: 'Restart connector probe',
64+
version: '0.1.0',
65+
type: 'app',
66+
connectors: [
67+
{ name: CONNECTOR, label: 'Fixture', type: 'api', provider: 'openapi', providerConfig: { spec: DOCUMENT } },
68+
],
69+
flows: [
70+
{
71+
name: FLOW,
72+
label: 'Restart probe (run by name)',
73+
type: 'autolaunched',
74+
status: 'active',
75+
nodes: [
76+
{ id: 'start', type: 'start', label: 'Start' },
77+
{ id: 'end', type: 'end', label: 'End' },
78+
],
79+
edges: [{ id: 'e1', source: 'start', target: 'end' }],
80+
},
81+
],
82+
},
83+
};
84+
85+
type Answer = { status: number; body: any };
86+
87+
async function json(res: Response): Promise<Answer> {
88+
return { status: res.status, body: await res.json().catch(() => null) };
89+
}
90+
91+
const listed = (answer: Answer, name: string): any =>
92+
(answer.body?.data?.connectors ?? []).find((c: any) => c?.name === name);
93+
94+
const names = (answer: Answer): unknown => answer.body?.data?.connectors?.map((c: any) => c?.name);
95+
96+
/** Read the connector listing until `name` is in it, or the window closes. */
97+
async function readConnectors(stack: VerifyStack, token: string, name: string): Promise<Answer> {
98+
let answer: Answer;
99+
const deadline = Date.now() + 10_000;
100+
do {
101+
answer = await json(await stack.apiAs(token, 'GET', '/automation/connectors'));
102+
if (listed(answer, name)) break;
103+
await new Promise((r) => setTimeout(r, 100));
104+
} while (Date.now() < deadline);
105+
return answer;
106+
}
107+
108+
describe('dogfood: an install-local package\'s connector is live again after a restart on the same ledger', () => {
109+
let storageDir: string;
110+
let install: Answer;
111+
let before: Answer;
112+
let packages: Answer;
113+
let after: Answer;
114+
let flowAfter: Answer;
115+
let bootError: unknown;
116+
117+
beforeAll(async () => {
118+
storageDir = mkdtempSync(join(tmpdir(), 'dogfood-install-local-restart-conn-'));
119+
const first = await bootShowcase({
120+
extraPlugins: [new MarketplaceInstallLocalPlugin({ controlPlaneUrl: 'off', storageDir })],
121+
});
122+
try {
123+
const token = await first.signIn();
124+
install = await json(await first.apiAs(token, 'POST', ROUTE, INSTALL_BODY));
125+
before = await readConnectors(first, token, CONNECTOR);
126+
} finally {
127+
await first.stop();
128+
}
129+
130+
let second: VerifyStack | undefined;
131+
try {
132+
second = await bootShowcase({
133+
extraPlugins: [new MarketplaceInstallLocalPlugin({ controlPlaneUrl: 'off', storageDir })],
134+
});
135+
} catch (err) {
136+
bootError = err;
137+
return;
138+
}
139+
try {
140+
const token = await second.signIn();
141+
packages = await json(await second.apiAs(token, 'GET', ROUTE));
142+
after = await readConnectors(second, token, CONNECTOR);
143+
flowAfter = await json(await second.apiAs(token, 'GET', `/automation/${FLOW}`));
144+
} finally {
145+
await second.stop();
146+
}
147+
}, 300_000);
148+
149+
afterAll(() => {
150+
if (storageDir) rmSync(storageDir, { recursive: true, force: true });
151+
});
152+
153+
it('before the restart, the install lists the connector ready (precondition)', () => {
154+
expect(install.status, JSON.stringify(install.body)).toBe(200);
155+
expect(install.body?.data?.hotLoaded).toBe(true);
156+
const entry = listed(before, CONNECTOR);
157+
expect(entry, JSON.stringify(names(before))).toBeDefined();
158+
expect(entry.state).toBe('ready');
159+
expect(entry.actions.map((a: any) => a.key)).toEqual(['GetThings']);
160+
});
161+
162+
it('the second boot over the same ledger succeeds and lists the package', () => {
163+
expect(bootError).toBeUndefined();
164+
expect(packages.status, JSON.stringify(packages.body)).toBe(200);
165+
const item = (packages.body?.data?.items ?? []).find((i: any) => i?.manifestId === PACKAGE_ID);
166+
expect(item, JSON.stringify(packages.body)).toBeDefined();
167+
expect(item.notLoaded).toBeUndefined();
168+
});
169+
170+
it('after the restart, the package\'s connector is listed ready with the document\'s operation', () => {
171+
expect(after.status, JSON.stringify(after.body)).toBe(200);
172+
const entry = listed(after, CONNECTOR);
173+
expect(entry, JSON.stringify(names(after))).toBeDefined();
174+
expect(entry.state).toBe('ready');
175+
expect(entry.actions.map((a: any) => a.key)).toEqual(['GetThings']);
176+
});
177+
178+
it('after the restart, the engine serves the package\'s flow', () => {
179+
expect(flowAfter.status, JSON.stringify(flowAfter.body)).toBe(200);
180+
});
181+
182+
it('after the restart, a connector the host declares is still listed (control)', () => {
183+
expect(listed(after, HOST_CONNECTOR), JSON.stringify(names(after))).toBeDefined();
184+
});
185+
});
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// [#28591] A provider-bound connector entry a plugin registers during
4+
// `kernel:ready` — after the boot reconcile in `start()` — is materialized
5+
// before the boot completes.
6+
//
7+
// The install-local rehydrate is the producer this covers: it re-registers every
8+
// installed package in its own `kernel:ready` handler and announces no reload.
9+
// Before the fix, the boot reconcile in `start()` was the only one a boot ran,
10+
// so a rehydrated package's connector stayed absent from `GET /connectors`
11+
// until some unrelated `metadata:reloaded`.
12+
//
13+
// Pinned here:
14+
// 1. an entry registered during `kernel:ready` is listed and dispatchable once
15+
// `bootstrap()` resolves, and the host entry is not materialized again;
16+
// 2. a boot that registers nothing after `start()` runs ONE reconcile (control);
17+
// 3. a configuration fault in an entry registered during `kernel:ready` is
18+
// logged and that entry skipped — the boot completes and the rest of the
19+
// batch is materialized (reload semantics, as on install).
20+
21+
import { describe, it, expect, vi, afterEach } from 'vitest';
22+
import { LiteKernel, ObjectLogger } from '@objectstack/core';
23+
import type { Connector, ConnectorProviderContext, ConnectorProviderFactory } from '@objectstack/spec/integration';
24+
import { AutomationServicePlugin } from './plugin.js';
25+
import type { AutomationEngine } from './engine.js';
26+
27+
afterEach(() => {
28+
vi.restoreAllMocks();
29+
});
30+
31+
/** A provider-bound declarative entry, as `registerApp` stores it. */
32+
function providerConnector(name: string, provider = 'fake') {
33+
return { name, label: name, type: 'api', provider, providerConfig: {} };
34+
}
35+
36+
/** A factory that records each materialization and returns a one-action connector. */
37+
function makeProvider() {
38+
const calls: ConnectorProviderContext[] = [];
39+
const factory: ConnectorProviderFactory = (ctx) => {
40+
calls.push(ctx);
41+
return {
42+
def: {
43+
name: ctx.name,
44+
label: ctx.label,
45+
type: 'api',
46+
authentication: { type: 'none' },
47+
actions: [{ key: 'ping', label: 'Ping' }],
48+
} as unknown as Connector,
49+
handlers: { ping: async () => ({ ok: true }) },
50+
};
51+
};
52+
return { factory, calls };
53+
}
54+
55+
/**
56+
* Boot the automation plugin over a registry holding `atStart`, with a harness
57+
* that appends `atReady` to the registry in its `kernel:ready` handler — the
58+
* shape of the install-local rehydrate.
59+
*/
60+
async function boot(atStart: unknown[], atReady: unknown[], factory: ConnectorProviderFactory) {
61+
const declared = [...atStart];
62+
const kernel = new LiteKernel({ logger: { level: 'silent' } } as never);
63+
kernel.use(new AutomationServicePlugin());
64+
kernel.use({
65+
name: 'test.rehydrate-harness',
66+
type: 'standard' as const,
67+
version: '1.0.0',
68+
dependencies: ['com.objectstack.service-automation'],
69+
async init(ctx: any) {
70+
ctx.registerService('objectql', {
71+
registry: { listItems: (type: string) => (type === 'connector' ? declared : []) },
72+
});
73+
ctx.getService('automation').registerConnectorProvider('fake', factory);
74+
},
75+
async start(ctx: any) {
76+
ctx.hook('kernel:ready', async () => {
77+
declared.push(...atReady);
78+
});
79+
},
80+
} as never);
81+
await kernel.bootstrap();
82+
return { kernel, engine: kernel.getService('automation') as AutomationEngine };
83+
}
84+
85+
const reconcileSpy = () =>
86+
vi.spyOn(AutomationServicePlugin.prototype as any, 'reconcileDeclaredConnectors');
87+
88+
describe('[#28591] connectors registered during kernel:ready are materialized before the boot completes', () => {
89+
it('lists and dispatches an entry registered during kernel:ready, without re-materializing the host entry', async () => {
90+
const { factory, calls } = makeProvider();
91+
const { kernel, engine } = await boot([providerConnector('host_conn')], [providerConnector('installed_conn')], factory);
92+
93+
expect(engine.getRegisteredConnectors()).toEqual(expect.arrayContaining(['host_conn', 'installed_conn']));
94+
expect(engine.getConnectorOrigin('installed_conn')).toBe('declarative');
95+
expect(calls.map((c) => c.name)).toEqual(['host_conn', 'installed_conn']);
96+
97+
engine.registerFlow('call_installed', {
98+
name: 'call_installed',
99+
label: 'Call installed',
100+
type: 'autolaunched',
101+
variables: [{ name: 'call.ok', type: 'boolean', isOutput: true }],
102+
nodes: [
103+
{ id: 'start', type: 'start', label: 'Start' },
104+
{
105+
id: 'call',
106+
type: 'connector_action',
107+
label: 'Ping',
108+
connectorConfig: { connectorId: 'installed_conn', actionId: 'ping', input: {} },
109+
},
110+
{ id: 'end', type: 'end', label: 'End' },
111+
],
112+
edges: [
113+
{ id: 'e1', source: 'start', target: 'call' },
114+
{ id: 'e2', source: 'call', target: 'end' },
115+
],
116+
} as never);
117+
const result = await engine.execute('call_installed');
118+
expect(result.success).toBe(true);
119+
expect(result.output).toMatchObject({ 'call.ok': true });
120+
121+
await kernel.shutdown();
122+
});
123+
124+
it('runs one reconcile on a boot that registers nothing after start() (control)', async () => {
125+
const spy = reconcileSpy();
126+
const { factory, calls } = makeProvider();
127+
const { kernel, engine } = await boot([providerConnector('host_conn')], [], factory);
128+
129+
expect(engine.getRegisteredConnectors()).toContain('host_conn');
130+
expect(calls).toHaveLength(1);
131+
expect(spy).toHaveBeenCalledTimes(1);
132+
expect(spy.mock.calls[0]?.[1]).toEqual({ fatal: true });
133+
134+
await kernel.shutdown();
135+
});
136+
137+
it('runs a second, non-fatal reconcile when kernel:ready registered an entry', async () => {
138+
const spy = reconcileSpy();
139+
const { factory } = makeProvider();
140+
const { kernel } = await boot([providerConnector('host_conn')], [providerConnector('installed_conn')], factory);
141+
142+
expect(spy.mock.calls.map((c) => c[1])).toEqual([{ fatal: true }, { fatal: false }]);
143+
144+
await kernel.shutdown();
145+
});
146+
147+
it('logs and skips a configuration fault in an entry registered during kernel:ready; the boot completes', async () => {
148+
const errors = vi.spyOn(ObjectLogger.prototype, 'error');
149+
const { factory } = makeProvider();
150+
const { kernel, engine } = await boot(
151+
[providerConnector('host_conn')],
152+
[providerConnector('broken_conn', 'no_such_provider'), providerConnector('installed_conn')],
153+
factory,
154+
);
155+
156+
const registered = engine.getRegisteredConnectors();
157+
expect(registered).toEqual(expect.arrayContaining(['host_conn', 'installed_conn']));
158+
expect(registered).not.toContain('broken_conn');
159+
const messages = errors.mock.calls.map((c) => String(c[0]));
160+
expect(messages.some((m) => m.includes('broken_conn') && m.includes('no_such_provider')), messages.join('\n')).toBe(true);
161+
162+
await kernel.shutdown();
163+
});
164+
});

0 commit comments

Comments
 (0)