@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 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 handoff。
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 handoff 管理。
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`. During the 4.x migration, `resolveEndpointControl()` can adapt
43
- legacy `recallMessage` / `$recallMessage` / reaction methods, but that bridge
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
 
@@ -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' | 'failed' | 'unconfigured';
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, options?: {
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>;
@@ -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
- /** True after `open()` until `close()` / `stop()` — late starts may open themselves. */
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.#startTimeoutMs = startTimeoutMs;
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, options = {}) {
16
+ static async create(slots, snapshot, signal) {
24
17
  const records = [];
25
- const unconfigured = [];
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 createEndpointSoft(slot, snapshot, expansion);
30
- if (endpoint.unconfigured)
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: endpoint.instance,
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
- // Unconfigured stubs skip start/open so kitchen-sink Roots stay quiet.
48
- stopped: endpoint.unconfigured,
38
+ stopped: false,
49
39
  });
50
40
  }
51
41
  }
52
- if (unconfigured.length > 0) {
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, unconfigured: _unconfigured, started: _started, open: _open, stopped: _stopped, failed: _failed, startAttempted: _startAttempted, segments: _segments, ...descriptor }) => Object.freeze(descriptor));
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
- // Soft-start in parallel with a short wait so kitchen-sink Roots do not
122
- // stall generation. Configured platforms that need longer (QQ auth, Slack
123
- // socket, GitHub verify) stay in-flight instead of being stop()'d mid-connect.
124
- const startTimeoutMs = this.#startTimeoutMs;
125
- await Promise.all(this.#order.map(async (record) => {
126
- if (record.started || record.stopped)
127
- return;
128
- record.startAttempted = true;
129
- const startPromise = (async () => record.endpoint.start?.())();
130
- try {
131
- await withTimeout(startPromise, startTimeoutMs, `Adapter start timed out after ${startTimeoutMs}ms`);
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
- return;
131
+ throw new Error(`Adapter Endpoint stopped during start: ${record.id}`);
134
132
  record.started = true;
135
133
  }
136
- catch (error) {
137
- const message = error instanceof Error ? error.message : String(error);
138
- if (message.includes('timed out after')) {
139
- logger.info(formatCompact({
140
- op: 'adapter_start_deferred',
141
- id: record.id,
142
- name: record.name,
143
- waitMs: startTimeoutMs,
144
- }));
145
- // Final backstop: a deferred start promise that never settles must
146
- // not keep the Endpoint in limbo forever.
147
- const giveUp = setTimeout(() => {
148
- if (record.stopped || record.started)
149
- return;
150
- record.stopped = true;
151
- record.failed = true;
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
- const entries = Array.isArray(raw)
327
- ? raw.filter((entry) => !!entry && typeof entry === 'object'
328
- && typeof entry.id === 'string'
329
- && entry.id.length > 0)
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
- // `~` record id 的分隔符、\0 是 CapabilityId 的分隔符,混入会破坏解析
342
- const valid = entries.filter((entry) => {
343
- if (/[~\0]/u.test(entry.id)) {
344
- logger.warn(formatCompact({
345
- op: 'adapter_endpoint_id_invalid',
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 true;
262
+ return entry;
352
263
  });
353
- // id 会让 #records 覆盖与 #order/resolve 三者不一致;保留首个并告警
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 deduped = valid.filter((entry) => {
271
+ for (const entry of normalized) {
356
272
  if (seen.has(entry.id)) {
357
- logger.warn(formatCompact({
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(deduped.map((entry) => 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 createEndpointSoft(slot, snapshot, expansion) {
378
- let endpoint;
379
- try {
380
- endpoint = await slot.definition.create(Object.freeze({
381
- ...createCapabilityContext(snapshot, slot.owner),
382
- ...(expansion?.config ? { config: expansion.config } : {}),
383
- id: expansion?.id ?? slot.id,
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 { instance: endpoint, unconfigured: false };
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(async () => {
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 AggregateError([primaryError, stopError], 'Adapter prepare and Endpoint cleanup both failed', { cause: stopError });
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
+ }
@@ -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
- /** Allocates transport resources but must not admit inbound events yet. */
25
- start?(): void | Promise<void>;
26
- /** Opens admission after the candidate generation has committed. */
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 { type ConversationTarget, type MessageTarget } from '@zhin.js/im-contract';
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: MessageTarget): Promise<void>;
12
- edit?(message: MessageTarget, content: unknown): Promise<string | null>;
13
- addReaction?(message: MessageTarget, emoji: string, hint?: {
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: MessageTarget, reactionId: string): Promise<void>;
18
- typing?(conversation: ConversationTarget, active?: boolean): Promise<void>;
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
- * Resolves the public control port. The legacy branch is deliberately kept in
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. */
@@ -1,57 +1,9 @@
1
- import { formatLegacyConversationRef, formatLegacyMessageRef, } from '@zhin.js/im-contract';
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
- if (explicit && typeof explicit === 'object')
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) {