Skip to content

Commit 1c5b177

Browse files
fix(service-automation): a stored connector's configuration fault never stops the platform starting (#28729)
<!-- claude-projects-attribution: {"github_login":"os-project-manager"} --> _Requested by **Leehom** · [project thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1XPL1bcTnVyoZPzisVsEVors)_ Fixes #28698 Clause-②: no — no accepted value and no public surface changes: the same stored definitions are refused as before; a start now carries on past them instead of stopping. Metadata-protocol: none ## 维护者速读 - 改了什么:环境里保存的连接器(元数据门 / Studio 保存的,环境级或 authored 包内)若配置后来失效,平台启动时只记录并跳过它,与运行中遇到同样问题时的处理完全一致;宿主自己声明的连接器和已安装包的连接器出错仍然阻止启动。 - 为什么改:一条存储的连接器定义失效(例如提供它 provider 的插件被移除)会让整个环境无法再启动,而运行时同样的问题只跳过该连接器;这是可用性问题,不是数据问题。 - 风险与回滚:只改 `plugin.ts` 启动连接器协调中的一个判断:错误归属于存储条目时走已有的软路径(同一条日志记录,无新记录形状)。回滚即还原该提交;宿主条目的失败语义未变,有对照测试钉住。 - 先看:`packages/services/service-automation/src/plugin.ts`(`fail` 的 `stored` 参数及各调用点)、`src/connector-materialization.test.ts` 新的 #28698 一节、`packages/qa/dogfood/test/stored-connector-package-ref-restart.dogfood.test.ts`。 - 两分钟验证:`pnpm --filter @objectstack/service-automation exec vitest run src/connector-materialization.test.ts`,预期 102/102 通过(含 5 类故障各一对「存储条目跳过 / 宿主条目失败」)。 - 需要你做的:无,席位落地。 ## What changes - **Stored connectors no longer stop a start.** A provider-bound connector saved in the environment that later stops being valid is now handled at start exactly as it is while the platform runs: the same log record, that connector not served, the host's own connectors beside it serving. This covers every configuration fault the start meets on a stored connector: a provider no installed plugin offers, a host credential resolver that throws, a retry policy its schema refuses, a name a plugin connector already holds, a configuration its provider refuses, and a duplicate of a name the host declares. PR #28671 made one of these (a refused package-relative ref) behave this way; this PR widens that same path to the rest. There is no second skip path and no new record. - **Host-declared and installed entries keep failing loudly.** A connector the host's stack declares, and an installed package's (#28534's `installed` / `unrecorded` readings), still stop the start on a configuration fault. Which entries count as stored is the classifier PR #28671 introduced, unchanged. - **Code.** In `reconcileDeclaredConnectors`, the reconcile's `fail` reporter takes a third argument, `stored`; at start it throws only when that is false. Each fault site passes the entry's reading. The per-entry flag PR #28671 used for the ref refusal is removed, because the ref refusal is now one stored fault among the others and reaches the same `fail` call. The package readers are read once at the top of the run (they were read a few lines later before), so the duplicate check can use them. - **Out of scope.** A stored row that takes a name a plugin connector registers later in the start is a separate card. - Changeset: `patch` for `@objectstack/service-automation`. ## Tests - **Restart (dogfood).** `stored-connector-package-ref-restart.dogfood.test.ts` now also carries a stored connector whose provider no installed plugin offers, beside the existing refused-ref rows and the inline control. After a restart the platform starts, the row is not listed and the start's record names it, and the inline control and the showcase's own connectors list `ready`. Red on base `0be4c8a5` (6 failed, 1 passed), green at head (7/7). - **Unit (`connector-materialization.test.ts`, new `#28698` section).** For each of five faults (unknown provider, invalid `retryConfig`, throwing credential resolver, plugin-held name, provider-refused config): a stored entry lets the start finish, is skipped with its record, and the host's entry beside it serves; and a control where the host's own entry with the same fault still fails the start. Plus a stored duplicate of the host's name (skipped, host serves), two host entries of one name (fails), and an installed package's entry with an unknown provider (fails, #28534 unchanged). The #28594 control "an unknown provider still fails the boot" now uses a host entry filed as declared, since a stored one with that fault is exactly what this PR makes soft. <details><summary>Ablations (each restored, blob equals HEAD, marker absent from dist after restore)</summary> - One fault site reverted to fatal (the unknown-provider report): unit `1 failed | 101 passed` (the stored unknown-provider case); restart dogfood `6 failed | 1 passed`. - The whole widening reverted (`fail` throws at start for every entry): unit `12 failed | 90 passed` (the five stored faults, the stored duplicate, and the six #28594 stored-ref cases); restart dogfood `6 failed | 1 passed`. Every host control stays green under both. </details> ## Acceptance notes - Which of these faults a stored row can actually carry was measured on the showcase before the change; the readings are with the seat. A retry policy the schema refuses is refused at save (`422 INVALID_METADATA`), so it is covered by the unit test only. A name saved twice (environment-wide and in an authored package) lists once and does not reach the duplicate check from stored rows; the duplicate case is unit-covered. The showcase has no credential resolver that throws; that case is unit-covered. - `check:dual-build-cjs-loads` answered exit 3 (prerequisite: a full build) and is NOT MEASURED locally; CI is the record. - Overlap: open PR #28693 touches `plugin.ts` with a comment around the flow contenders, far from this hunk. ## Verification At head `f76068ce`: <details><summary>Package and file runs</summary> - `@objectstack/service-automation`: `typecheck` exit 0; `test`: Test Files 251 passed (251), Tests 3210 passed (3210). - `connector-materialization.test.ts`: Tests 102 passed (102). - dogfood `stored-connector-package-ref-restart` + `stored-connector-package-ref`: Test Files 2 passed (2), Tests 14 passed (14), after rebuilding `@objectstack/service-automation`. </details> <details><summary>Derived gates</summary> `dispatch-gates --ran`: 65 derived, 64 run, 1 NOT-MEASURED (`check:dual-build-cjs-loads`, exit 3), 0 unrun. Every other family exit 0. </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01FEkQ5oQNecNsthbZA1bZTp)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent 256c86b commit 1c5b177

