@evomap/evolver-adapter-public 2.0.0-beta.2 → 2.0.0-beta.22

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.
@@ -1,15 +1,17 @@
1
- import { hub as hubNs } from '@evomap/evolver-core';
1
+ import { createHash } from 'node:crypto';
2
+ import { bootstrap, hub as hubNs, signals, wire } from '@evomap/evolver-core';
2
3
  import { AuthError, HubFetch, HubClientError, isHubUnreachableError } from './hubFetch.js';
3
4
  import { isNodeSecret, parseNodeSecretVersion } from './auth/legacyShim.js';
4
- import { inboundToAgentEvent, agentEventToOutbound, publishRespToReceipt, searchQueryToFetchWire } from './wireMap.js';
5
+ import { inboundToAgentEvent, agentEventToOutbound, publishRespToReceipt, searchQueryToFetchWire, searchQueryToSearchOnlyWire, } from './wireMap.js';
5
6
  import { antiAbuseTelemetryMode, buildHeartbeatAntiAbuseTelemetry, } from './antiAbuseTelemetry.js';
6
7
  import { getWorkspaceKeychainMode } from './auth/workspaceKeychain.js';
7
8
  import { agentDirectoryFailure, parsePublicAgentPage, parsePublicAgentProfile, paginatePublicAgentPage, mergePublicAgentPages, publicAgentSearchQuery, publicTaskDiscoveryQuery, PUBLIC_TASK_DISCOVERY_MAX_CANDIDATES, unsupportedPublicAvailability, unsupportedPublicSort, withDirectoryTimeout, } from './agentDirectory.js';
9
+ import { assetMatchesId, stripHubDeliveryMetadataForIntegrity } from './hubReuse.js';
8
10
  export const INBOUND_LIMIT = 100;
9
11
  export const OUTBOUND_MAX_BATCH = 50;
10
12
  export const OUTBOUND_MAX_BODY_BYTES = 4 * 1024 * 1024;
11
13
  export const PUBLIC_PROTOCOL_VERSION = 'gep-a2a/1.0.0';
12
- export const PUBLIC_HUB_CAPABILITIES = ['publish', 'fetch', 'search', 'task', 'mailbox', 'auth', 'marketplace', 'economy', 'questions', 'recipes', 'agent_directory'];
14
+ export const PUBLIC_HUB_CAPABILITIES = ['publish', 'fetch', 'search', 'task', 'mailbox', 'auth', 'marketplace', 'economy', 'questions', 'recipes', 'agent_directory', 'learning_assets'];
13
15
  const QUESTION_SUBMIT_FAST_PATH_BYPASS_CONTENT_HASH = 'sha256:0000000000000000000000000000000000000000000000000000000000000000';
14
16
  const DRY_RUN_RECIPE_ID = 'dry-run-recipe';
15
17
  const HUB_DRY_RUN_VALUES = new Set(['1', 'true', 'yes', 'on']);
@@ -18,14 +20,29 @@ const HUB_DRY_RUN_VALUES = new Set(['1', 'true', 'yes', 'on']);
18
20
  // equals what the hub will KEEP.
19
21
  export const USED_ASSET_IDS_MAX = 50;
20
22
  export const USED_ASSET_ID_MAX_LEN = 200;
23
+ export const LEARNING_ASSET_IDS_MAX = 50;
24
+ export const LEARNING_ASSET_ID_MAX_LEN = 128;
25
+ export class MalformedAccountAssetPageError extends Error {
26
+ constructor() {
27
+ super('Hub account asset page is malformed');
28
+ this.name = 'MalformedAccountAssetPageError';
29
+ }
30
+ }
21
31
  /** 完整 GEP-A2A 信封(实测 dev: publish/fetch/validate 等协议消息端点必须全信封, 非仅 protocol+message_type). */
