@zhin.js/adapter 1.1.7 → 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 +7 -7
- package/lib/adapter-index.js +103 -235
- package/lib/definition.d.ts +3 -3
- package/lib/endpoint-control.d.ts +8 -16
- package/lib/endpoint-control.js +3 -51
- package/lib/provider.js +2 -14
- package/package.json +4 -4
- package/src/adapter-index.ts +115 -260
- package/src/definition.ts +3 -3
- package/src/endpoint-control.ts +9 -73
- package/src/provider.ts +3 -16
package/lib/provider.js
CHANGED
|
@@ -16,29 +16,17 @@ const adapterFeature = defineFeatureProvider({
|
|
|
16
16
|
},
|
|
17
17
|
runtime: {
|
|
18
18
|
async project(slots, context) {
|
|
19
|
-
const index = await AdapterIndex.create(slots, context.snapshot);
|
|
20
|
-
let previousIndex;
|
|
19
|
+
const index = await AdapterIndex.create(slots, context.snapshot, context.signal);
|
|
21
20
|
return {
|
|
22
21
|
value: index,
|
|
23
22
|
dispose: () => index.stop(),
|
|
24
23
|
handoff: {
|
|
25
|
-
|
|
26
|
-
previousIndex = previousAdapterIndex(previous);
|
|
27
|
-
return previousIndex?.close();
|
|
28
|
-
},
|
|
29
|
-
activateNext: () => index.start(),
|
|
24
|
+
activateNext: (signal) => index.activate(signal),
|
|
30
25
|
deactivateNext: () => index.stop(),
|
|
31
|
-
resumePrevious() {
|
|
32
|
-
previousIndex?.open();
|
|
33
|
-
},
|
|
34
|
-
openNext: () => index.open(),
|
|
35
26
|
},
|
|
36
27
|
};
|
|
37
28
|
},
|
|
38
29
|
},
|
|
39
30
|
});
|
|
40
|
-
function previousAdapterIndex(snapshot) {
|
|
41
|
-
return snapshot.projections.get(adapterFeatureId);
|
|
42
|
-
}
|
|
43
31
|
export { adapterFeature };
|
|
44
32
|
export default adapterFeature;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zhin.js/adapter",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.8",
|
|
4
4
|
"description": "Convention-based Adapter and Endpoint Feature for Zhin Plugin Runtime",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./lib/index.js",
|
|
@@ -18,15 +18,15 @@
|
|
|
18
18
|
],
|
|
19
19
|
"dependencies": {
|
|
20
20
|
"yaml": "^2.9.0",
|
|
21
|
-
"@zhin.js/feature-kit": "1.0.
|
|
21
|
+
"@zhin.js/feature-kit": "1.0.9",
|
|
22
22
|
"@zhin.js/im-contract": "1.0.3",
|
|
23
23
|
"@zhin.js/logger": "1.0.76",
|
|
24
|
-
"@zhin.js/plugin-runtime": "1.1.
|
|
24
|
+
"@zhin.js/plugin-runtime": "1.1.6"
|
|
25
25
|
},
|
|
26
26
|
"devDependencies": {
|
|
27
27
|
"@types/node": "^26.1.2",
|
|
28
28
|
"typescript": "^6.0.3",
|
|
29
|
-
"@zhin.js/command": "1.0.
|
|
29
|
+
"@zhin.js/command": "1.0.12"
|
|
30
30
|
},
|
|
31
31
|
"zhin": {
|
|
32
32
|
"protocol": 1,
|
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,57 +40,46 @@ 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,
|
|
@@ -98,30 +88,16 @@ export class AdapterIndex {
|
|
|
98
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
|
}
|
|
@@ -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;
|
|
@@ -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,17 +312,6 @@ 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;
|
|
@@ -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 { id: string }).id.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
|
-
if (Array.isArray(raw) && raw.length > 0) {
|
|
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 id',
|
|
429
|
-
}));
|
|
430
|
-
}
|
|
431
340
|
return Object.freeze([{ id: slot.id, endpointId: slot.localName }]);
|
|
432
341
|
}
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
id: slot.id,
|
|
439
|
-
endpointId: entry.id,
|
|
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
|
|
357
|
+
for (const entry of normalized) {
|
|
448
358
|
if (seen.has(entry.id)) {
|
|
449
|
-
|
|
450
|
-
op: 'adapter_endpoint_id_duplicate',
|
|
451
|
-
id: slot.id,
|
|
452
|
-
endpointId: entry.id,
|
|
453
|
-
}));
|
|
454
|
-
return false;
|
|
359
|
+
throw new TypeError(`Adapter ${slot.id} endpoint id is duplicated: ${entry.id}`);
|
|
455
360
|
}
|
|
456
361
|
seen.add(entry.id);
|
|
457
|
-
return true;
|
|
458
|
-
});
|
|
459
|
-
if (deduped.length === 0) {
|
|
460
|
-
return Object.freeze([{ id: slot.id, endpointId: slot.localName }]);
|
|
461
362
|
}
|
|
462
363
|
const { endpoints: _drop, ...base } = (config ?? {}) as Record<string, unknown>;
|
|
463
|
-
return Object.freeze(
|
|
364
|
+
return Object.freeze(normalized.map((entry) => Object.freeze({
|
|
464
365
|
id: `${slot.id}~${entry.id}` as CapabilityId,
|
|
465
366
|
endpointId: entry.id,
|
|
466
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>;
|