@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/src/adapter-index.ts
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
import {
|
|
2
2
|
DisposeStack,
|
|
3
|
+
GenerationCompensationError,
|
|
4
|
+
createGenerationAdmissionGate,
|
|
5
|
+
generationAdmissionSource,
|
|
3
6
|
type CapabilityId,
|
|
4
7
|
type CapabilitySlot,
|
|
8
|
+
type GenerationAdmissionGate,
|
|
5
9
|
type PluginId,
|
|
6
10
|
type RuntimeSnapshot,
|
|
7
11
|
} from '@zhin.js/plugin-runtime';
|
|
8
12
|
import { createCapabilityContext } from '@zhin.js/feature-kit';
|
|
9
|
-
import { formatCompact, getLogger } from '@zhin.js/logger';
|
|
10
13
|
import type {
|
|
11
14
|
AdapterCapability,
|
|
12
15
|
AdapterDefinition,
|
|
@@ -20,8 +23,6 @@ import {
|
|
|
20
23
|
} from './endpoint-management.js';
|
|
21
24
|
import { assertDeclaredEndpointOperations } from './endpoint-control.js';
|
|
22
25
|
|
|
23
|
-
const logger = getLogger('Adapter');
|
|
24
|
-
|
|
25
26
|
export interface AdapterDescriptor {
|
|
26
27
|
readonly id: CapabilityId;
|
|
27
28
|
readonly owner: PluginId;
|
|
@@ -39,89 +40,64 @@ export interface AdapterEndpointSummary extends AdapterDescriptor {
|
|
|
39
40
|
}
|
|
40
41
|
|
|
41
42
|
export type AdapterEndpointPhase =
|
|
42
|
-
'pending' | 'starting' | 'online'
|
|
43
|
+
'pending' | 'starting' | 'online';
|
|
43
44
|
|
|
44
45
|
interface AdapterRecord extends AdapterDescriptor {
|
|
45
46
|
readonly endpoint: EndpointInstance;
|
|
46
47
|
readonly segments?: AdapterSegmentPolicy;
|
|
47
|
-
readonly unconfigured: boolean;
|
|
48
48
|
started: boolean;
|
|
49
49
|
open: boolean;
|
|
50
50
|
stopped: boolean;
|
|
51
|
-
|
|
52
|
-
failed: boolean;
|
|
53
|
-
/** start() was invoked at least once (may still be in flight). */
|
|
51
|
+
stopping?: Promise<void>;
|
|
54
52
|
startAttempted: boolean;
|
|
55
53
|
}
|
|
56
54
|
|
|
57
55
|
export class AdapterIndex {
|
|
58
56
|
readonly $projection = 'zhin.adapter-index/1' as const;
|
|
59
57
|
readonly #records = new Map<CapabilityId, AdapterRecord>();
|
|
58
|
+
readonly [generationAdmissionSource]: readonly GenerationAdmissionGate[];
|
|
60
59
|
readonly #order: readonly AdapterRecord[];
|
|
61
|
-
/** True after `open()` until `close()` / `stop()` — late starts may open themselves. */
|
|
62
|
-
#admissionOpen = false;
|
|
63
|
-
readonly #startTimeoutMs: number;
|
|
64
|
-
/** Final give-up budget for deferred starts (never-settling start promises). */
|
|
65
|
-
readonly #deferredGiveUpMs: number;
|
|
66
60
|
|
|
67
61
|
private constructor(
|
|
68
62
|
records: readonly AdapterRecord[],
|
|
69
|
-
|
|
70
|
-
deferredGiveUpMs: number,
|
|
63
|
+
admission: GenerationAdmissionGate,
|
|
71
64
|
) {
|
|
72
65
|
this.#order = Object.freeze([...records]);
|
|
73
|
-
this
|
|
74
|
-
this.#deferredGiveUpMs = deferredGiveUpMs;
|
|
66
|
+
this[generationAdmissionSource] = Object.freeze([admission]);
|
|
75
67
|
for (const record of records) this.#records.set(record.id, record);
|
|
76
68
|
}
|
|
77
69
|
|
|
78
70
|
static async create(
|
|
79
71
|
slots: readonly Readonly<CapabilitySlot<AdapterDefinition>>[],
|
|
80
72
|
snapshot: RuntimeSnapshot,
|
|
81
|
-
|
|
82
|
-
readonly startTimeoutMs?: number;
|
|
83
|
-
readonly deferredGiveUpMs?: number;
|
|
84
|
-
} = {},
|
|
73
|
+
signal: AbortSignal,
|
|
85
74
|
): Promise<AdapterIndex> {
|
|
86
75
|
const records: AdapterRecord[] = [];
|
|
87
|
-
const
|
|
76
|
+
const admission = createGenerationAdmissionGate();
|
|
88
77
|
try {
|
|
89
78
|
for (const slot of [...slots].sort((left, right) => left.id.localeCompare(right.id))) {
|
|
79
|
+
signal.throwIfAborted();
|
|
90
80
|
for (const expansion of expandEndpointConfigs(slot, snapshot)) {
|
|
91
|
-
const endpoint = await
|
|
92
|
-
|
|
81
|
+
const endpoint = await createEndpoint(slot, snapshot, admission, signal, expansion);
|
|
82
|
+
signal.throwIfAborted();
|
|
93
83
|
records.push({
|
|
94
84
|
id: expansion.id,
|
|
95
85
|
owner: slot.owner,
|
|
96
|
-
// 展开模式下 record name 即 endpoint
|
|
97
|
-
// 保证 Console 展示与 resolve/instance 按 entry
|
|
98
|
-
name: expansion.
|
|
86
|
+
// 展开模式下 record name 即 endpoint id(entry.id),
|
|
87
|
+
// 保证 Console 展示与 resolve/instance 按 entry id 命中唯一 record
|
|
88
|
+
name: expansion.endpointId,
|
|
99
89
|
source: slot.source,
|
|
100
90
|
capabilities: slot.definition.capabilities,
|
|
101
|
-
endpoint
|
|
91
|
+
endpoint,
|
|
102
92
|
...(slot.definition.segments ? { segments: slot.definition.segments } : {}),
|
|
103
|
-
unconfigured: endpoint.unconfigured,
|
|
104
93
|
started: false,
|
|
105
94
|
open: false,
|
|
106
|
-
failed: false,
|
|
107
95
|
startAttempted: false,
|
|
108
|
-
|
|
109
|
-
stopped: endpoint.unconfigured,
|
|
96
|
+
stopped: false,
|
|
110
97
|
});
|
|
111
98
|
}
|
|
112
99
|
}
|
|
113
|
-
|
|
114
|
-
logger.info(formatCompact({
|
|
115
|
-
op: 'adapters_unconfigured',
|
|
116
|
-
count: unconfigured.length,
|
|
117
|
-
names: unconfigured.join(','),
|
|
118
|
-
}));
|
|
119
|
-
}
|
|
120
|
-
return new AdapterIndex(
|
|
121
|
-
records,
|
|
122
|
-
options.startTimeoutMs ?? 3_000,
|
|
123
|
-
options.deferredGiveUpMs ?? 60_000,
|
|
124
|
-
);
|
|
100
|
+
return new AdapterIndex(records, admission);
|
|
125
101
|
} catch (error) {
|
|
126
102
|
await stopRecords(records, error);
|
|
127
103
|
throw error;
|
|
@@ -129,8 +105,8 @@ export class AdapterIndex {
|
|
|
129
105
|
}
|
|
130
106
|
|
|
131
107
|
list(): readonly AdapterDescriptor[] {
|
|
132
|
-
return this.#order.map(({ endpoint: _endpoint,
|
|
133
|
-
started: _started, open: _open, stopped: _stopped,
|
|
108
|
+
return this.#order.map(({ endpoint: _endpoint,
|
|
109
|
+
started: _started, open: _open, stopped: _stopped, stopping: _stopping,
|
|
134
110
|
startAttempted: _startAttempted, segments: _segments,
|
|
135
111
|
...descriptor }) => Object.freeze(descriptor));
|
|
136
112
|
}
|
|
@@ -155,21 +131,21 @@ export class AdapterIndex {
|
|
|
155
131
|
* Resolve a Console `$adapter` + `$endpoint` pair to a capability id.
|
|
156
132
|
* Matches local name, capability id, or owner path segments.
|
|
157
133
|
*/
|
|
158
|
-
resolve(adapter: string,
|
|
134
|
+
resolve(adapter: string, endpointKey: string): CapabilityId | undefined {
|
|
159
135
|
const matches = this.#order.filter((record) =>
|
|
160
|
-
matchesEndpoint(record, adapter,
|
|
136
|
+
matchesEndpoint(record, adapter, endpointKey));
|
|
161
137
|
if (matches.length === 1) return matches[0]?.id;
|
|
162
138
|
if (matches.length === 0) return undefined;
|
|
163
|
-
// Prefer exact localName ===
|
|
164
|
-
const exact = matches.find((record) => record.name ===
|
|
139
|
+
// Prefer exact localName === endpointKey when ambiguous.
|
|
140
|
+
const exact = matches.find((record) => record.name === endpointKey);
|
|
165
141
|
return exact?.id ?? matches[0]?.id;
|
|
166
142
|
}
|
|
167
143
|
|
|
168
144
|
/**
|
|
169
145
|
* Resolve a live EndpointInstance for Host-side side channels (reactions, etc.).
|
|
170
146
|
*/
|
|
171
|
-
instance(adapter: string,
|
|
172
|
-
const id = this.resolve(adapter,
|
|
147
|
+
instance(adapter: string, endpointKey: string): EndpointInstance | undefined {
|
|
148
|
+
const id = this.resolve(adapter, endpointKey);
|
|
173
149
|
if (!id) return undefined;
|
|
174
150
|
return this.#records.get(id)?.endpoint;
|
|
175
151
|
}
|
|
@@ -188,103 +164,59 @@ export class AdapterIndex {
|
|
|
188
164
|
return this.#records.get(id)?.segments;
|
|
189
165
|
}
|
|
190
166
|
|
|
191
|
-
async start(): Promise<void> {
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
record.stopped = true;
|
|
222
|
-
record.failed = true;
|
|
223
|
-
// Swallow a late rejection so it does not become unhandled.
|
|
224
|
-
void startPromise.catch(() => undefined);
|
|
225
|
-
logger.warn(formatCompact({
|
|
226
|
-
op: 'adapter_start_give_up',
|
|
227
|
-
id: record.id,
|
|
228
|
-
name: record.name,
|
|
229
|
-
waitMs: this.#deferredGiveUpMs,
|
|
230
|
-
}));
|
|
231
|
-
}, this.#deferredGiveUpMs);
|
|
232
|
-
giveUp.unref?.();
|
|
233
|
-
void startPromise.then(
|
|
234
|
-
() => {
|
|
235
|
-
clearTimeout(giveUp);
|
|
236
|
-
if (record.stopped || record.started) return;
|
|
237
|
-
record.started = true;
|
|
238
|
-
if (this.#admissionOpen && !record.open) {
|
|
239
|
-
try {
|
|
240
|
-
record.endpoint.open?.();
|
|
241
|
-
record.open = true;
|
|
242
|
-
} catch (openError) {
|
|
243
|
-
logger.warn(formatCompact({
|
|
244
|
-
op: 'adapter_open_after_deferred_fail',
|
|
245
|
-
id: record.id,
|
|
246
|
-
name: record.name,
|
|
247
|
-
error: openError instanceof Error ? openError.message : String(openError),
|
|
248
|
-
}));
|
|
249
|
-
}
|
|
250
|
-
}
|
|
251
|
-
},
|
|
252
|
-
(startError) => {
|
|
253
|
-
clearTimeout(giveUp);
|
|
254
|
-
if (record.stopped) return;
|
|
255
|
-
record.stopped = true;
|
|
256
|
-
record.failed = true;
|
|
257
|
-
logger.warn(formatCompact({
|
|
258
|
-
op: 'adapter_start_soft_fail',
|
|
259
|
-
id: record.id,
|
|
260
|
-
name: record.name,
|
|
261
|
-
error: startError instanceof Error ? startError.message : String(startError),
|
|
262
|
-
stack: startError instanceof Error ? startError.stack : undefined,
|
|
263
|
-
}));
|
|
264
|
-
},
|
|
265
|
-
);
|
|
266
|
-
return;
|
|
167
|
+
async start(signal: AbortSignal = new AbortController().signal): Promise<void> {
|
|
168
|
+
try {
|
|
169
|
+
// Sequential readiness gives the candidate one owned in-flight start at a
|
|
170
|
+
// time. A sibling failure can therefore never leave an un-awaited start
|
|
171
|
+
// promise mutating resources after rollback has returned.
|
|
172
|
+
for (const record of this.#order) {
|
|
173
|
+
if (record.started || record.stopped) continue;
|
|
174
|
+
record.startAttempted = true;
|
|
175
|
+
signal.throwIfAborted();
|
|
176
|
+
let stopOnAbort!: () => void;
|
|
177
|
+
const aborted = new Promise<never>((_resolve, reject) => {
|
|
178
|
+
stopOnAbort = () => {
|
|
179
|
+
void stopRecord(record).then(
|
|
180
|
+
() => reject(signal.reason ?? new Error('Adapter Endpoint start aborted')),
|
|
181
|
+
(cleanupError) => reject(new GenerationCompensationError(
|
|
182
|
+
[signal.reason, cleanupError],
|
|
183
|
+
'Adapter Endpoint cancellation cleanup failed',
|
|
184
|
+
{ cause: cleanupError },
|
|
185
|
+
)),
|
|
186
|
+
);
|
|
187
|
+
};
|
|
188
|
+
});
|
|
189
|
+
signal.addEventListener('abort', stopOnAbort, { once: true });
|
|
190
|
+
try {
|
|
191
|
+
await Promise.race([
|
|
192
|
+
Promise.resolve(record.endpoint.start?.(signal)),
|
|
193
|
+
aborted,
|
|
194
|
+
]);
|
|
195
|
+
} finally {
|
|
196
|
+
signal.removeEventListener('abort', stopOnAbort);
|
|
267
197
|
}
|
|
268
|
-
|
|
269
|
-
record.
|
|
270
|
-
|
|
271
|
-
// Startup connect failures are logged once here (with stack); Endpoint
|
|
272
|
-
// implementations must NOT re-log them at error level.
|
|
273
|
-
logger.warn(formatCompact({
|
|
274
|
-
op: 'adapter_start_soft_fail',
|
|
275
|
-
id: record.id,
|
|
276
|
-
name: record.name,
|
|
277
|
-
error: message,
|
|
278
|
-
stack: error instanceof Error ? error.stack : undefined,
|
|
279
|
-
}));
|
|
280
|
-
// No endpoint.stop() here: adapter Endpoints self-stop in their start()
|
|
281
|
-
// catch by convention (verified across icqq/qq/slack/… endpoints).
|
|
198
|
+
signal.throwIfAborted();
|
|
199
|
+
if (record.stopped) throw new Error(`Adapter Endpoint stopped during start: ${record.id}`);
|
|
200
|
+
record.started = true;
|
|
282
201
|
}
|
|
283
|
-
})
|
|
202
|
+
} catch (error) {
|
|
203
|
+
await stopRecords(this.#order, error);
|
|
204
|
+
throw error;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** Required readiness boundary: no generation can publish a partial Endpoint set. */
|
|
209
|
+
async activate(signal: AbortSignal): Promise<void> {
|
|
210
|
+
try {
|
|
211
|
+
await this.start(signal);
|
|
212
|
+
this.open();
|
|
213
|
+
} catch (error) {
|
|
214
|
+
await stopRecords(this.#order, error);
|
|
215
|
+
throw error;
|
|
216
|
+
}
|
|
284
217
|
}
|
|
285
218
|
|
|
286
219
|
open(): void {
|
|
287
|
-
this.#admissionOpen = true;
|
|
288
220
|
const errors: unknown[] = [];
|
|
289
221
|
for (const record of this.#order) {
|
|
290
222
|
if (!record.started || record.open || record.stopped) continue;
|
|
@@ -299,7 +231,6 @@ export class AdapterIndex {
|
|
|
299
231
|
}
|
|
300
232
|
|
|
301
233
|
async close(): Promise<void> {
|
|
302
|
-
this.#admissionOpen = false;
|
|
303
234
|
const stack = new DisposeStack();
|
|
304
235
|
for (const record of this.#order) {
|
|
305
236
|
if (!record.open || record.stopped) continue;
|
|
@@ -341,7 +272,7 @@ export function isAdapterIndex(value: unknown): value is AdapterIndex {
|
|
|
341
272
|
function matchesEndpoint(
|
|
342
273
|
record: AdapterRecord,
|
|
343
274
|
adapter: string,
|
|
344
|
-
|
|
275
|
+
endpointKey: string,
|
|
345
276
|
): boolean {
|
|
346
277
|
// 消息上的 $adapter 是 CapabilityId 的 localName 段(多 endpoint 展开后形如
|
|
347
278
|
// `icqq~8596238`)。CapabilityId 段分隔符是 \0(owner\0feature\0localName),
|
|
@@ -357,10 +288,10 @@ function matchesEndpoint(
|
|
|
357
288
|
// activity-feedback resolve with that id; slot.localName alone is not enough
|
|
358
289
|
// when multiple plugin instances share localName "icqq".
|
|
359
290
|
const liveName = endpointLiveName(record.endpoint);
|
|
360
|
-
const endpointOk = record.name ===
|
|
361
|
-
|| record.id ===
|
|
362
|
-
|| record.id.endsWith(`/${
|
|
363
|
-
|| (liveName !== undefined && liveName ===
|
|
291
|
+
const endpointOk = record.name === endpointKey
|
|
292
|
+
|| record.id === endpointKey
|
|
293
|
+
|| record.id.endsWith(`/${endpointKey}`)
|
|
294
|
+
|| (liveName !== undefined && liveName === endpointKey);
|
|
364
295
|
return adapterOk && endpointOk;
|
|
365
296
|
}
|
|
366
297
|
|
|
@@ -370,10 +301,8 @@ function endpointLiveName(endpoint: EndpointInstance): string | undefined {
|
|
|
370
301
|
}
|
|
371
302
|
|
|
372
303
|
function endpointPhase(record: AdapterRecord): AdapterEndpointPhase {
|
|
373
|
-
if (record.unconfigured) return 'unconfigured';
|
|
374
|
-
if (record.failed) return 'failed';
|
|
375
304
|
if (record.open && !record.stopped) return 'online';
|
|
376
|
-
if (record.startAttempted) return 'starting';
|
|
305
|
+
if (record.startAttempted && !record.started) return 'starting';
|
|
377
306
|
return 'pending';
|
|
378
307
|
}
|
|
379
308
|
|
|
@@ -383,26 +312,15 @@ function assertEndpoint(value: unknown, id: CapabilityId): asserts value is Endp
|
|
|
383
312
|
}
|
|
384
313
|
}
|
|
385
314
|
|
|
386
|
-
/**
|
|
387
|
-
* Adapter `resolveXxxConfig` helpers report missing config/credentials as
|
|
388
|
-
* TypeError("… requires …") by convention; only those are expected failures.
|
|
389
|
-
*/
|
|
390
|
-
function isUnconfiguredError(error: unknown): boolean {
|
|
391
|
-
return (
|
|
392
|
-
error instanceof TypeError
|
|
393
|
-
&& /requires|not configured|missing|未配置|缺少/i.test(error.message)
|
|
394
|
-
);
|
|
395
|
-
}
|
|
396
|
-
|
|
397
315
|
/** 单个实例配置展开的 endpoint 描述(多账号适配器经 `endpoints` 数组声明)。 */
|
|
398
316
|
interface EndpointExpansion {
|
|
399
317
|
readonly id: CapabilityId;
|
|
400
|
-
readonly
|
|
318
|
+
readonly endpointId: string;
|
|
401
319
|
readonly config?: Readonly<Record<string, unknown>>;
|
|
402
320
|
}
|
|
403
321
|
|
|
404
322
|
/**
|
|
405
|
-
* 实例配置的 endpoint 展开:插件实例 config 含非空 `endpoints: [{
|
|
323
|
+
* 实例配置的 endpoint 展开:插件实例 config 含非空 `endpoints: [{id, ...覆盖}]` 时
|
|
406
324
|
* 按数组一一创建 endpoint(基础配置为实例 config 去掉 `endpoints` 键,逐项合并),
|
|
407
325
|
* 否则按实例 config 创建单个 endpoint(历史行为)。
|
|
408
326
|
*/
|
|
@@ -414,137 +332,64 @@ function expandEndpointConfigs(
|
|
|
414
332
|
| { endpoints?: unknown }
|
|
415
333
|
| undefined;
|
|
416
334
|
const raw = config?.endpoints;
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
&& (entry as { name: string }).name.length > 0)
|
|
422
|
-
: [];
|
|
335
|
+
if (raw !== undefined && !Array.isArray(raw)) {
|
|
336
|
+
throw new TypeError(`Adapter ${slot.id} endpoints must be an array`);
|
|
337
|
+
}
|
|
338
|
+
const entries = (raw ?? []) as readonly unknown[];
|
|
423
339
|
if (entries.length === 0) {
|
|
424
|
-
|
|
425
|
-
logger.warn(formatCompact({
|
|
426
|
-
op: 'adapter_endpoints_entries_dropped',
|
|
427
|
-
id: slot.id,
|
|
428
|
-
reason: 'every endpoints entry is missing a non-empty string name',
|
|
429
|
-
}));
|
|
430
|
-
}
|
|
431
|
-
return Object.freeze([{ id: slot.id, name: slot.localName }]);
|
|
340
|
+
return Object.freeze([{ id: slot.id, endpointId: slot.localName }]);
|
|
432
341
|
}
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
id: slot.id,
|
|
439
|
-
name: entry.name,
|
|
440
|
-
}));
|
|
441
|
-
return false;
|
|
342
|
+
const normalized = entries.map((entry, index) => {
|
|
343
|
+
if (!entry || typeof entry !== 'object'
|
|
344
|
+
|| typeof (entry as { id?: unknown }).id !== 'string'
|
|
345
|
+
|| (entry as { id: string }).id.length === 0) {
|
|
346
|
+
throw new TypeError(`Adapter ${slot.id} endpoints[${index}].id must be a non-empty string`);
|
|
442
347
|
}
|
|
443
|
-
return
|
|
348
|
+
return entry as Record<string, unknown> & { id: string };
|
|
444
349
|
});
|
|
445
|
-
//
|
|
350
|
+
// `~` 是 record id 的分隔符、\0 是 CapabilityId 的分隔符,混入会破坏解析。
|
|
351
|
+
for (const entry of normalized) {
|
|
352
|
+
if (/[~\0]/u.test(entry.id)) {
|
|
353
|
+
throw new TypeError(`Adapter ${slot.id} endpoint id contains a reserved delimiter: ${entry.id}`);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
446
356
|
const seen = new Set<string>();
|
|
447
|
-
const
|
|
448
|
-
if (seen.has(entry.
|
|
449
|
-
|
|
450
|
-
op: 'adapter_endpoint_name_duplicate',
|
|
451
|
-
id: slot.id,
|
|
452
|
-
name: entry.name,
|
|
453
|
-
}));
|
|
454
|
-
return false;
|
|
357
|
+
for (const entry of normalized) {
|
|
358
|
+
if (seen.has(entry.id)) {
|
|
359
|
+
throw new TypeError(`Adapter ${slot.id} endpoint id is duplicated: ${entry.id}`);
|
|
455
360
|
}
|
|
456
|
-
seen.add(entry.
|
|
457
|
-
return true;
|
|
458
|
-
});
|
|
459
|
-
if (deduped.length === 0) {
|
|
460
|
-
return Object.freeze([{ id: slot.id, name: slot.localName }]);
|
|
361
|
+
seen.add(entry.id);
|
|
461
362
|
}
|
|
462
363
|
const { endpoints: _drop, ...base } = (config ?? {}) as Record<string, unknown>;
|
|
463
|
-
return Object.freeze(
|
|
464
|
-
id: `${slot.id}~${entry.
|
|
465
|
-
|
|
466
|
-
config: Object.freeze({ ...base, ...entry,
|
|
364
|
+
return Object.freeze(normalized.map((entry) => Object.freeze({
|
|
365
|
+
id: `${slot.id}~${entry.id}` as CapabilityId,
|
|
366
|
+
endpointId: entry.id,
|
|
367
|
+
config: Object.freeze({ ...base, ...entry, id: entry.id }),
|
|
467
368
|
})));
|
|
468
369
|
}
|
|
469
370
|
|
|
470
|
-
async function
|
|
371
|
+
async function createEndpoint(
|
|
471
372
|
slot: Readonly<CapabilitySlot<AdapterDefinition>>,
|
|
472
373
|
snapshot: RuntimeSnapshot,
|
|
374
|
+
admission: GenerationAdmissionGate,
|
|
375
|
+
signal: AbortSignal,
|
|
473
376
|
expansion?: EndpointExpansion,
|
|
474
|
-
): Promise<
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
...createCapabilityContext(snapshot, slot.owner),
|
|
480
|
-
...(expansion?.config ? { config: expansion.config } : {}),
|
|
481
|
-
id: expansion?.id ?? slot.id,
|
|
482
|
-
name: slot.localName,
|
|
483
|
-
}),
|
|
484
|
-
);
|
|
485
|
-
} catch (error) {
|
|
486
|
-
// Missing config / credentials: degrade to an inert stub so the rest of
|
|
487
|
-
// the generation still boots. Anything else (network failures, bugs in
|
|
488
|
-
// create()) is unexpected — keep the stub but surface a warning instead
|
|
489
|
-
// of silently swallowing it at debug level.
|
|
490
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
491
|
-
const log = isUnconfiguredError(error) ? logger.debug.bind(logger) : logger.warn.bind(logger);
|
|
492
|
-
log(formatCompact({
|
|
493
|
-
op: 'adapter_create_soft_fail',
|
|
377
|
+
): Promise<EndpointInstance> {
|
|
378
|
+
const endpoint = await slot.definition.create(
|
|
379
|
+
Object.freeze({
|
|
380
|
+
...createCapabilityContext(snapshot, slot.owner, admission, signal),
|
|
381
|
+
...(expansion?.config ? { config: expansion.config } : {}),
|
|
494
382
|
id: expansion?.id ?? slot.id,
|
|
495
|
-
name:
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
return {
|
|
499
|
-
instance: createUnconfiguredEndpoint(message),
|
|
500
|
-
unconfigured: true,
|
|
501
|
-
};
|
|
502
|
-
}
|
|
503
|
-
// Programming errors (create() did not return an Endpoint) must surface:
|
|
504
|
-
// they propagate to AdapterIndex.create's catch, which disposes the records
|
|
505
|
-
// created so far instead of hiding the bug behind an unconfigured stub.
|
|
383
|
+
name: slot.localName,
|
|
384
|
+
}),
|
|
385
|
+
);
|
|
506
386
|
assertEndpoint(endpoint, expansion?.id ?? slot.id);
|
|
507
387
|
assertDeclaredEndpointOperations(
|
|
508
388
|
endpoint,
|
|
509
389
|
slot.definition.operations,
|
|
510
390
|
String(expansion?.id ?? slot.id),
|
|
511
391
|
);
|
|
512
|
-
return
|
|
513
|
-
}
|
|
514
|
-
|
|
515
|
-
function createUnconfiguredEndpoint(reason: string): EndpointInstance {
|
|
516
|
-
return Object.freeze({
|
|
517
|
-
start() {
|
|
518
|
-
throw new Error(`Adapter unconfigured: ${reason}`);
|
|
519
|
-
},
|
|
520
|
-
open() {},
|
|
521
|
-
close() {},
|
|
522
|
-
stop() {},
|
|
523
|
-
send() {
|
|
524
|
-
throw new Error(`Adapter unconfigured: ${reason}`);
|
|
525
|
-
},
|
|
526
|
-
});
|
|
527
|
-
}
|
|
528
|
-
|
|
529
|
-
function withTimeout<T>(
|
|
530
|
-
promise: Promise<T> | T | undefined,
|
|
531
|
-
ms: number,
|
|
532
|
-
message: string,
|
|
533
|
-
): Promise<T | undefined> {
|
|
534
|
-
if (promise === undefined) return Promise.resolve(undefined);
|
|
535
|
-
return new Promise<T | undefined>((resolve, reject) => {
|
|
536
|
-
const timer = setTimeout(() => reject(new Error(message)), ms);
|
|
537
|
-
Promise.resolve(promise).then(
|
|
538
|
-
(value) => {
|
|
539
|
-
clearTimeout(timer);
|
|
540
|
-
resolve(value);
|
|
541
|
-
},
|
|
542
|
-
(error) => {
|
|
543
|
-
clearTimeout(timer);
|
|
544
|
-
reject(error);
|
|
545
|
-
},
|
|
546
|
-
);
|
|
547
|
-
});
|
|
392
|
+
return endpoint;
|
|
548
393
|
}
|
|
549
394
|
|
|
550
395
|
async function stopRecords(
|
|
@@ -554,17 +399,13 @@ async function stopRecords(
|
|
|
554
399
|
const stack = new DisposeStack();
|
|
555
400
|
for (const record of records) {
|
|
556
401
|
if (record.stopped) continue;
|
|
557
|
-
stack.add(
|
|
558
|
-
record.stopped = true;
|
|
559
|
-
record.open = false;
|
|
560
|
-
await record.endpoint.stop?.();
|
|
561
|
-
});
|
|
402
|
+
stack.add(() => stopRecord(record));
|
|
562
403
|
}
|
|
563
404
|
try {
|
|
564
405
|
await stack.dispose();
|
|
565
406
|
} catch (stopError) {
|
|
566
407
|
if (primaryError !== undefined) {
|
|
567
|
-
throw new
|
|
408
|
+
throw new GenerationCompensationError(
|
|
568
409
|
[primaryError, stopError],
|
|
569
410
|
'Adapter prepare and Endpoint cleanup both failed',
|
|
570
411
|
{ cause: stopError },
|
|
@@ -573,3 +414,17 @@ async function stopRecords(
|
|
|
573
414
|
throw stopError;
|
|
574
415
|
}
|
|
575
416
|
}
|
|
417
|
+
|
|
418
|
+
function stopRecord(record: AdapterRecord): Promise<void> {
|
|
419
|
+
if (record.stopped) return Promise.resolve();
|
|
420
|
+
if (record.stopping) return record.stopping;
|
|
421
|
+
const stopping = Promise.resolve(record.endpoint.stop?.()).then(() => {
|
|
422
|
+
record.stopped = true;
|
|
423
|
+
record.open = false;
|
|
424
|
+
});
|
|
425
|
+
record.stopping = stopping;
|
|
426
|
+
void stopping.catch(() => undefined).finally(() => {
|
|
427
|
+
if (!record.stopped && record.stopping === stopping) record.stopping = undefined;
|
|
428
|
+
});
|
|
429
|
+
return stopping;
|
|
430
|
+
}
|
package/src/definition.ts
CHANGED
|
@@ -32,9 +32,9 @@ export interface EndpointInstance<TResult = unknown> {
|
|
|
32
32
|
readonly management?: EndpointManagement;
|
|
33
33
|
/** Optional platform-neutral control surface for existing messages. */
|
|
34
34
|
readonly control?: EndpointControl;
|
|
35
|
-
/**
|
|
36
|
-
start?(): void | Promise<void>;
|
|
37
|
-
/** Opens
|
|
35
|
+
/** Required readiness; must observe abort and settle before rollback returns. */
|
|
36
|
+
start?(signal: AbortSignal): void | Promise<void>;
|
|
37
|
+
/** Opens Endpoint-local flow behind the candidate generation admission gate. */
|
|
38
38
|
open?(): void;
|
|
39
39
|
/** Stops new inbound events while preserving in-flight work. */
|
|
40
40
|
close?(): void | Promise<void>;
|