@zhin.js/adapter 1.1.5 → 1.1.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -5
- package/lib/adapter-index.d.ts +9 -9
- package/lib/adapter-index.js +124 -256
- package/lib/definition.d.ts +3 -3
- package/lib/endpoint-commands.d.ts +17 -12
- package/lib/endpoint-commands.js +88 -38
- package/lib/endpoint-control.d.ts +8 -12
- package/lib/endpoint-control.js +3 -48
- package/lib/endpoint-lifecycle.js +1 -1
- package/lib/provider.js +2 -14
- package/package.json +6 -6
- package/src/adapter-index.ts +137 -282
- package/src/definition.ts +3 -3
- package/src/endpoint-commands.ts +116 -45
- package/src/endpoint-control.ts +9 -88
- package/src/endpoint-lifecycle.ts +1 -1
- package/src/provider.ts +3 -16
package/README.md
CHANGED
|
@@ -2,7 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
Zhin Plugin Runtime 的 Adapter Feature。它从插件或项目的 `adapters/**/*.ts` 发现
|
|
4
4
|
`defineAdapter()` 定义,按 Plugin owner 投影 Endpoint,并把 start/open/close/stop 纳入同一
|
|
5
|
-
generation
|
|
5
|
+
generation lifecycle。候选 Endpoint 可完成连接 readiness,但入站由 `SnapshotStore`
|
|
6
|
+
切换的 generation admission gate 阻断到 commit;旧 Endpoint 不会在 commit 前被关闭。
|
|
7
|
+
已声明的 Endpoint 默认都是 required:`create()`、`start()` 或 `open()` 任一步失败都会
|
|
8
|
+
销毁整组候选 Endpoint 并拒绝本次 generation,不存在 inert stub 或后台 late-open。
|
|
6
9
|
|
|
7
10
|
```ts
|
|
8
11
|
import { defineAdapter } from '@zhin.js/adapter';
|
|
@@ -17,7 +20,7 @@ export default defineAdapter({
|
|
|
17
20
|
`lib/provider.js`;开发时可通过 conditional export 读取源码。
|
|
18
21
|
|
|
19
22
|
单文件插件可用 `setup({ addAdapter })` 注册 `defineAdapter(...)`;Endpoint 仍由同一个
|
|
20
|
-
AdapterIndex 和 generation
|
|
23
|
+
AdapterIndex 和 generation lifecycle 管理。
|
|
21
24
|
|
|
22
25
|
## Transport Contract
|
|
23
26
|
|
|
@@ -39,9 +42,8 @@ adapter-specific method names and compound message ids stay at the protocol
|
|
|
39
42
|
boundary.
|
|
40
43
|
|
|
41
44
|
New adapters should provide `control` directly and declare matching
|
|
42
|
-
`operations`.
|
|
43
|
-
|
|
44
|
-
exists only in this package and is not a public extension pattern.
|
|
45
|
+
`operations`. Protocol-specific methods and compound string identifiers are not
|
|
46
|
+
inspected or adapted by the runtime.
|
|
45
47
|
|
|
46
48
|
## Endpoint 生命周期基座(createEndpointLifecycle)
|
|
47
49
|
|
package/lib/adapter-index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type CapabilityId, type CapabilitySlot, type PluginId, type RuntimeSnapshot } from '@zhin.js/plugin-runtime';
|
|
1
|
+
import { generationAdmissionSource, type CapabilityId, type CapabilitySlot, type GenerationAdmissionGate, type PluginId, type RuntimeSnapshot } from '@zhin.js/plugin-runtime';
|
|
2
2
|
import type { AdapterCapability, AdapterDefinition, AdapterSegmentPolicy, EndpointInstance, EndpointSendRequest } from './definition.js';
|
|
3
3
|
import { type EndpointManagementCapability } from './endpoint-management.js';
|
|
4
4
|
export interface AdapterDescriptor {
|
|
@@ -15,15 +15,13 @@ export interface AdapterEndpointSummary extends AdapterDescriptor {
|
|
|
15
15
|
readonly phase: AdapterEndpointPhase;
|
|
16
16
|
readonly managementCapabilities: readonly EndpointManagementCapability[];
|
|
17
17
|
}
|
|
18
|
-
export type AdapterEndpointPhase = 'pending' | 'starting' | 'online'
|
|
18
|
+
export type AdapterEndpointPhase = 'pending' | 'starting' | 'online';
|
|
19
19
|
export declare class AdapterIndex {
|
|
20
20
|
#private;
|
|
21
21
|
readonly $projection: "zhin.adapter-index/1";
|
|
22
|
+
readonly [generationAdmissionSource]: readonly GenerationAdmissionGate[];
|
|
22
23
|
private constructor();
|
|
23
|
-
static create(slots: readonly Readonly<CapabilitySlot<AdapterDefinition>>[], snapshot: RuntimeSnapshot,
|
|
24
|
-
readonly startTimeoutMs?: number;
|
|
25
|
-
readonly deferredGiveUpMs?: number;
|
|
26
|
-
}): Promise<AdapterIndex>;
|
|
24
|
+
static create(slots: readonly Readonly<CapabilitySlot<AdapterDefinition>>[], snapshot: RuntimeSnapshot, signal: AbortSignal): Promise<AdapterIndex>;
|
|
27
25
|
list(): readonly AdapterDescriptor[];
|
|
28
26
|
/** Endpoint rows for Console `endpoint.list` / `endpoint.info`. */
|
|
29
27
|
describe(): readonly AdapterEndpointSummary[];
|
|
@@ -31,18 +29,20 @@ export declare class AdapterIndex {
|
|
|
31
29
|
* Resolve a Console `$adapter` + `$endpoint` pair to a capability id.
|
|
32
30
|
* Matches local name, capability id, or owner path segments.
|
|
33
31
|
*/
|
|
34
|
-
resolve(adapter: string,
|
|
32
|
+
resolve(adapter: string, endpointKey: string): CapabilityId | undefined;
|
|
35
33
|
/**
|
|
36
34
|
* Resolve a live EndpointInstance for Host-side side channels (reactions, etc.).
|
|
37
35
|
*/
|
|
38
|
-
instance(adapter: string,
|
|
36
|
+
instance(adapter: string, endpointKey: string): EndpointInstance | undefined;
|
|
39
37
|
owner(id: CapabilityId): PluginId;
|
|
40
38
|
/**
|
|
41
39
|
* Endpoint 的消息段能力声明(出站协商降级依据);
|
|
42
40
|
* 未声明或未知 id 返回 undefined(调用方按历史行为处理)。
|
|
43
41
|
*/
|
|
44
42
|
segmentPolicy(id: CapabilityId): AdapterSegmentPolicy | undefined;
|
|
45
|
-
start(): Promise<void>;
|
|
43
|
+
start(signal?: AbortSignal): Promise<void>;
|
|
44
|
+
/** Required readiness boundary: no generation can publish a partial Endpoint set. */
|
|
45
|
+
activate(signal: AbortSignal): Promise<void>;
|
|
46
46
|
open(): void;
|
|
47
47
|
close(): Promise<void>;
|
|
48
48
|
stop(): Promise<void>;
|
package/lib/adapter-index.js
CHANGED
|
@@ -1,62 +1,45 @@
|
|
|
1
|
-
import { DisposeStack, } from '@zhin.js/plugin-runtime';
|
|
1
|
+
import { DisposeStack, GenerationCompensationError, createGenerationAdmissionGate, generationAdmissionSource, } from '@zhin.js/plugin-runtime';
|
|
2
2
|
import { createCapabilityContext } from '@zhin.js/feature-kit';
|
|
3
|
-
import { formatCompact, getLogger } from '@zhin.js/logger';
|
|
4
3
|
import { listEndpointManagementCapabilities, } from './endpoint-management.js';
|
|
5
4
|
import { assertDeclaredEndpointOperations } from './endpoint-control.js';
|
|
6
|
-
const logger = getLogger('Adapter');
|
|
7
5
|
export class AdapterIndex {
|
|
8
6
|
$projection = 'zhin.adapter-index/1';
|
|
9
7
|
#records = new Map();
|
|
8
|
+
[generationAdmissionSource];
|
|
10
9
|
#order;
|
|
11
|
-
|
|
12
|
-
#admissionOpen = false;
|
|
13
|
-
#startTimeoutMs;
|
|
14
|
-
/** Final give-up budget for deferred starts (never-settling start promises). */
|
|
15
|
-
#deferredGiveUpMs;
|
|
16
|
-
constructor(records, startTimeoutMs, deferredGiveUpMs) {
|
|
10
|
+
constructor(records, admission) {
|
|
17
11
|
this.#order = Object.freeze([...records]);
|
|
18
|
-
this
|
|
19
|
-
this.#deferredGiveUpMs = deferredGiveUpMs;
|
|
12
|
+
this[generationAdmissionSource] = Object.freeze([admission]);
|
|
20
13
|
for (const record of records)
|
|
21
14
|
this.#records.set(record.id, record);
|
|
22
15
|
}
|
|
23
|
-
static async create(slots, snapshot,
|
|
16
|
+
static async create(slots, snapshot, signal) {
|
|
24
17
|
const records = [];
|
|
25
|
-
const
|
|
18
|
+
const admission = createGenerationAdmissionGate();
|
|
26
19
|
try {
|
|
27
20
|
for (const slot of [...slots].sort((left, right) => left.id.localeCompare(right.id))) {
|
|
21
|
+
signal.throwIfAborted();
|
|
28
22
|
for (const expansion of expandEndpointConfigs(slot, snapshot)) {
|
|
29
|
-
const endpoint = await
|
|
30
|
-
|
|
31
|
-
unconfigured.push(expansion.name);
|
|
23
|
+
const endpoint = await createEndpoint(slot, snapshot, admission, signal, expansion);
|
|
24
|
+
signal.throwIfAborted();
|
|
32
25
|
records.push({
|
|
33
26
|
id: expansion.id,
|
|
34
27
|
owner: slot.owner,
|
|
35
|
-
// 展开模式下 record name 即 endpoint
|
|
36
|
-
// 保证 Console 展示与 resolve/instance 按 entry
|
|
37
|
-
name: expansion.
|
|
28
|
+
// 展开模式下 record name 即 endpoint id(entry.id),
|
|
29
|
+
// 保证 Console 展示与 resolve/instance 按 entry id 命中唯一 record
|
|
30
|
+
name: expansion.endpointId,
|
|
38
31
|
source: slot.source,
|
|
39
32
|
capabilities: slot.definition.capabilities,
|
|
40
|
-
endpoint
|
|
33
|
+
endpoint,
|
|
41
34
|
...(slot.definition.segments ? { segments: slot.definition.segments } : {}),
|
|
42
|
-
unconfigured: endpoint.unconfigured,
|
|
43
35
|
started: false,
|
|
44
36
|
open: false,
|
|
45
|
-
failed: false,
|
|
46
37
|
startAttempted: false,
|
|
47
|
-
|
|
48
|
-
stopped: endpoint.unconfigured,
|
|
38
|
+
stopped: false,
|
|
49
39
|
});
|
|
50
40
|
}
|
|
51
41
|
}
|
|
52
|
-
|
|
53
|
-
logger.info(formatCompact({
|
|
54
|
-
op: 'adapters_unconfigured',
|
|
55
|
-
count: unconfigured.length,
|
|
56
|
-
names: unconfigured.join(','),
|
|
57
|
-
}));
|
|
58
|
-
}
|
|
59
|
-
return new AdapterIndex(records, options.startTimeoutMs ?? 3_000, options.deferredGiveUpMs ?? 60_000);
|
|
42
|
+
return new AdapterIndex(records, admission);
|
|
60
43
|
}
|
|
61
44
|
catch (error) {
|
|
62
45
|
await stopRecords(records, error);
|
|
@@ -64,7 +47,7 @@ export class AdapterIndex {
|
|
|
64
47
|
}
|
|
65
48
|
}
|
|
66
49
|
list() {
|
|
67
|
-
return this.#order.map(({ endpoint: _endpoint,
|
|
50
|
+
return this.#order.map(({ endpoint: _endpoint, started: _started, open: _open, stopped: _stopped, stopping: _stopping, startAttempted: _startAttempted, segments: _segments, ...descriptor }) => Object.freeze(descriptor));
|
|
68
51
|
}
|
|
69
52
|
/** Endpoint rows for Console `endpoint.list` / `endpoint.info`. */
|
|
70
53
|
describe() {
|
|
@@ -85,21 +68,21 @@ export class AdapterIndex {
|
|
|
85
68
|
* Resolve a Console `$adapter` + `$endpoint` pair to a capability id.
|
|
86
69
|
* Matches local name, capability id, or owner path segments.
|
|
87
70
|
*/
|
|
88
|
-
resolve(adapter,
|
|
89
|
-
const matches = this.#order.filter((record) => matchesEndpoint(record, adapter,
|
|
71
|
+
resolve(adapter, endpointKey) {
|
|
72
|
+
const matches = this.#order.filter((record) => matchesEndpoint(record, adapter, endpointKey));
|
|
90
73
|
if (matches.length === 1)
|
|
91
74
|
return matches[0]?.id;
|
|
92
75
|
if (matches.length === 0)
|
|
93
76
|
return undefined;
|
|
94
|
-
// Prefer exact localName ===
|
|
95
|
-
const exact = matches.find((record) => record.name ===
|
|
77
|
+
// Prefer exact localName === endpointKey when ambiguous.
|
|
78
|
+
const exact = matches.find((record) => record.name === endpointKey);
|
|
96
79
|
return exact?.id ?? matches[0]?.id;
|
|
97
80
|
}
|
|
98
81
|
/**
|
|
99
82
|
* Resolve a live EndpointInstance for Host-side side channels (reactions, etc.).
|
|
100
83
|
*/
|
|
101
|
-
instance(adapter,
|
|
102
|
-
const id = this.resolve(adapter,
|
|
84
|
+
instance(adapter, endpointKey) {
|
|
85
|
+
const id = this.resolve(adapter, endpointKey);
|
|
103
86
|
if (!id)
|
|
104
87
|
return undefined;
|
|
105
88
|
return this.#records.get(id)?.endpoint;
|
|
@@ -117,102 +100,55 @@ export class AdapterIndex {
|
|
|
117
100
|
segmentPolicy(id) {
|
|
118
101
|
return this.#records.get(id)?.segments;
|
|
119
102
|
}
|
|
120
|
-
async start() {
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
103
|
+
async start(signal = new AbortController().signal) {
|
|
104
|
+
try {
|
|
105
|
+
// Sequential readiness gives the candidate one owned in-flight start at a
|
|
106
|
+
// time. A sibling failure can therefore never leave an un-awaited start
|
|
107
|
+
// promise mutating resources after rollback has returned.
|
|
108
|
+
for (const record of this.#order) {
|
|
109
|
+
if (record.started || record.stopped)
|
|
110
|
+
continue;
|
|
111
|
+
record.startAttempted = true;
|
|
112
|
+
signal.throwIfAborted();
|
|
113
|
+
let stopOnAbort;
|
|
114
|
+
const aborted = new Promise((_resolve, reject) => {
|
|
115
|
+
stopOnAbort = () => {
|
|
116
|
+
void stopRecord(record).then(() => reject(signal.reason ?? new Error('Adapter Endpoint start aborted')), (cleanupError) => reject(new GenerationCompensationError([signal.reason, cleanupError], 'Adapter Endpoint cancellation cleanup failed', { cause: cleanupError })));
|
|
117
|
+
};
|
|
118
|
+
});
|
|
119
|
+
signal.addEventListener('abort', stopOnAbort, { once: true });
|
|
120
|
+
try {
|
|
121
|
+
await Promise.race([
|
|
122
|
+
Promise.resolve(record.endpoint.start?.(signal)),
|
|
123
|
+
aborted,
|
|
124
|
+
]);
|
|
125
|
+
}
|
|
126
|
+
finally {
|
|
127
|
+
signal.removeEventListener('abort', stopOnAbort);
|
|
128
|
+
}
|
|
129
|
+
signal.throwIfAborted();
|
|
132
130
|
if (record.stopped)
|
|
133
|
-
|
|
131
|
+
throw new Error(`Adapter Endpoint stopped during start: ${record.id}`);
|
|
134
132
|
record.started = true;
|
|
135
133
|
}
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
// Swallow a late rejection so it does not become unhandled.
|
|
153
|
-
void startPromise.catch(() => undefined);
|
|
154
|
-
logger.warn(formatCompact({
|
|
155
|
-
op: 'adapter_start_give_up',
|
|
156
|
-
id: record.id,
|
|
157
|
-
name: record.name,
|
|
158
|
-
waitMs: this.#deferredGiveUpMs,
|
|
159
|
-
}));
|
|
160
|
-
}, this.#deferredGiveUpMs);
|
|
161
|
-
giveUp.unref?.();
|
|
162
|
-
void startPromise.then(() => {
|
|
163
|
-
clearTimeout(giveUp);
|
|
164
|
-
if (record.stopped || record.started)
|
|
165
|
-
return;
|
|
166
|
-
record.started = true;
|
|
167
|
-
if (this.#admissionOpen && !record.open) {
|
|
168
|
-
try {
|
|
169
|
-
record.endpoint.open?.();
|
|
170
|
-
record.open = true;
|
|
171
|
-
}
|
|
172
|
-
catch (openError) {
|
|
173
|
-
logger.warn(formatCompact({
|
|
174
|
-
op: 'adapter_open_after_deferred_fail',
|
|
175
|
-
id: record.id,
|
|
176
|
-
name: record.name,
|
|
177
|
-
error: openError instanceof Error ? openError.message : String(openError),
|
|
178
|
-
}));
|
|
179
|
-
}
|
|
180
|
-
}
|
|
181
|
-
}, (startError) => {
|
|
182
|
-
clearTimeout(giveUp);
|
|
183
|
-
if (record.stopped)
|
|
184
|
-
return;
|
|
185
|
-
record.stopped = true;
|
|
186
|
-
record.failed = true;
|
|
187
|
-
logger.warn(formatCompact({
|
|
188
|
-
op: 'adapter_start_soft_fail',
|
|
189
|
-
id: record.id,
|
|
190
|
-
name: record.name,
|
|
191
|
-
error: startError instanceof Error ? startError.message : String(startError),
|
|
192
|
-
stack: startError instanceof Error ? startError.stack : undefined,
|
|
193
|
-
}));
|
|
194
|
-
});
|
|
195
|
-
return;
|
|
196
|
-
}
|
|
197
|
-
record.stopped = true;
|
|
198
|
-
record.failed = true;
|
|
199
|
-
void startPromise.catch(() => undefined);
|
|
200
|
-
// Startup connect failures are logged once here (with stack); Endpoint
|
|
201
|
-
// implementations must NOT re-log them at error level.
|
|
202
|
-
logger.warn(formatCompact({
|
|
203
|
-
op: 'adapter_start_soft_fail',
|
|
204
|
-
id: record.id,
|
|
205
|
-
name: record.name,
|
|
206
|
-
error: message,
|
|
207
|
-
stack: error instanceof Error ? error.stack : undefined,
|
|
208
|
-
}));
|
|
209
|
-
// No endpoint.stop() here: adapter Endpoints self-stop in their start()
|
|
210
|
-
// catch by convention (verified across icqq/qq/slack/… endpoints).
|
|
211
|
-
}
|
|
212
|
-
}));
|
|
134
|
+
}
|
|
135
|
+
catch (error) {
|
|
136
|
+
await stopRecords(this.#order, error);
|
|
137
|
+
throw error;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
/** Required readiness boundary: no generation can publish a partial Endpoint set. */
|
|
141
|
+
async activate(signal) {
|
|
142
|
+
try {
|
|
143
|
+
await this.start(signal);
|
|
144
|
+
this.open();
|
|
145
|
+
}
|
|
146
|
+
catch (error) {
|
|
147
|
+
await stopRecords(this.#order, error);
|
|
148
|
+
throw error;
|
|
149
|
+
}
|
|
213
150
|
}
|
|
214
151
|
open() {
|
|
215
|
-
this.#admissionOpen = true;
|
|
216
152
|
const errors = [];
|
|
217
153
|
for (const record of this.#order) {
|
|
218
154
|
if (!record.started || record.open || record.stopped)
|
|
@@ -229,7 +165,6 @@ export class AdapterIndex {
|
|
|
229
165
|
throw new AggregateError(errors, 'Adapter Endpoint open failed');
|
|
230
166
|
}
|
|
231
167
|
async close() {
|
|
232
|
-
this.#admissionOpen = false;
|
|
233
168
|
const stack = new DisposeStack();
|
|
234
169
|
for (const record of this.#order) {
|
|
235
170
|
if (!record.open || record.stopped)
|
|
@@ -266,7 +201,7 @@ export function isAdapterIndex(value) {
|
|
|
266
201
|
return !!value && typeof value === 'object'
|
|
267
202
|
&& value.$projection === 'zhin.adapter-index/1';
|
|
268
203
|
}
|
|
269
|
-
function matchesEndpoint(record, adapter,
|
|
204
|
+
function matchesEndpoint(record, adapter, endpointKey) {
|
|
270
205
|
// 消息上的 $adapter 是 CapabilityId 的 localName 段(多 endpoint 展开后形如
|
|
271
206
|
// `icqq~8596238`)。CapabilityId 段分隔符是 \0(owner\0feature\0localName),
|
|
272
207
|
// 不能用 `/` 去 endsWith,否则永远匹配不上(endpoint not found)。
|
|
@@ -281,10 +216,10 @@ function matchesEndpoint(record, adapter, endpointId) {
|
|
|
281
216
|
// activity-feedback resolve with that id; slot.localName alone is not enough
|
|
282
217
|
// when multiple plugin instances share localName "icqq".
|
|
283
218
|
const liveName = endpointLiveName(record.endpoint);
|
|
284
|
-
const endpointOk = record.name ===
|
|
285
|
-
|| record.id ===
|
|
286
|
-
|| record.id.endsWith(`/${
|
|
287
|
-
|| (liveName !== undefined && liveName ===
|
|
219
|
+
const endpointOk = record.name === endpointKey
|
|
220
|
+
|| record.id === endpointKey
|
|
221
|
+
|| record.id.endsWith(`/${endpointKey}`)
|
|
222
|
+
|| (liveName !== undefined && liveName === endpointKey);
|
|
288
223
|
return adapterOk && endpointOk;
|
|
289
224
|
}
|
|
290
225
|
function endpointLiveName(endpoint) {
|
|
@@ -292,13 +227,9 @@ function endpointLiveName(endpoint) {
|
|
|
292
227
|
return typeof name === 'string' && name.length > 0 ? name : undefined;
|
|
293
228
|
}
|
|
294
229
|
function endpointPhase(record) {
|
|
295
|
-
if (record.unconfigured)
|
|
296
|
-
return 'unconfigured';
|
|
297
|
-
if (record.failed)
|
|
298
|
-
return 'failed';
|
|
299
230
|
if (record.open && !record.stopped)
|
|
300
231
|
return 'online';
|
|
301
|
-
if (record.startAttempted)
|
|
232
|
+
if (record.startAttempted && !record.started)
|
|
302
233
|
return 'starting';
|
|
303
234
|
return 'pending';
|
|
304
235
|
}
|
|
@@ -308,152 +239,89 @@ function assertEndpoint(value, id) {
|
|
|
308
239
|
}
|
|
309
240
|
}
|
|
310
241
|
/**
|
|
311
|
-
*
|
|
312
|
-
* TypeError("… requires …") by convention; only those are expected failures.
|
|
313
|
-
*/
|
|
314
|
-
function isUnconfiguredError(error) {
|
|
315
|
-
return (error instanceof TypeError
|
|
316
|
-
&& /requires|not configured|missing|未配置|缺少/i.test(error.message));
|
|
317
|
-
}
|
|
318
|
-
/**
|
|
319
|
-
* 实例配置的 endpoint 展开:插件实例 config 含非空 `endpoints: [{name, ...覆盖}]` 时
|
|
242
|
+
* 实例配置的 endpoint 展开:插件实例 config 含非空 `endpoints: [{id, ...覆盖}]` 时
|
|
320
243
|
* 按数组一一创建 endpoint(基础配置为实例 config 去掉 `endpoints` 键,逐项合并),
|
|
321
244
|
* 否则按实例 config 创建单个 endpoint(历史行为)。
|
|
322
245
|
*/
|
|
323
246
|
function expandEndpointConfigs(slot, snapshot) {
|
|
324
247
|
const config = snapshot.config.get(slot.owner);
|
|
325
248
|
const raw = config?.endpoints;
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
: [];
|
|
249
|
+
if (raw !== undefined && !Array.isArray(raw)) {
|
|
250
|
+
throw new TypeError(`Adapter ${slot.id} endpoints must be an array`);
|
|
251
|
+
}
|
|
252
|
+
const entries = (raw ?? []);
|
|
331
253
|
if (entries.length === 0) {
|
|
332
|
-
|
|
333
|
-
logger.warn(formatCompact({
|
|
334
|
-
op: 'adapter_endpoints_entries_dropped',
|
|
335
|
-
id: slot.id,
|
|
336
|
-
reason: 'every endpoints entry is missing a non-empty string name',
|
|
337
|
-
}));
|
|
338
|
-
}
|
|
339
|
-
return Object.freeze([{ id: slot.id, name: slot.localName }]);
|
|
254
|
+
return Object.freeze([{ id: slot.id, endpointId: slot.localName }]);
|
|
340
255
|
}
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
id: slot.id,
|
|
347
|
-
name: entry.name,
|
|
348
|
-
}));
|
|
349
|
-
return false;
|
|
256
|
+
const normalized = entries.map((entry, index) => {
|
|
257
|
+
if (!entry || typeof entry !== 'object'
|
|
258
|
+
|| typeof entry.id !== 'string'
|
|
259
|
+
|| entry.id.length === 0) {
|
|
260
|
+
throw new TypeError(`Adapter ${slot.id} endpoints[${index}].id must be a non-empty string`);
|
|
350
261
|
}
|
|
351
|
-
return
|
|
262
|
+
return entry;
|
|
352
263
|
});
|
|
353
|
-
//
|
|
264
|
+
// `~` 是 record id 的分隔符、\0 是 CapabilityId 的分隔符,混入会破坏解析。
|
|
265
|
+
for (const entry of normalized) {
|
|
266
|
+
if (/[~\0]/u.test(entry.id)) {
|
|
267
|
+
throw new TypeError(`Adapter ${slot.id} endpoint id contains a reserved delimiter: ${entry.id}`);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
354
270
|
const seen = new Set();
|
|
355
|
-
const
|
|
356
|
-
if (seen.has(entry.
|
|
357
|
-
|
|
358
|
-
op: 'adapter_endpoint_name_duplicate',
|
|
359
|
-
id: slot.id,
|
|
360
|
-
name: entry.name,
|
|
361
|
-
}));
|
|
362
|
-
return false;
|
|
271
|
+
for (const entry of normalized) {
|
|
272
|
+
if (seen.has(entry.id)) {
|
|
273
|
+
throw new TypeError(`Adapter ${slot.id} endpoint id is duplicated: ${entry.id}`);
|
|
363
274
|
}
|
|
364
|
-
seen.add(entry.
|
|
365
|
-
return true;
|
|
366
|
-
});
|
|
367
|
-
if (deduped.length === 0) {
|
|
368
|
-
return Object.freeze([{ id: slot.id, name: slot.localName }]);
|
|
275
|
+
seen.add(entry.id);
|
|
369
276
|
}
|
|
370
277
|
const { endpoints: _drop, ...base } = (config ?? {});
|
|
371
|
-
return Object.freeze(
|
|
372
|
-
id: `${slot.id}~${entry.
|
|
373
|
-
|
|
374
|
-
config: Object.freeze({ ...base, ...entry,
|
|
278
|
+
return Object.freeze(normalized.map((entry) => Object.freeze({
|
|
279
|
+
id: `${slot.id}~${entry.id}`,
|
|
280
|
+
endpointId: entry.id,
|
|
281
|
+
config: Object.freeze({ ...base, ...entry, id: entry.id }),
|
|
375
282
|
})));
|
|
376
283
|
}
|
|
377
|
-
async function
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
name: slot.localName,
|
|
385
|
-
}));
|
|
386
|
-
}
|
|
387
|
-
catch (error) {
|
|
388
|
-
// Missing config / credentials: degrade to an inert stub so the rest of
|
|
389
|
-
// the generation still boots. Anything else (network failures, bugs in
|
|
390
|
-
// create()) is unexpected — keep the stub but surface a warning instead
|
|
391
|
-
// of silently swallowing it at debug level.
|
|
392
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
393
|
-
const log = isUnconfiguredError(error) ? logger.debug.bind(logger) : logger.warn.bind(logger);
|
|
394
|
-
log(formatCompact({
|
|
395
|
-
op: 'adapter_create_soft_fail',
|
|
396
|
-
id: expansion?.id ?? slot.id,
|
|
397
|
-
name: expansion?.name ?? slot.localName,
|
|
398
|
-
error: message,
|
|
399
|
-
}));
|
|
400
|
-
return {
|
|
401
|
-
instance: createUnconfiguredEndpoint(message),
|
|
402
|
-
unconfigured: true,
|
|
403
|
-
};
|
|
404
|
-
}
|
|
405
|
-
// Programming errors (create() did not return an Endpoint) must surface:
|
|
406
|
-
// they propagate to AdapterIndex.create's catch, which disposes the records
|
|
407
|
-
// created so far instead of hiding the bug behind an unconfigured stub.
|
|
284
|
+
async function createEndpoint(slot, snapshot, admission, signal, expansion) {
|
|
285
|
+
const endpoint = await slot.definition.create(Object.freeze({
|
|
286
|
+
...createCapabilityContext(snapshot, slot.owner, admission, signal),
|
|
287
|
+
...(expansion?.config ? { config: expansion.config } : {}),
|
|
288
|
+
id: expansion?.id ?? slot.id,
|
|
289
|
+
name: slot.localName,
|
|
290
|
+
}));
|
|
408
291
|
assertEndpoint(endpoint, expansion?.id ?? slot.id);
|
|
409
292
|
assertDeclaredEndpointOperations(endpoint, slot.definition.operations, String(expansion?.id ?? slot.id));
|
|
410
|
-
return
|
|
411
|
-
}
|
|
412
|
-
function createUnconfiguredEndpoint(reason) {
|
|
413
|
-
return Object.freeze({
|
|
414
|
-
start() {
|
|
415
|
-
throw new Error(`Adapter unconfigured: ${reason}`);
|
|
416
|
-
},
|
|
417
|
-
open() { },
|
|
418
|
-
close() { },
|
|
419
|
-
stop() { },
|
|
420
|
-
send() {
|
|
421
|
-
throw new Error(`Adapter unconfigured: ${reason}`);
|
|
422
|
-
},
|
|
423
|
-
});
|
|
424
|
-
}
|
|
425
|
-
function withTimeout(promise, ms, message) {
|
|
426
|
-
if (promise === undefined)
|
|
427
|
-
return Promise.resolve(undefined);
|
|
428
|
-
return new Promise((resolve, reject) => {
|
|
429
|
-
const timer = setTimeout(() => reject(new Error(message)), ms);
|
|
430
|
-
Promise.resolve(promise).then((value) => {
|
|
431
|
-
clearTimeout(timer);
|
|
432
|
-
resolve(value);
|
|
433
|
-
}, (error) => {
|
|
434
|
-
clearTimeout(timer);
|
|
435
|
-
reject(error);
|
|
436
|
-
});
|
|
437
|
-
});
|
|
293
|
+
return endpoint;
|
|
438
294
|
}
|
|
439
295
|
async function stopRecords(records, primaryError) {
|
|
440
296
|
const stack = new DisposeStack();
|
|
441
297
|
for (const record of records) {
|
|
442
298
|
if (record.stopped)
|
|
443
299
|
continue;
|
|
444
|
-
stack.add(
|
|
445
|
-
record.stopped = true;
|
|
446
|
-
record.open = false;
|
|
447
|
-
await record.endpoint.stop?.();
|
|
448
|
-
});
|
|
300
|
+
stack.add(() => stopRecord(record));
|
|
449
301
|
}
|
|
450
302
|
try {
|
|
451
303
|
await stack.dispose();
|
|
452
304
|
}
|
|
453
305
|
catch (stopError) {
|
|
454
306
|
if (primaryError !== undefined) {
|
|
455
|
-
throw new
|
|
307
|
+
throw new GenerationCompensationError([primaryError, stopError], 'Adapter prepare and Endpoint cleanup both failed', { cause: stopError });
|
|
456
308
|
}
|
|
457
309
|
throw stopError;
|
|
458
310
|
}
|
|
459
311
|
}
|
|
312
|
+
function stopRecord(record) {
|
|
313
|
+
if (record.stopped)
|
|
314
|
+
return Promise.resolve();
|
|
315
|
+
if (record.stopping)
|
|
316
|
+
return record.stopping;
|
|
317
|
+
const stopping = Promise.resolve(record.endpoint.stop?.()).then(() => {
|
|
318
|
+
record.stopped = true;
|
|
319
|
+
record.open = false;
|
|
320
|
+
});
|
|
321
|
+
record.stopping = stopping;
|
|
322
|
+
void stopping.catch(() => undefined).finally(() => {
|
|
323
|
+
if (!record.stopped && record.stopping === stopping)
|
|
324
|
+
record.stopping = undefined;
|
|
325
|
+
});
|
|
326
|
+
return stopping;
|
|
327
|
+
}
|
package/lib/definition.d.ts
CHANGED
|
@@ -21,9 +21,9 @@ export interface EndpointInstance<TResult = unknown> {
|
|
|
21
21
|
readonly management?: EndpointManagement;
|
|
22
22
|
/** Optional platform-neutral control surface for existing messages. */
|
|
23
23
|
readonly control?: EndpointControl;
|
|
24
|
-
/**
|
|
25
|
-
start?(): void | Promise<void>;
|
|
26
|
-
/** Opens
|
|
24
|
+
/** Required readiness; must observe abort and settle before rollback returns. */
|
|
25
|
+
start?(signal: AbortSignal): void | Promise<void>;
|
|
26
|
+
/** Opens Endpoint-local flow behind the candidate generation admission gate. */
|
|
27
27
|
open?(): void;
|
|
28
28
|
/** Stops new inbound events while preserving in-flight work. */
|
|
29
29
|
close?(): void | Promise<void>;
|