@zhin.js/adapter 1.1.7 → 1.1.9
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 +5 -5
- 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/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[];
|
|
@@ -42,7 +40,9 @@ export declare class AdapterIndex {
|
|
|
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,34 +1,27 @@
|
|
|
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.endpointId);
|
|
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,
|
|
@@ -37,26 +30,16 @@ export class AdapterIndex {
|
|
|
37
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() {
|
|
@@ -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)
|
|
@@ -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
|
}
|
|
@@ -307,14 +238,6 @@ function assertEndpoint(value, id) {
|
|
|
307
238
|
throw new TypeError(`Adapter ${id} create() must return an Endpoint instance`);
|
|
308
239
|
}
|
|
309
240
|
}
|
|
310
|
-
/**
|
|
311
|
-
* Adapter `resolveXxxConfig` helpers report missing config/credentials as
|
|
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
241
|
/**
|
|
319
242
|
* 实例配置的 endpoint 展开:插件实例 config 含非空 `endpoints: [{id, ...覆盖}]` 时
|
|
320
243
|
* 按数组一一创建 endpoint(基础配置为实例 config 去掉 `endpoints` 键,逐项合并),
|
|
@@ -323,137 +246,82 @@ function isUnconfiguredError(error) {
|
|
|
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
|
-
if (Array.isArray(raw) && raw.length > 0) {
|
|
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 id',
|
|
337
|
-
}));
|
|
338
|
-
}
|
|
339
254
|
return Object.freeze([{ id: slot.id, endpointId: slot.localName }]);
|
|
340
255
|
}
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
id: slot.id,
|
|
347
|
-
endpointId: entry.id,
|
|
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
|
|
271
|
+
for (const entry of normalized) {
|
|
356
272
|
if (seen.has(entry.id)) {
|
|
357
|
-
|
|
358
|
-
op: 'adapter_endpoint_id_duplicate',
|
|
359
|
-
id: slot.id,
|
|
360
|
-
endpointId: entry.id,
|
|
361
|
-
}));
|
|
362
|
-
return false;
|
|
273
|
+
throw new TypeError(`Adapter ${slot.id} endpoint id is duplicated: ${entry.id}`);
|
|
363
274
|
}
|
|
364
275
|
seen.add(entry.id);
|
|
365
|
-
return true;
|
|
366
|
-
});
|
|
367
|
-
if (deduped.length === 0) {
|
|
368
|
-
return Object.freeze([{ id: slot.id, endpointId: slot.localName }]);
|
|
369
276
|
}
|
|
370
277
|
const { endpoints: _drop, ...base } = (config ?? {});
|
|
371
|
-
return Object.freeze(
|
|
278
|
+
return Object.freeze(normalized.map((entry) => Object.freeze({
|
|
372
279
|
id: `${slot.id}~${entry.id}`,
|
|
373
280
|
endpointId: entry.id,
|
|
374
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?.endpointId ?? 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>;
|
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
import {
|
|
2
|
-
export type { LegacyEndpointControlSurface } from '@zhin.js/im-contract';
|
|
1
|
+
import type { ConversationRef, MessageRef } from '@zhin.js/im-contract';
|
|
3
2
|
/**
|
|
4
3
|
* Transport-neutral control plane for a live endpoint.
|
|
5
4
|
*
|
|
@@ -8,27 +7,20 @@ export type { LegacyEndpointControlSurface } from '@zhin.js/im-contract';
|
|
|
8
7
|
* to know a protocol's method names or identifier layout.
|
|
9
8
|
*/
|
|
10
9
|
export interface EndpointControl {
|
|
11
|
-
recall?(message:
|
|
12
|
-
edit?(message:
|
|
13
|
-
addReaction?(message:
|
|
10
|
+
recall?(message: MessageRef): Promise<void>;
|
|
11
|
+
edit?(message: MessageRef, content: unknown): Promise<string | null>;
|
|
12
|
+
addReaction?(message: MessageRef, emoji: string, hint?: {
|
|
14
13
|
readonly sceneType?: string;
|
|
15
14
|
readonly channelId?: string;
|
|
16
15
|
}): Promise<string | null>;
|
|
17
|
-
removeReaction?(message:
|
|
18
|
-
typing?(conversation:
|
|
16
|
+
removeReaction?(message: MessageRef, reactionId: string): Promise<void>;
|
|
17
|
+
typing?(conversation: ConversationRef, active?: boolean): Promise<void>;
|
|
19
18
|
}
|
|
20
19
|
export interface EndpointWithControl {
|
|
21
20
|
readonly control?: EndpointControl;
|
|
22
21
|
}
|
|
23
|
-
/**
|
|
24
|
-
|
|
25
|
-
* Adapter only: it is a migration bridge for existing classic protocol
|
|
26
|
-
* endpoints (`LegacyEndpointControlSurface` lives in `@zhin.js/im-contract`),
|
|
27
|
-
* not an IM Core extension point. New adapters must expose `control` directly.
|
|
28
|
-
* 下线条件:classic Plugin 轨下线后,legacy 分支与 LegacyEndpointControlSurface
|
|
29
|
-
* 一并删除。
|
|
30
|
-
*/
|
|
31
|
-
export declare function resolveEndpointControl(endpoint: unknown): EndpointControl | undefined;
|
|
22
|
+
/** Reads the canonical control port without probing protocol-specific methods. */
|
|
23
|
+
export declare function endpointControlOf(endpoint: unknown): EndpointControl | undefined;
|
|
32
24
|
/** Checks only an Endpoint's explicit `control` port, never the legacy bridge. */
|
|
33
25
|
export declare function hasExplicitEndpointOperation(endpoint: unknown, operation: 'recall' | 'edit' | 'reaction' | 'typing'): boolean;
|
|
34
26
|
/** Rejects a declaration that cannot be fulfilled by the explicit control port. */
|
package/lib/endpoint-control.js
CHANGED
|
@@ -1,57 +1,9 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
* Resolves the public control port. The legacy branch is deliberately kept in
|
|
4
|
-
* Adapter only: it is a migration bridge for existing classic protocol
|
|
5
|
-
* endpoints (`LegacyEndpointControlSurface` lives in `@zhin.js/im-contract`),
|
|
6
|
-
* not an IM Core extension point. New adapters must expose `control` directly.
|
|
7
|
-
* 下线条件:classic Plugin 轨下线后,legacy 分支与 LegacyEndpointControlSurface
|
|
8
|
-
* 一并删除。
|
|
9
|
-
*/
|
|
10
|
-
export function resolveEndpointControl(endpoint) {
|
|
1
|
+
/** Reads the canonical control port without probing protocol-specific methods. */
|
|
2
|
+
export function endpointControlOf(endpoint) {
|
|
11
3
|
if (!endpoint || typeof endpoint !== 'object')
|
|
12
4
|
return undefined;
|
|
13
5
|
const explicit = endpoint.control;
|
|
14
|
-
|
|
15
|
-
return explicit;
|
|
16
|
-
const legacy = endpoint;
|
|
17
|
-
const recall = legacy.recallMessage ?? legacy.$recallMessage;
|
|
18
|
-
const edit = legacy.editMessage ?? legacy.$editMessage;
|
|
19
|
-
const addReaction = legacy.addReaction ?? legacy.$addReaction;
|
|
20
|
-
const removeReaction = legacy.removeReaction ?? legacy.$removeReaction;
|
|
21
|
-
const typing = legacy.typing ?? legacy.$typing;
|
|
22
|
-
if (!recall && !edit && !addReaction && !removeReaction && !typing)
|
|
23
|
-
return undefined;
|
|
24
|
-
return Object.freeze({
|
|
25
|
-
...(recall
|
|
26
|
-
? { recall: (message) => recall.call(endpoint, legacyMessageId(message)) }
|
|
27
|
-
: {}),
|
|
28
|
-
...(edit
|
|
29
|
-
? {
|
|
30
|
-
edit: (message, content) => edit.call(endpoint, legacyMessageId(message), content),
|
|
31
|
-
}
|
|
32
|
-
: {}),
|
|
33
|
-
...(addReaction
|
|
34
|
-
? {
|
|
35
|
-
addReaction: (message, emoji, hint) => addReaction.call(endpoint, legacyMessageId(message), emoji, hint),
|
|
36
|
-
}
|
|
37
|
-
: {}),
|
|
38
|
-
...(removeReaction
|
|
39
|
-
? {
|
|
40
|
-
removeReaction: (message, reactionId) => removeReaction.call(endpoint, legacyMessageId(message), reactionId),
|
|
41
|
-
}
|
|
42
|
-
: {}),
|
|
43
|
-
...(typing
|
|
44
|
-
? {
|
|
45
|
-
typing: (conversation, active) => typing.call(endpoint, legacyConversationTarget(conversation), active),
|
|
46
|
-
}
|
|
47
|
-
: {}),
|
|
48
|
-
});
|
|
49
|
-
}
|
|
50
|
-
function legacyMessageId(message) {
|
|
51
|
-
return typeof message === 'string' ? message : formatLegacyMessageRef(message);
|
|
52
|
-
}
|
|
53
|
-
function legacyConversationTarget(conversation) {
|
|
54
|
-
return typeof conversation === 'string' ? conversation : formatLegacyConversationRef(conversation);
|
|
6
|
+
return explicit && typeof explicit === 'object' ? explicit : undefined;
|
|
55
7
|
}
|
|
56
8
|
/** Checks only an Endpoint's explicit `control` port, never the legacy bridge. */
|
|
57
9
|
export function hasExplicitEndpointOperation(endpoint, operation) {
|