22
- export function gepEnvelope(messageType, payload) {
32
+ export function gepEnvelope(messageType, payload, options = {}) {
23
33
  return {
24
34
  protocol: 'gep-a2a', protocol_version: '1.0.0', message_type: messageType,
25
- message_id: `msg_${Date.now()}_${Math.random().toString(16).slice(2, 10)}`,
35
+ message_id: options.messageId ?? `msg_${Date.now()}_${Math.random().toString(16).slice(2, 10)}`,
26
36
  timestamp: new Date().toISOString(), payload,
27
37
  };
28
38
  }
39
+ function stablePublishMessageId(idempotencyKey) {
40
+ const digest = createHash('sha256')
41
+ .update(idempotencyKey.trim())
42
+ .digest('hex')
43
+ .slice(0, 40);
44
+ return `msg_idem_${digest}`;
45
+ }
29
46
  // v1 a2aProtocol.js L1999-2003: the three app-level rejection reasons that mean
30
47
  // our cached node_secret has DIVERGED from the hub's record (hub-side reset,
31
48
  // restored-from-backup machine, manual unlink) — not a transport/generic failure.
@@ -83,18 +100,38 @@ export class PublicHubCapability {
83
100
  auth;
84
101
  recipes = {
85
102
  create: async (request) => this.createRecipe(request),
86
- publish: async (recipeId) => this.publishRecipe(recipeId),
103
+ publish: async (recipeId, options) => this.publishRecipe(recipeId, options),
87
104
  get: async (recipeId) => this.getRecipe(recipeId),
88
105
  express: async (recipeId, request = {}) => this.expressRecipe(recipeId, request),
106
+ search: async (request = {}) => this.searchRecipes(request),
107
+ list: async (request = {}) => this.listRecipes(request),
89
108
  };
90
109
  constructor(opts) {
91
110
  this.opts = opts;
92
111
  this.auth = opts.auth;
93
112
  this.http = new HubFetch({ baseUrl: opts.baseUrl, auth: opts.auth, fetchFn: opts.fetchFn, senderId: opts.senderId });
94
113
  }
114
+ evolverVersionForWire(explicitVersion) {
115
+ const antiAbuse = this.opts.antiAbuse;
116
+ return bootstrap.normalizeEvolverVersion(explicitVersion !== undefined
117
+ ? explicitVersion
118
+ : antiAbuse?.evolverVersion ?? antiAbuse?.envFingerprint?.evolver_version);
119
+ }
120
+ envFingerprintForWire(evolverVersion) {
121
+ const fingerprint = {
122
+ ...(this.opts.antiAbuse?.envFingerprint
123
+ ?? bootstrap.captureEnvFingerprint({ env: this.opts.antiAbuse?.env ?? process.env })),
124
+ };
125
+ if (evolverVersion)
126
+ fingerprint.evolver_version = evolverVersion;
127
+ else
128
+ delete fingerprint.evolver_version;
129
+ return fingerprint;
130
+ }
95
131
  async hello(opts) {
96
132
  try {
97
133
  const sender = this.opts.senderId();
134
+ const evolverVersion = this.evolverVersionForWire(opts.evolverVersion);
98
135
  const body = await this.http.call('POST', '/a2a/hello', gepEnvelope('hello', {
99
136
  rotate_secret: opts.rotate,
100
137
  capabilities: { supported_types: ['publish', 'fetch', 'mailbox', 'questions'] },
@@ -102,7 +139,12 @@ export class PublicHubCapability {
102
139
  status: 'active',
103
140
  timestamp: new Date().toISOString(),
104
141
  ...(sender ? { node_id: sender } : {}),
105
- ...(opts.evolverVersion ? { evolver_version: opts.evolverVersion } : {}),
142
+ ...(evolverVersion ? { evolver_version: evolverVersion } : {}),
143
+ // v1 parity (a2aProtocol.js buildHello): every hello carries the env fingerprint — it is how the
144
+ // hub builds node/IP trust for its anti-abuse layer. v2 had moved it to heartbeat-only meta, which
145
+ // one-shot CLI paths never send; the hub then answers heartbeats with resend_hello
146
+ // `missing_env_fingerprint` and 403-antibodies /a2a/fetch (#555).
147
+ env_fingerprint: this.envFingerprintForWire(evolverVersion),
106
148
  }));
107
149
  const payload = asRecord(body['payload']) ?? body;
108
150
  const retryAfterMs = numberField(payload, 'retry_after_ms') ?? numberField(payload, 'retryAfterMs');
@@ -115,10 +157,12 @@ export class PublicHubCapability {
115
157
  // cleanly; retrying with the diverged secret never can. Signals the caller NOT to arm reauth
116
158
  // backoff. Only legacy node_secret auth exposes this hook — enterprise_token is a no-op.
117
159
  if (isSecretDivergenceRejection(body, payload)) {
118
- this.auth.notifyNodeSecretDiverged?.();
160
+ if (!opts.preserveCredentials) {
161
+ this.auth.notifyNodeSecretDiverged?.();
162
+ }
119
163
  return {
120
164
  ok: false,
121
- error: 'secret_diverged_cleared',
165
+ error: opts.preserveCredentials ? 'secret_diverged' : 'secret_diverged_cleared',
122
166
  secretDiverged: true,
123
167
  ...(rateLimitUntilMs !== undefined ? { rateLimitUntilMs } : {}),
124
168
  ...(retryAfterMs !== undefined ? { retryAfterMs } : {}),
@@ -134,15 +178,21 @@ export class PublicHubCapability {
134
178
  ?? this.opts.senderId();
135
179
  const nodeSecret = stringField(payload, 'node_secret') ?? stringField(payload, 'nodeSecret');
136
180
  const nodeSecretVersion = parseNodeSecretVersion(payload['node_secret_version'] ?? payload['nodeSecretVersion']);
137
- if (nodeSecret && isNodeSecret(nodeSecret)) {
138
- this.auth.adoptNodeSecret?.(nodeSecret, nodeSecretVersion);
139
- }
140
- else {
141
- this.auth.adoptNodeSecretVersion?.(nodeSecretVersion);
181
+ const claimCode = stringField(payload, 'claim_code') ?? stringField(payload, 'claimCode');
182
+ const claimUrl = stringField(payload, 'claim_url') ?? stringField(payload, 'claimUrl');
183
+ if (!opts.preserveCredentials) {
184
+ if (nodeSecret && isNodeSecret(nodeSecret)) {
185
+ this.auth.adoptNodeSecret?.(nodeSecret, nodeSecretVersion);
186
+ }
187
+ else {
188
+ this.auth.adoptNodeSecretVersion?.(nodeSecretVersion);
189
+ }
142
190
  }
143
191
  return {
144
192
  ok: payload['ok'] !== false && Boolean(nodeId),
145
193
  ...(nodeId ? { nodeId } : {}),
194
+ ...(claimCode ? { claimCode } : {}),
195
+ ...(claimUrl ? { claimUrl } : {}),
146
196
  ...(nodeSecretVersion !== undefined ? { nodeSecretVersion } : {}),
147
197
  ...(rateLimitUntilMs !== undefined ? { rateLimitUntilMs } : {}),
148
198
  ...(retryAfterMs !== undefined ? { retryAfterMs } : {}),
@@ -161,12 +211,13 @@ export class PublicHubCapability {
161
211
  async heartbeat(opts = {}) {
162
212
  try {
163
213
  const nodeSecretVersion = this.auth.getNodeSecretVersion?.();
164
- const meta = this.heartbeatMeta(opts, nodeSecretVersion);
214
+ const evolverVersion = this.evolverVersionForWire(opts.evolverVersion);
215
+ const meta = this.heartbeatMeta(evolverVersion, nodeSecretVersion);
165
216
  const body = await this.http.call('POST', '/a2a/heartbeat', {
166
217
  ...(this.opts.senderId() ? { node_id: this.opts.senderId() } : {}),
167
218
  timestamp: new Date().toISOString(),
168
219
  status: 'active',
169
- ...(opts.evolverVersion ? { evolver_version: opts.evolverVersion } : {}),
220
+ ...(evolverVersion ? { evolver_version: evolverVersion } : {}),
170
221
  ...(opts.lastUpdate ? { last_update: opts.lastUpdate } : {}),
171
222
  ...(nodeSecretVersion !== undefined ? { node_secret_version: nodeSecretVersion } : {}),
172
223
  ...(meta ? { meta } : {}),
@@ -183,7 +234,7 @@ export class PublicHubCapability {
183
234
  throw err;
184
235
  }
185
236
  }
186
- heartbeatMeta(opts, nodeSecretVersion) {
237
+ heartbeatMeta(evolverVersion, nodeSecretVersion) {
187
238
  const meta = {};
188
239
  if (nodeSecretVersion !== undefined)
189
240
  meta['node_secret_version'] = nodeSecretVersion;
@@ -193,7 +244,7 @@ export class PublicHubCapability {
193
244
  meta['anti_abuse'] = buildHeartbeatAntiAbuseTelemetry({
194
245
  ...antiAbuse,
195
246
  nodeId: this.opts.senderId(),
196
- evolverVersion: opts.evolverVersion,
247
+ evolverVersion,
197
248
  });
198
249
  }
199
250
  catch (err) {
@@ -204,15 +255,29 @@ export class PublicHubCapability {
204
255
  }
205
256
  return Object.keys(meta).length > 0 ? meta : undefined;
206
257
  }
207
- async publish(bundle) {
258
+ async publish(bundle, options = {}) {
259
+ const normalizedIdempotencyKey = options.idempotencyKey?.trim();
260
+ if (options.idempotencyKey !== undefined && !normalizedIdempotencyKey) {
261
+ return {
262
+ receiptId: 'local_invalid_idempotency_key',
263
+ status: 'rejected',
264
+ terminal: true,
265
+ reason: 'publish idempotency key must not be blank',
266
+ };
267
+ }
208
268
  try {
209
269
  // 公版 /a2a/publish 收 payload.assets=[Gene,Capsule,(Event)] 捆绑(实测 dev).
210
- const body = await this.http.call('POST', '/a2a/publish', gepEnvelope('publish', { assets: bundle }));
270
+ const idempotencyKey = normalizedIdempotencyKey;
271
+ const messageId = idempotencyKey !== undefined
272
+ ? stablePublishMessageId(idempotencyKey)
273
+ : undefined;
274
+ const body = await this.http.call('POST', '/a2a/publish', gepEnvelope('publish', { assets: bundle }, messageId ? { messageId } : {}));
211
275
  return publishRespToReceipt(200, body);
212
276
  }
213
277
  catch (e) {
214
- if (e instanceof HubClientError)
215
- return publishRespToReceipt(e.status, e.body ?? {});
278
+ if (e instanceof HubClientError) {
279
+ return publishRespToReceipt(e.status, e.body ?? {}, e.retryAfterMs);
280
+ }
216
281
  throw e; // 5xx/网络 → 重试
217
282
  }
218
283
  }
@@ -221,29 +286,79 @@ export class PublicHubCapability {
221
286
  // /a2a/fetch responses are FULL GEP envelopes (buildResponse('fetch', …)); the rows live at payload.results,
222
287
  // NOT at the top level. Reading body.results here always yielded [] — every fetch silently returned nothing.
223
288
  const body = await this.http.call('POST', '/a2a/fetch', gepEnvelope('fetch', searchQueryToFetchWire(query)));
224
- return (body.payload?.results ?? []);
289
+ return assetsFromBody(body);
225
290
  }
226
- async fetchAssetById(assetId) {
291
+ /**
292
+ * Fetch one asset AND say why, when the answer is not an asset. `fetchAssetById` collapses every outcome to
293
+ * `null`, so a caller could not tell "the hub does not have this" from "the hub delivered something the
294
+ * client refuses" — and the CLI reported both as `not_found` on assets that demonstrably exist (#964).
295
+ */
296
+ async fetchAssetDeliveryById(assetId, options) {
227
297
  const id = assetId.trim();
228
298
  if (!id)
229
- return null;
299
+ return { status: 'absent' };
230
300
  const body = await this.http.call('POST', '/a2a/fetch', gepEnvelope('fetch', { asset_ids: [id] }));
231
- return assetsFromBody(body).find((asset) => assetMatchesId(asset, id)) ?? null;
301
+ const matches = [];
302
+ for (const row of assetCandidatesFromBody(body)) {
303
+ const asset = unwrapFetchDeliveryRow(row);
304
+ if (!fetchDeliveryIdentityConsistent(row, asset))
305
+ return { status: 'rejected', reason: 'identity_mismatch' };
306
+ if (isContentAssetIdRequest(id)) {
307
+ // Identity is what this gate is for: the delivery must bind the REQUESTED content id through its own
308
+ // canonical `asset_id`. Whether the delivered body then hashes to that id, or satisfies the wire schema,
309
+ // is a trust question the caller resolves (quarantine + repair) — not grounds to drop the asset here.
310
+ if (!assetMatchesId(asset, id) && stringField(asset, 'asset_id') !== id) {
311
+ return { status: 'rejected', reason: 'identity_mismatch' };
312
+ }
313
+ if (isRevokedFetchDelivery(row))
314
+ return { status: 'rejected', reason: 'revoked' };
315
+ matches.push(asset);
316
+ continue;
317
+ }
318
+ if (fetchResultMatchesId(asset, id)) {
319
+ if (isRevokedFetchDelivery(row))
320
+ return { status: 'rejected', reason: 'revoked' };
321
+ matches.push(asset);
322
+ }
323
+ }
324
+ if (matches.length === 0)
325
+ return { status: 'absent' };
326
+ const result = unambiguousFetchResult(matches);
327
+ if (!result)
328
+ return { status: 'rejected', reason: 'ambiguous' };
329
+ // Logical-id lookups retain their historical matching semantics. Only a canonical sha256 lookup can opt in
330
+ // to a content-hash drift, and every non-opted-in caller remains strict.
331
+ const verified = assetMatchesId(result, id);
332
+ if (verified || !isContentAssetIdRequest(id))
333
+ return { status: 'delivered', asset: result, verified: true };
334
+ return options?.allowUnverifiedExactIdentity === true
335
+ ? { status: 'delivered', asset: result, verified: false }
336
+ : { status: 'rejected', reason: 'unverified_not_allowed' };
337
+ }
338
+ async fetchAssetById(assetId, options) {
339
+ const outcome = await this.fetchAssetDeliveryById(assetId, options);
340
+ return outcome.status === 'delivered' ? outcome.asset : null;
232
341
  }
233
342
  /**
234
343
  * #69: search != fetch. Free-text is the hub's vector endpoint (GET /a2a/assets/semantic-search?q=);
235
- * signals/id queries fall through to fetch. /a2a/fetch does NOT do semantic, so text must not go there.
344
+ * signal/id queries use the Hub's free search-only phase on /a2a/fetch. /a2a/fetch does NOT do semantic,
345
+ * so text must not go there and paid/full fetch must remain an explicit follow-up.
236
346
  */
237
347
  async search(query) {
238
348
  if (query.text && query.text.trim()) {
239
349
  // GET /a2a/assets/semantic-search returns a FLAT object keyed `assets` (no GEP envelope), plus a
240
- // `search_status` (found / degraded(retryable) / low_confidence_only / no_match). Reading body.results
241
- // here always yielded [] the semantic path could never return a hit. (Status not surfaced yet: the
242
- // HubCapability.search contract is AssetRecord[]; honoring search_status needs an interface change — later.)
243
- const body = await this.http.call('GET', '/a2a/assets/semantic-search', undefined, { q: query.text, ...(query.limit !== undefined ? { limit: query.limit } : {}) });
244
- return (body.assets ?? []);
350
+ // `search_status` (found / degraded(retryable) / low_confidence_only / no_match). Only an explicit
351
+ // no_match is a verified empty result; degraded or malformed 200 responses must not trigger ATP spend.
352
+ const body = await this.http.call('GET', '/a2a/assets/semantic-search', undefined, {
353
+ q: query.text,
354
+ ...(query.kind !== undefined ? { type: query.kind } : {}),
355
+ ...(query.domain !== undefined ? { domain: query.domain } : {}),
356
+ ...(query.limit !== undefined ? { limit: query.limit } : {}),
357
+ });
358
+ return semanticSearchAssets(body);
245
359
  }
246
- return this.fetch(query);
360
+ const body = await this.http.call('POST', '/a2a/fetch', gepEnvelope('fetch', searchQueryToSearchOnlyWire(query)));
361
+ return signalSearchAssets(body);
247
362
  }
248
363
  agentDirectory = {
249
364
  search: async (request) => {
@@ -305,16 +420,18 @@ export class PublicHubCapability {
305
420
  ...(opts.scope === 'published' && opts.status && opts.status !== 'all' ? { status: opts.status } : {}),
306
421
  };
307
422
  const body = await this.http.call('GET', path, undefined, query);
308
- const payload = asRecord(body['payload']) ?? body;
423
+ const payload = Object.prototype.hasOwnProperty.call(body, 'payload')
424
+ ? asRecord(body['payload'])
425
+ : body;
426
+ if (!payload)
427
+ throw new MalformedAccountAssetPageError();
309
428
  const assets = accountAssetsFromPayload(payload);
310
429
  const count = numberField(payload, 'count');
311
- const nextCursor = stringField(payload, 'next_cursor') ?? stringField(payload, 'nextCursor');
312
- const hasMore = booleanField(payload, 'has_more') ?? booleanField(payload, 'hasMore') ?? Boolean(nextCursor);
430
+ const pagination = accountPaginationFromPayload(payload);
313
431
  return {
314
432
  assets,
315
433
  ...(count !== undefined ? { count } : {}),
316
- hasMore,
317
- ...(nextCursor ? { nextCursor } : {}),
434
+ ...pagination,
318
435
  };
319
436
  }
320
437
  /**
@@ -393,6 +510,52 @@ export class PublicHubCapability {
393
510
  return { recorded: false, reason: e instanceof Error ? e.message : String(e) };
394
511
  }
395
512
  }
513
+ async listLearningAssets(options = {}) {
514
+ const limit = normalizeLearningAssetLimit(options.limit);
515
+ if (!this.opts.senderId()?.trim())
516
+ return { assets: [], limit, reason: 'sender_id_required' };
517
+ try {
518
+ const body = await this.http.call('GET', '/a2a/learning-assets', undefined, learningAssetListQuery(options, limit));
519
+ const payload = asRecord(body['payload']) ?? body;
520
+ return {
521
+ assets: learningAssetsFromPayload(payload),
522
+ limit: numberField(payload, 'limit') ?? limit,
523
+ };
524
+ }
525
+ catch (e) {
526
+ return { assets: [], limit, reason: failureReason(e) };
527
+ }
528
+ }
529
+ async recordLearningAssetUsage(report) {
530
+ if (!this.opts.senderId()?.trim())
531
+ return { recorded: false, reason: 'sender_id_required', results: [] };
532
+ const sourceEventId = trimStringField(report.sourceEventId, 160);
533
+ if (!sourceEventId)
534
+ return { recorded: false, reason: 'source_event_id_required', results: [] };
535
+ const assetIds = normalizeLearningAssetIds(report.usedAssetIds && report.usedAssetIds.length > 0 ? report.usedAssetIds : [report.assetId]);
536
+ if (assetIds.length === 0)
537
+ return { recorded: false, reason: 'asset_id_required', results: [] };
538
+ const outcome = normalizeLearningAssetOutcome(report.outcome);
539
+ if (!outcome)
540
+ return { recorded: false, reason: 'invalid_learning_asset_outcome', results: [] };
541
+ const score = optionalLearningAssetScore(report.score);
542
+ if ('reason' in score)
543
+ return { recorded: false, reason: score.reason, results: [] };
544
+ const reason = trimStringField(report.reason, 2_000);
545
+ try {
546
+ const body = await this.http.call('POST', '/a2a/learning-assets/usage', {
547
+ ...(assetIds.length === 1 ? { asset_id: assetIds[0] } : { used_asset_ids: assetIds }),
548
+ outcome,
549
+ source_event_id: sourceEventId,
550
+ ...(score.value !== undefined ? { score: score.value } : {}),
551
+ ...(reason ? { reason } : {}),
552
+ });
553
+ return learningAssetUsageReceiptFromBody(body);
554
+ }
555
+ catch (e) {
556
+ return { recorded: false, reason: failureReason(e), results: [] };
557
+ }
558
+ }
396
559
  /**
397
560
  * Pre-publish dry-run (POST /a2a/validate). The hub runs the same hub-side quality +
398
561
  * content-safety gate as publish but stores nothing and charges no credits. This adapter is
@@ -448,14 +611,14 @@ export class PublicHubCapability {
448
611
  ...(request.pricePerExecution !== undefined ? { price_per_execution: request.pricePerExecution } : {}),
449
612
  ...(request.currency ? { currency: request.currency } : {}),
450
613
  ...(request.maxConcurrent !== undefined ? { max_concurrent: request.maxConcurrent } : {}),
451
- });
614
+ }, undefined, request.idempotencyKey ? { 'idempotency-key': request.idempotencyKey } : undefined);
452
615
  return recipeReceiptFromBody(body);
453
616
  }
454
- async publishRecipe(recipeId) {
617
+ async publishRecipe(recipeId, options) {
455
618
  if (isHubDryRunEnabled())
456
619
  return dryRunRecipeReceipt('publish_recipe', recipeId);
457
620
  const sender = this.opts.senderId();
458
- const body = await this.http.call('POST', `/a2a/recipe/${encodeURIComponent(recipeId)}/publish`, { ...(sender ? { node_id: sender } : {}) });
621
+ const body = await this.http.call('POST', `/a2a/recipe/${encodeURIComponent(recipeId)}/publish`, { ...(sender ? { node_id: sender } : {}) }, undefined, options?.idempotencyKey ? { 'idempotency-key': options.idempotencyKey } : undefined);
459
622
  return recipeReceiptFromBody(body);
460
623
  }
461
624
  async getRecipe(recipeId) {
@@ -472,6 +635,20 @@ export class PublicHubCapability {
472
635
  ...(recipe !== undefined ? { recipe } : {}),
473
636
  };
474
637
  }
638
+ async searchRecipes(request = {}) {
639
+ if (isHubDryRunEnabled()) {
640
+ return dryRunRecipeSearchReceipt('search_recipe', request);
641
+ }
642
+ const body = await this.http.call('GET', '/a2a/recipe/search', undefined, recipeSearchQuery(request));
643
+ return recipeSearchReceiptFromBody(body);
644
+ }
645
+ async listRecipes(request = {}) {
646
+ if (isHubDryRunEnabled()) {
647
+ return dryRunRecipeSearchReceipt('list_recipe', request);
648
+ }
649
+ const body = await this.http.call('GET', '/a2a/recipe/list', undefined, recipeSearchQuery(request));
650
+ return recipeSearchReceiptFromBody(body);
651
+ }
475
652
  async expressRecipe(recipeId, request = {}) {
476
653
  if (isHubDryRunEnabled()) {
477
654
  return dryRunRecipeReceipt('express_recipe', recipeId, { input_payload: request.inputPayload ?? {} });
@@ -546,7 +723,10 @@ export class PublicHubCapability {
546
723
  const body = await this.http.call('POST', '/a2a/events/poll', gepEnvelope('events_poll', { timeout_ms: 1000 }));
547
724
  for (const e of body.events ?? []) {
548
725
  if (String(e['type']).startsWith('task_')) {
549
- yield { taskId: String(e['payload']?.taskId ?? e['id']), type: String(e['type']), payload: e['payload'], priority: e['priority'] ?? 'medium', createdAt: Date.parse(String(e['created_at'] ?? '')) || 0 };
726
+ const payload = asRecord(e['payload']);
727
+ const wireTaskId = payload?.['task_id'] ?? payload?.['taskId'];
728
+ const taskId = typeof wireTaskId === 'string' && wireTaskId.length > 0 ? wireTaskId : String(e['id']);
729
+ yield { taskId, type: String(e['type']), payload: e['payload'], priority: e['priority'] ?? 'medium', createdAt: Date.parse(String(e['created_at'] ?? '')) || 0 };
550
730
  }
551
731
  }
552
732
  }
@@ -646,6 +826,44 @@ export class PublicHubCapability {
646
826
  function asRecord(value) {
647
827
  return value && typeof value === 'object' && !Array.isArray(value) ? value : undefined;
648
828
  }
829
+ function searchAssets(value, source) {
830
+ if (!Array.isArray(value))
831
+ throw new Error(`${source}_results_invalid`);
832
+ return value.map((candidate) => {
833
+ const record = asRecord(candidate);
834
+ if (!record)
835
+ throw new Error(`${source}_asset_invalid`);
836
+ const asset = unwrapFetchDeliveryRow(record);
837
+ const assetId = stringField(asset, 'asset_id') ?? stringField(asset, 'assetId');
838
+ if (!assetId)
839
+ throw new Error(`${source}_asset_invalid`);
840
+ return asset;
841
+ });
842
+ }
843
+ function semanticSearchAssets(body) {
844
+ const status = stringField(body, 'search_status');
845
+ if (status === 'degraded' || body['retryable'] === true)
846
+ throw new Error('semantic_search_degraded');
847
+ const assets = searchAssets(body['assets'], 'semantic_search');
848
+ if (status === 'no_match') {
849
+ if (assets.length !== 0)
850
+ throw new Error('semantic_search_status_invalid');
851
+ return assets;
852
+ }
853
+ if (status === 'found' || status === 'low_confidence_only') {
854
+ if (assets.length === 0)
855
+ throw new Error('semantic_search_status_invalid');
856
+ return assets;
857
+ }
858
+ // Older successful Hub responses are usable only when they carry a concrete candidate.
859
+ if (status === undefined && assets.length > 0)
860
+ return assets;
861
+ throw new Error('semantic_search_status_invalid');
862
+ }
863
+ function signalSearchAssets(body) {
864
+ const payload = asRecord(body['payload']);
865
+ return searchAssets(payload?.['results'], 'signal_search');
866
+ }
649
867
  function recipeStepToWire(step) {
650
868
  return {
651
869
  asset_id: step.assetId,
@@ -676,6 +894,58 @@ function recipeOrganismIdFromPayload(payload) {
676
894
  ? stringField(organism, 'id') ?? stringField(organism, 'organism_id') ?? stringField(organism, 'organismId')
677
895
  : undefined;
678
896
  }
897
+ function recipeSearchQuery(request) {
898
+ return {
899
+ ...(request.q ? { q: request.q } : {}),
900
+ ...(request.limit !== undefined ? { limit: request.limit } : {}),
901
+ ...(request.cursor ? { cursor: request.cursor } : {}),
902
+ ...(request.sort ? { sort: request.sort } : {}),
903
+ };
904
+ }
905
+ function recipeListFromRecord(value) {
906
+ for (const key of ['recipes', 'items', 'results']) {
907
+ const found = value[key];
908
+ if (Array.isArray(found))
909
+ return found;
910
+ }
911
+ const nested = asRecord(value['data']);
912
+ if (!nested)
913
+ return undefined;
914
+ for (const key of ['recipes', 'items', 'results']) {
915
+ const found = nested[key];
916
+ if (Array.isArray(found))
917
+ return found;
918
+ }
919
+ return undefined;
920
+ }
921
+ function recipeSearchReceiptFromBody(body) {
922
+ const payload = recipePayload(body);
923
+ const recipes = recipeListFromRecord(payload) ?? recipeListFromRecord(body) ?? [];
924
+ const nextCursor = stringField(payload, 'next_cursor')
925
+ ?? stringField(payload, 'nextCursor')
926
+ ?? stringField(body, 'next_cursor')
927
+ ?? stringField(body, 'nextCursor');
928
+ const hasMore = booleanField(payload, 'has_more')
929
+ ?? booleanField(payload, 'hasMore')
930
+ ?? booleanField(body, 'has_more')
931
+ ?? booleanField(body, 'hasMore');
932
+ return {
933
+ recipes,
934
+ ...(nextCursor ? { nextCursor } : {}),
935
+ ...(hasMore !== undefined ? { hasMore } : {}),
936
+ raw: body,
937
+ };
938
+ }
939
+ function dryRunRecipeSearchReceipt(action, request) {
940
+ return {
941
+ recipes: [],
942
+ raw: {
943
+ dry_run: true,
944
+ would: action,
945
+ ...request,
946
+ },
947
+ };
948
+ }
679
949
  function recipeReceiptFromBody(body) {
680
950
  const payload = recipePayload(body);
681
951
  const recipe = asRecord(payload['recipe']);
@@ -700,7 +970,7 @@ function dryRunRecipeReceipt(action, recipeId, extra = {}) {
700
970
  },
701
971
  };
702
972
  }
703
- function assetsFromBody(body) {
973
+ function assetCandidatesFromBody(body) {
704
974
  const payload = asRecord(body['payload']);
705
975
  const candidates = [
706
976
  body['asset'],
@@ -710,9 +980,134 @@ function assetsFromBody(body) {
710
980
  ...(Array.isArray(payload?.['assets']) ? payload['assets'] : []),
711
981
  ...(Array.isArray(payload?.['results']) ? payload['results'] : []),
712
982
  ];
713
- return candidates.filter((candidate) => Boolean(candidate && typeof candidate === 'object' && !Array.isArray(candidate)));
983
+ return candidates
984
+ .filter((candidate) => Boolean(candidate && typeof candidate === 'object' && !Array.isArray(candidate)));
985
+ }
986
+ function assetsFromBody(body) {
987
+ return assetCandidatesFromBody(body).map(unwrapFetchDeliveryRow);
988
+ }
989
+ // Delivery-row metadata carried over onto the unwrapped GEP record. Ranking fields are consumed by
990
+ // hubReuse and stripped before canonical storage. `payload_backfill_reason` must also survive this
991
+ // boundary so integrity consumers can report that the Hub synthesized the payload (#570). Do not
992
+ // carry `confidence`: it is transport metadata on Gene rows but canonical content on Capsules, so
993
+ // overloading it can either poison a Gene hash or overwrite Capsule content (#565).
994
+ const FETCH_ROW_CARRYOVER_KEYS = [
995
+ 'gdi_score',
996
+ 'success_rate',
997
+ 'reuse_count',
998
+ 'source_node_id',
999
+ 'payload_backfill_reason',
1000
+ 'status',
1001
+ 'trust_state',
1002
+ ];
1003
+ /**
1004
+ * The live hub's /a2a/fetch results are DELIVERY ROWS, not raw GEP records (#565, observed on
1005
+ * evomap.ai 2026-07-22): the record itself nests under `payload`, while the row's own keys are
1006
+ * delivery metadata (asset_type, bundle_id, confidence, gdi_score_mean, callable, …). Treating the
1007
+ * row as the asset made reuse's integrity check (computeAssetId over the row) fail on every
1008
+ * delivered asset. A GEP record always carries a string `type`; delivery rows carry `asset_type`
1009
+ * instead — so unwrap exactly when the row has no `type` and nests an object payload that looks
1010
+ * like a GEP record. Rows that already ARE raw records (older hubs, tests) pass through unchanged.
1011
+ */
1012
+ function unwrapFetchDeliveryRow(row) {
1013
+ const record = row;
1014
+ if (typeof record['type'] === 'string')
1015
+ return row;
1016
+ const inner = asRecord(record['payload']);
1017
+ if (!inner || typeof inner['type'] !== 'string' || typeof inner['asset_id'] !== 'string')
1018
+ return row;
1019
+ const carryover = {};
1020
+ for (const key of FETCH_ROW_CARRYOVER_KEYS) {
1021
+ if (record[key] !== undefined && inner[key] === undefined)
1022
+ carryover[key] = record[key];
1023
+ }
1024
+ return { ...inner, ...carryover };
1025
+ }
1026
+ function fetchDeliveryIdentityConsistent(row, asset) {
1027
+ const outer = row;
1028
+ if (typeof outer['type'] === 'string') {
1029
+ const transportType = stringField(outer, 'asset_type');
1030
+ if (transportType !== undefined && transportType !== outer['type'])
1031
+ return false;
1032
+ const transportLogicalId = stringField(outer, 'local_id');
1033
+ if (transportLogicalId !== undefined && transportLogicalId !== stringField(outer, 'id'))
1034
+ return false;
1035
+ return true;
1036
+ }
1037
+ const inner = asRecord(outer['payload']);
1038
+ if (!inner)
1039
+ return false;
1040
+ const outerAssetId = stringField(outer, 'asset_id');
1041
+ const innerAssetId = stringField(inner, 'asset_id');
1042
+ if (!outerAssetId || !innerAssetId || outerAssetId !== innerAssetId)
1043
+ return false;
1044
+ const outerType = stringField(outer, 'asset_type');
1045
+ const innerType = stringField(inner, 'type');
1046
+ if (outerType !== undefined && outerType !== innerType)
1047
+ return false;
1048
+ const innerLogicalId = stringField(inner, 'id');
1049
+ for (const key of ['id', 'local_id']) {
1050
+ const outerLogicalId = stringField(outer, key);
1051
+ if (outerLogicalId !== undefined && outerLogicalId !== innerLogicalId)
1052
+ return false;
1053
+ }
1054
+ for (const key of ['status', 'trust_state']) {
1055
+ const outerMarker = stringField(outer, key);
1056
+ const innerMarker = stringField(inner, key);
1057
+ const outerRevoked = isRevokedMarker(outerMarker);
1058
+ const innerRevoked = isRevokedMarker(innerMarker);
1059
+ if (outerMarker !== undefined && innerMarker !== undefined && outerRevoked !== innerRevoked)
1060
+ return false;
1061
+ }
1062
+ return asset === row || stringField(asset, 'asset_id') === innerAssetId;
1063
+ }
1064
+ function isRevokedFetchDelivery(row) {
1065
+ const record = row;
1066
+ const nested = asRecord(record['payload']);
1067
+ return [record, ...(nested ? [nested] : [])].some((value) => (isRevokedMarker(value['status'])
1068
+ || isRevokedMarker(value['trust_state'])));
1069
+ }
1070
+ function isRevokedMarker(raw) {
1071
+ return typeof raw === 'string' && raw.trim().toLowerCase() === 'revoked';
714
1072
  }
715
1073
  function accountAssetsFromPayload(payload) {
1074
+ const keys = ['assets', 'results', 'items']
1075
+ .filter((key) => Object.prototype.hasOwnProperty.call(payload, key));
1076
+ if (keys.length !== 1)
1077
+ throw new MalformedAccountAssetPageError();
1078
+ const candidate = payload[keys[0]];
1079
+ if (!Array.isArray(candidate) || candidate.some((asset) => !asset || typeof asset !== 'object' || Array.isArray(asset))) {
1080
+ throw new MalformedAccountAssetPageError();
1081
+ }
1082
+ return candidate.map((row) => {
1083
+ const asset = unwrapFetchDeliveryRow(row);
1084
+ return fetchDeliveryIdentityConsistent(row, asset) ? asset : row;
1085
+ });
1086
+ }
1087
+ function accountPaginationFromPayload(payload) {
1088
+ const snakeHasMore = payload['has_more'];
1089
+ const camelHasMore = payload['hasMore'];
1090
+ if ((snakeHasMore !== undefined && typeof snakeHasMore !== 'boolean')
1091
+ || (camelHasMore !== undefined && typeof camelHasMore !== 'boolean')
1092
+ || (snakeHasMore === undefined && camelHasMore === undefined)
1093
+ || (typeof snakeHasMore === 'boolean' && typeof camelHasMore === 'boolean' && snakeHasMore !== camelHasMore)) {
1094
+ throw new MalformedAccountAssetPageError();
1095
+ }
1096
+ const hasMore = typeof snakeHasMore === 'boolean' ? snakeHasMore : camelHasMore;
1097
+ const rawCursors = [payload['next_cursor'], payload['nextCursor']]
1098
+ .filter((value) => value !== undefined && value !== null);
1099
+ if (rawCursors.some((value) => typeof value !== 'string' || !value.trim())) {
1100
+ throw new MalformedAccountAssetPageError();
1101
+ }
1102
+ if (rawCursors.length === 2 && rawCursors[0] !== rawCursors[1]) {
1103
+ throw new MalformedAccountAssetPageError();
1104
+ }
1105
+ const nextCursor = rawCursors[0];
1106
+ if (hasMore !== Boolean(nextCursor))
1107
+ throw new MalformedAccountAssetPageError();
1108
+ return { hasMore, ...(nextCursor ? { nextCursor } : {}) };
1109
+ }
1110
+ function learningAssetsFromPayload(payload) {
716
1111
  const candidates = [
717
1112
  payload['assets'],
718
1113
  payload['results'],
@@ -721,12 +1116,123 @@ function accountAssetsFromPayload(payload) {
721
1116
  for (const candidate of candidates) {
722
1117
  if (!Array.isArray(candidate))
723
1118
  continue;
724
- return candidate.filter((asset) => Boolean(asset && typeof asset === 'object' && !Array.isArray(asset)));
1119
+ return candidate.filter((asset) => isLearningAssetRecord(asset));
725
1120
  }
726
1121
  return [];
727
1122
  }
728
- function assetMatchesId(asset, assetId) {
729
- return Boolean(asset && (asset.asset_id === assetId || stringField(asset, 'id') === assetId));
1123
+ function isLearningAssetRecord(value) {
1124
+ const record = asRecord(value);
1125
+ return Boolean(record && typeof record['asset_id'] === 'string' && typeof record['type'] === 'string');
1126
+ }
1127
+ function normalizeLearningAssetLimit(value) {
1128
+ if (!Number.isFinite(value))
1129
+ return 20;
1130
+ return Math.min(100, Math.max(1, Math.floor(value)));
1131
+ }
1132
+ function learningAssetListQuery(options, limit) {
1133
+ const status = normalizeLearningAssetStatusParam(options.status);
1134
+ const query = {
1135
+ limit,
1136
+ runtime: options.includeExpired === true ? undefined : 'true',
1137
+ include_expired: options.includeExpired === true ? 'true' : undefined,
1138
+ include_payload: options.includePayload === true ? 'true' : undefined,
1139
+ ...(options.type ? { type: options.type } : {}),
1140
+ ...(status ? { status } : options.includeExpired === true ? { status: 'active' } : {}),
1141
+ };
1142
+ const scope = compactLearningAssetParam(options.scope);
1143
+ if (scope)
1144
+ query['scope'] = scope;
1145
+ return query;
1146
+ }
1147
+ function normalizeLearningAssetStatusParam(status) {
1148
+ if (Array.isArray(status))
1149
+ return compactLearningAssetParam(status);
1150
+ return typeof status === 'string' && status.trim() ? status.trim() : undefined;
1151
+ }
1152
+ function compactLearningAssetParam(values) {
1153
+ if (!values)
1154
+ return undefined;
1155
+ const out = [...new Set(values.map((value) => String(value).trim()).filter(Boolean))];
1156
+ return out.length > 0 ? out.join(',') : undefined;
1157
+ }
1158
+ function trimStringField(value, maxLen) {
1159
+ return typeof value === 'string' && value.trim() ? value.trim().slice(0, maxLen) : undefined;
1160
+ }
1161
+ function normalizeLearningAssetIds(values) {
1162
+ const out = [];
1163
+ const seen = new Set();
1164
+ for (const value of values) {
1165
+ if (typeof value !== 'string')
1166
+ continue;
1167
+ const trimmed = value.trim();
1168
+ if (!trimmed || trimmed.length > LEARNING_ASSET_ID_MAX_LEN || seen.has(trimmed))
1169
+ continue;
1170
+ seen.add(trimmed);
1171
+ out.push(trimmed);
1172
+ if (out.length >= LEARNING_ASSET_IDS_MAX)
1173
+ break;
1174
+ }
1175
+ return out;
1176
+ }
1177
+ function normalizeLearningAssetOutcome(value) {
1178
+ if (value === 'success' || value === 'failed' || value === 'mismatched' || value === 'stale' || value === 'unsafe')
1179
+ return value;
1180
+ return undefined;
1181
+ }
1182
+ function optionalLearningAssetScore(value) {
1183
+ if (value === undefined)
1184
+ return { value: undefined };
1185
+ if (typeof value !== 'number' || !Number.isFinite(value) || value < 0 || value > 1)
1186
+ return { reason: 'invalid_score' };
1187
+ return { value };
1188
+ }
1189
+ function learningAssetUsageReceiptFromBody(body) {
1190
+ const payload = asRecord(body['payload']) ?? body;
1191
+ const rows = Array.isArray(payload['results'])
1192
+ ? payload['results'].filter((row) => Boolean(asRecord(row)))
1193
+ : [];
1194
+ const reason = stringField(payload, 'reason') ?? stringField(payload, 'error');
1195
+ return {
1196
+ recorded: rows.length > 0 && rows.some((row) => row.recorded === true),
1197
+ ...(reason ? { reason } : {}),
1198
+ results: rows,
1199
+ };
1200
+ }
1201
+ function failureReason(error) {
1202
+ if (error instanceof HubClientError) {
1203
+ const body = asRecord(error.body) ?? {};
1204
+ const payload = asRecord(body['payload']) ?? body;
1205
+ return stringField(payload, 'reason') ?? stringField(payload, 'error') ?? `hub ${error.status}`;
1206
+ }
1207
+ return error instanceof Error ? error.message : String(error);
1208
+ }
1209
+ function fetchResultMatchesId(asset, requestedId) {
1210
+ if (assetMatchesId(asset, requestedId))
1211
+ return true;
1212
+ if (!asset)
1213
+ return false;
1214
+ if (!requestedId.startsWith('sha256:'))
1215
+ return stringField(asset, 'id') === requestedId;
1216
+ return false;
1217
+ }
1218
+ function unambiguousFetchResult(matches) {
1219
+ if (matches.length === 0)
1220
+ return null;
1221
+ let canonical;
1222
+ try {
1223
+ canonical = wire.canonicalize(stripHubDeliveryMetadataForIntegrity(matches[0]));
1224
+ for (const asset of matches.slice(1)) {
1225
+ if (wire.canonicalize(stripHubDeliveryMetadataForIntegrity(asset)) !== canonical)
1226
+ return null;
1227
+ }
1228
+ }
1229
+ catch {
1230
+ return null;
1231
+ }
1232
+ return matches.find((asset) => stringField(asset, 'payload_backfill_reason') !== undefined) ?? matches[0];
1233
+ }
1234
+ function isContentAssetIdRequest(requestedId) {
1235
+ return /^sha256:[0-9a-f]{64}$/.test(requestedId);
730
1236
  }
731
1237
  function stringField(value, key) {
732
1238
  return typeof value[key] === 'string' && value[key].length > 0 ? value[key] : undefined;
@@ -800,14 +1306,39 @@ function mailboxPushResultFromBody(body, events) {
800
1306
  if (results.length === 0) {
801
1307
  return { outcomes: events.map((event) => ({ id: event.id, status: 'accepted' })) };
802
1308
  }
1309
+ const resultIds = results.map(mailboxPushResultId);
1310
+ const hasCompletePositions = results.length === events.length;
803
1311
  return {
804
- outcomes: events.map((event, index) => mailboxPushOutcomeFromRow(event.id, results, index)),
1312
+ outcomes: events.map((event, index) => {
1313
+ const matches = results.filter((_, resultIndex) => resultIds[resultIndex] === event.id);
1314
+ if (matches.length === 1)
1315
+ return mailboxPushOutcomeFromRow(event.id, matches[0]);
1316
+ const positionalMatch = matches.length === 0
1317
+ && hasCompletePositions
1318
+ && resultIds[index] === undefined
1319
+ ? results[index]
1320
+ : undefined;
1321
+ return mailboxPushOutcomeFromRow(event.id, positionalMatch);
1322
+ }),
805
1323
  };
806
1324
  }
807
- function mailboxPushOutcomeFromRow(eventId, results, index) {
808
- const match = results.find((result) => String(result['id'] ?? result['message_id'] ?? '') === eventId) ?? results[index];
809
- if (!match)
810
- return { id: eventId, status: 'accepted' };
1325
+ function mailboxPushResultId(row) {
1326
+ const value = row['id'] ?? row['message_id'];
1327
+ if (typeof value !== 'string' && typeof value !== 'number')
1328
+ return undefined;
1329
+ const id = String(value);
1330
+ return id.length > 0 ? id : undefined;
1331
+ }
1332
+ function mailboxPushOutcomeFromRow(eventId, match) {
1333
+ if (!match) {
1334
+ return {
1335
+ id: eventId,
1336
+ status: 'failed',
1337
+ reason: 'mailbox_response_incomplete',
1338
+ retryable: true,
1339
+ terminal: false,
1340
+ };
1341
+ }
811
1342
  const reason = mailboxPushFailureReason(match);
812
1343
  if (!reason)
813
1344
  return { id: eventId, status: 'accepted', raw: match };
@@ -860,9 +1391,12 @@ function hubErrorStatus(err) {
860
1391
  }
861
1392
  return undefined;
862
1393
  }
863
- // Retry-After hint from a HubClientError JSON body (429 cooldown). Headers aren't
864
- // carried on HubClientError, so we read the body's retry_after_ms / retry_after.
1394
+ // Prefer the standardized Retry-After header carried by HubClientError, then
1395
+ // preserve the existing JSON body hints as a compatibility fallback.
865
1396
  function clientErrorRetryAfterMs(err) {
1397
+ if (err instanceof HubClientError && typeof err.retryAfterMs === 'number' && Number.isFinite(err.retryAfterMs)) {
1398
+ return err.retryAfterMs;
1399
+ }
866
1400
  const body = err instanceof HubClientError ? asRecord(err.body) : undefined;
867
1401
  if (!body)
868
1402
  return undefined;
@@ -1006,6 +1540,10 @@ function heartbeatResultFromBody(httpStatus, body) {
1006
1540
  const details = payload['details'] ?? body['details'];
1007
1541
  const ack = asRecord(payload['last_update_ack']);
1008
1542
  const forceUpdate = forceUpdateFromRecord(asRecord(payload['force_update']));
1543
+ const rawCapabilityGaps = payload['capability_gaps'] ?? payload['capabilityGaps'];
1544
+ const capabilityGaps = Array.isArray(rawCapabilityGaps)
1545
+ ? signals.normalizeCapabilityGaps(rawCapabilityGaps)
1546
+ : undefined;
1009
1547
  const ok = httpStatus >= 200
1010
1548
  && httpStatus < 300
1011
1549
  && payload['ok'] !== false
@@ -1023,6 +1561,7 @@ function heartbeatResultFromBody(httpStatus, body) {
1023
1561
  ...(typeof ack['reason'] === 'string' ? { reason: ack['reason'] } : {}),
1024
1562
  } } : {}),
1025
1563
  ...(forceUpdate ? { forceUpdate } : {}),
1564
+ ...(capabilityGaps !== undefined ? { capabilityGaps } : {}),
1026
1565
  };
1027
1566
  }
1028
1567
  function forceUpdateFromRecord(value) {