4 files changed

Lines changed: 220 additions & 56 deletions

File tree

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
"@objectstack/service-automation": patch
3+
---
4+
5+
fix(service-automation): a stored connector with a configuration fault never stops the platform starting
6+
7+
A provider-bound connector saved in the environment (through the metadata door or Studio, environment-wide or in an `authored` package) that later stops being valid, for example because the plugin offering its provider was removed, stopped the next start of the platform. The same fault met while the platform runs only skipped that connector.
8+
9+
Clause-②: no — no accepted value and no public surface changes: the same stored definitions are refused as before; a start now carries on past them instead of stopping.
10+
11+
Metadata-protocol: none
12+
13+
- **Before.** At start, any configuration fault of a stored connector (a provider no installed plugin offers, a host credential resolver that throws, a name a plugin connector already holds, a configuration its provider refuses) stopped the start. Only a refused package-relative ref was skipped.
14+
- **Now.** Every configuration fault of a stored connector takes the path it takes while the platform runs: it is logged with the same record, that connector is not served, and the host's own connectors beside it serve. A connector the host's own stack declares, and an installed package's, still stop the start on a configuration fault, as before.
15+
- **Not covered here.** A stored connector that takes a name a plugin connector registers later in the start is a separate card.

‎packages/qa/dogfood/test/stored-connector-package-ref-restart.dogfood.test.ts‎

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
22
//
33
// GOLDEN REGRESSION — a stored connector entry whose package-relative ref is
4-
// refused (#28594) never stops the platform from booting.
4+
// refused (#28594), or that carries any other configuration fault (#28698),
5+
// never stops the platform from booting.
56
//
67
// ## Why a restart
78
//
@@ -20,11 +21,14 @@
2021
// metadata door, an environment-wide `openapi` entry naming a
2122
// package-relative file, the same entry in an `authored` package, an
2223
// environment-wide stdio `mcp` entry, and a control carrying its document
23-
// inline. The second boot:
24+
// inline. [#28698] It also saves an environment-wide entry naming a provider
25+
// no installed plugin offers (accepted at save, skipped after boot: the shape
26+
// a row takes when the plugin that offered its provider is removed). The
27+
// second boot:
2428
//
2529
// 1. starts (no throw);
26-
// 2. does not list either refused entry, and its boot record names each
27-
// entry and its ref;
30+
// 2. does not list any refused entry, and its boot record names each entry
31+
// and its ref (or, for the unknown provider, the provider);
2832
// 3. lists the stored inline control `ready` with its operation (the rows
2933
// were hydrated, so the refusal above was read at boot, not skipped);
3034
// 4. lists the showcase's own file-ref and stdio entries `ready` (control).
@@ -47,6 +51,8 @@ const AUTHORED_FILE = 'srr_authored_file';
4751
/** An `authored` package created through the package door. */
4852
const AUTHORED_PACKAGE_ID = 'com.dogfood.storedrestart';
4953
const INLINE = 'srr_inline';
54+
/** [#28698] A stored entry whose provider no installed plugin offers. */
55+
const UNKNOWN_PROVIDER = 'srr_unknown_provider';
5056
const HOST_FILE_ENTRY = 'showcase_status_openapi';
5157
const HOST_STDIO_ENTRY = 'showcase_mcp_tools';
5258

@@ -144,6 +150,10 @@ describe('dogfood: a stored connector entry refused its package-relative ref doe
144150
saves[AUTHORED_FILE] = await json(await first.apiAs(
145151
token, 'PUT', `/meta/connector/${AUTHORED_FILE}?package=${AUTHORED_PACKAGE_ID}`, openapi(AUTHORED_FILE, FILE_REF),
146152
));
153+
saves[UNKNOWN_PROVIDER] = await json(await first.apiAs(token, 'PUT', `/meta/connector/${UNKNOWN_PROVIDER}`, {
154+
...openapi(UNKNOWN_PROVIDER, DOCUMENT),
155+
provider: 'no_such_provider',
156+
}));
147157
saves[INLINE] = await json(await first.apiAs(token, 'PUT', `/meta/connector/${INLINE}`, openapi(INLINE, DOCUMENT)));
148158
before = await settle(first, token, [INLINE]);
149159
} finally {
@@ -175,6 +185,7 @@ describe('dogfood: a stored connector entry refused its package-relative ref doe
175185
}
176186
expect(entryOf(before, INLINE)?.state, shown(before, INLINE)).toBe('ready');
177187
expect(entryOf(before, ENV_FILE), shown(before, ENV_FILE)).toBeUndefined();
188+
expect(entryOf(before, UNKNOWN_PROVIDER), shown(before, UNKNOWN_PROVIDER)).toBeUndefined();
178189
});
179190

180191
it('the second boot over the same database starts', () => {
@@ -192,6 +203,13 @@ describe('dogfood: a stored connector entry refused its package-relative ref doe
192203
expect(record).toContain(ref);
193204
});
194205

206+
it('[#28698] a stored entry naming a provider no plugin offers is not listed after the restart, and the boot record names it', () => {
207+
expect(entryOf(after, UNKNOWN_PROVIDER), shown(after, UNKNOWN_PROVIDER)).toBeUndefined();
208+
const record = bootLines.find((l) => l.includes(`connector instance '${UNKNOWN_PROVIDER}' declares provider 'no_such_provider'`));
209+
expect(record, `no boot record names '${UNKNOWN_PROVIDER}'`).toBeDefined();
210+
expect(record).toContain('no provider factory is registered');
211+
});
212+
195213
it('controls: the stored inline entry, and the showcase\'s own file-ref and stdio entries, list ready after the restart', () => {
196214
expect(entryOf(after, INLINE)?.state, shown(after, INLINE)).toBe('ready');
197215
expect(entryOf(after, INLINE).actions.map((a: any) => a.key)).toEqual(['GetThings']);

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

Lines changed: 139 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1037,16 +1037,15 @@ describe('#28594 — a definition stored in this environment is refused its pack
10371037
await kernel.shutdown();
10381038
});
10391039

1040-
it('control: a configuration fault other than a refused ref (an unknown provider) still fails the boot', async () => {
1041-
// The soft path is the stored entry's refused ref alone; a provider the
1042-
// runtime does not offer keeps the fail-loud contract.
1040+
it('control: the host\'s own entry with a configuration fault (an unknown provider) still fails the boot', async () => {
1041+
// The soft path is a stored entry's alone (#28698): the host's
1042+
// registered declaration, faulty as filed, keeps the fail-loud contract.
10431043
const { factory } = makeRefProvider();
1044+
const faulty = { ...hostEntry, provider: 'no_such_provider' };
10441045
const err = await rejectionOf(
1045-
boot([{ ...hostEntry, provider: 'no_such_provider' }], {
1046-
providerFactory: factory, packageRoot: fixtureRoot, packages, registered,
1047-
}),
1046+
boot([{ ...faulty }], { providerFactory: factory, packageRoot: fixtureRoot, packages, registered: [faulty] }),
10481047
);
1049-
expect(err.message).toContain('host_pkg');
1048+
expect(err.message).toContain(`connector instance 'host_pkg' declares provider 'no_such_provider'`);
10501049
});
10511050

10521051
it('control: a stored entry carrying its document inline materializes — neither member is reached', async () => {
@@ -1085,6 +1084,139 @@ describe('#28594 — a definition stored in this environment is refused its pack
10851084
});
10861085
});
10871086

1087+
// ── #28698 — every configuration fault of a STORED entry is soft at boot ────
1088+
//
1089+
// #28594 made a stored entry's refused ref soft at boot. Every other
1090+
// configuration fault the boot reconcile meets on a stored entry takes the same
1091+
// path (#28698): logged with the record a reload writes, the entry skipped,
1092+
// the host's entries beside it served. The host's own entry with the same fault
1093+
// keeps failing the boot.
1094+
1095+
describe('#28698 — a stored entry\'s configuration fault never fails the boot; the host\'s still does', () => {
1096+
const HOST = 'com.acme.host';
1097+
const packages = { [HOST]: { source: 'code' } };
1098+
const ownedBy = <T extends object>(item: T, packageId: string) => ({ ...item, _packageId: packageId });
1099+
const bearer = { type: 'bearer', credentialRef: 'OS_TEST_28698_VAULT_REF' } as ConnectorInstanceAuth;
1100+
1101+
/** A factory that materializes every entry, except one whose providerConfig asks it to refuse. */
1102+
function makeProvider() {
1103+
const calls: string[] = [];
1104+
const factory: ConnectorProviderFactory = (ctx) => {
1105+
calls.push(ctx.name);
1106+
if (ctx.providerConfig.refuse === true) throw new Error(`providerConfig of '${ctx.name}' is not valid: refuse is set`);
1107+
return {
1108+
def: { name: ctx.name, label: ctx.label, type: 'api', authentication: { type: 'none' }, actions: [{ key: 'ping', label: 'Ping' }] } as unknown as Connector,
1109+
handlers: { ping: async () => ({ ok: true }) },
1110+
};
1111+
};
1112+
return { factory, calls };
1113+
}
1114+
const throwingResolver: CredentialResolver = () => { throw new Error('the vault could not be reached'); };
1115+
1116+
/** The host's entry served beside every stored fault: registered, so it is the host's. */
1117+
const hostServed = ownedBy(providerConnector('host_served'), HOST);
1118+
1119+
/**
1120+
* One fault, spelled for an entry `name`: what the entry carries, the boot
1121+
* options it needs, and the record text that names it.
1122+
*/
1123+
const FAULTS: Array<{
1124+
fault: string;
1125+
entry: (name: string) => Record<string, unknown>;
1126+
opts?: Partial<BootOptions>;
1127+
livePlugin?: boolean;
1128+
record: (name: string) => string;
1129+
}> = [
1130+
{
1131+
fault: 'a provider no plugin offers',
1132+
entry: (name) => providerConnector(name, { provider: 'no_such_provider' }),
1133+
record: (name) => `connector instance '${name}' declares provider 'no_such_provider', but no provider factory is registered`,
1134+
},
1135+
{
1136+
fault: 'an invalid retryConfig',
1137+
entry: (name) => ({ ...providerConnector(name), retryConfig: { maxAttempts: -3 } }),
1138+
record: (name) => `connector instance '${name}' (provider 'fake'): retryConfig is not a valid connector retry policy`,
1139+
},
1140+
{
1141+
fault: 'a credential resolver that throws',
1142+
entry: (name) => providerConnector(name, { auth: bearer }),
1143+
opts: { credentialResolver: throwingResolver },
1144+
record: (name) => `connector instance '${name}' (provider 'fake'): auth resolution failed`,
1145+
},
1146+
{
1147+
fault: 'a name a plugin connector holds',
1148+
entry: (name) => providerConnector(name),
1149+
livePlugin: true,
1150+
record: (name) => `declarative provider-bound instance '${name}' collides with a plugin-registered connector`,
1151+
},
1152+
{
1153+
fault: 'a providerConfig its provider refuses',
1154+
entry: (name) => providerConnector(name, { providerConfig: { refuse: true } }),
1155+
record: (name) => `failed to materialize connector instance '${name}' via provider 'fake'`,
1156+
},
1157+
];
1158+
1159+
it.each(FAULTS.map((f) => [f.fault, f] as const))('a stored entry with %s: the boot starts, the entry is skipped with its record, the host\'s entry serves', async (_fault, f) => {
1160+
const { factory } = makeProvider();
1161+
const logged: Array<{ msg: string; meta?: unknown }> = [];
1162+
const name = 'stored_faulty';
1163+
const kernel = await boot([{ ...hostServed }, f.entry(name)], {
1164+
providerFactory: factory, packages, registered: [hostServed], logged,
1165+
...(f.livePlugin ? { registerLivePlugin: [name] } : {}),
1166+
...f.opts,
1167+
});
1168+
const engine = automationOf(kernel);
1169+
expect(engine.getRegisteredConnectors()).toContain('host_served');
1170+
// Skipped: nothing declarative serves the name (a plugin connector may).
1171+
expect(engine.getConnectorOrigin(name)).not.toBe('declarative');
1172+
const record = logged.find((l) => l.msg.includes(f.record(name)));
1173+
expect(record, `no record names '${name}': ${JSON.stringify(logged.map((l) => l.msg))}`).toBeDefined();
1174+
await kernel.shutdown();
1175+
});
1176+
1177+
it.each(FAULTS.map((f) => [f.fault, f] as const))('control: the host\'s own entry with %s still fails the boot', async (_fault, f) => {
1178+
const { factory } = makeProvider();
1179+
const name = 'host_faulty';
1180+
const faulty = ownedBy(f.entry(name), HOST);
1181+
const err = await rejectionOf(boot([{ ...faulty }], {
1182+
providerFactory: factory, packages, registered: [faulty],
1183+
...(f.livePlugin ? { registerLivePlugin: [name] } : {}),
1184+
...f.opts,
1185+
}));
1186+
expect(err.message).toContain(f.record(name));
1187+
});
1188+
1189+
it('a stored entry duplicating the host\'s name is skipped with its record; the host\'s entry serves', async () => {
1190+
const { factory, calls } = makeProvider();
1191+
const logged: Array<{ msg: string; meta?: unknown }> = [];
1192+
const kernel = await boot(
1193+
[{ ...hostServed }, providerConnector('host_served', { providerConfig: { other: true } })],
1194+
{ providerFactory: factory, packages, registered: [hostServed], logged },
1195+
);
1196+
expect(automationOf(kernel).getRegisteredConnectors()).toContain('host_served');
1197+
expect(calls).toEqual(['host_served']);
1198+
expect(logged.some((l) => l.msg.includes(`duplicate declarative connector instance name 'host_served'`))).toBe(true);
1199+
await kernel.shutdown();
1200+
});
1201+
1202+
it('control: two host entries of one name still fail the boot', async () => {
1203+
const { factory } = makeProvider();
1204+
const err = await rejectionOf(boot([{ ...hostServed }, { ...hostServed }], {
1205+
providerFactory: factory, packages, registered: [hostServed],
1206+
}));
1207+
expect(err.message).toContain(`duplicate declarative connector instance name 'host_served'`);
1208+
});
1209+
1210+
it('control: an INSTALLED package\'s entry with a configuration fault still fails the boot (#28534 unchanged)', async () => {
1211+
const { factory } = makeProvider();
1212+
const installed = ownedBy(providerConnector('installed_faulty', { provider: 'no_such_provider' }), 'com.acme.installed');
1213+
const err = await rejectionOf(boot([installed], {
1214+
providerFactory: factory, packages: { ...packages, 'com.acme.installed': { source: 'marketplace' } }, registered: [installed],
1215+
}));
1216+
expect(err.message).toContain(`connector instance 'installed_faulty' declares provider 'no_such_provider'`);
1217+
});
1218+
});
1219+
10881220
// ── #3017 — upstream unavailable: degrade + retry, never a dead boot ─────────
10891221
//
10901222
// A provider factory that throws the CONNECTOR_UPSTREAM_UNAVAILABLE marker

0 commit comments

Comments
 (0)