@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.
- package/dist/antiAbuseTelemetry.js +2 -1
- package/dist/auth/credentialStore.d.ts +90 -3
- package/dist/auth/credentialStore.js +1096 -10
- package/dist/auth/legacyShim.d.ts +2 -2
- package/dist/auth/legacyShim.js +2 -2
- package/dist/auth/oauthDeviceToken.d.ts +9 -6
- package/dist/auth/oauthDeviceToken.js +71 -18
- package/dist/auth/oauthHttpTransport.d.ts +4 -0
- package/dist/auth/oauthHttpTransport.js +66 -15
- package/dist/auth/windowsPowerShell.d.ts +3 -0
- package/dist/auth/windowsPowerShell.js +91 -0
- package/dist/hubCapability.d.ts +35 -9
- package/dist/hubCapability.js +593 -54
- package/dist/hubFetch.d.ts +47 -14
- package/dist/hubFetch.js +374 -79
- package/dist/hubReuse.d.ts +41 -0
- package/dist/hubReuse.js +328 -33
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/learningPacketFeedback.d.ts +68 -0
- package/dist/learningPacketFeedback.js +104 -0
- package/dist/learningPacketSink.d.ts +48 -0
- package/dist/learningPacketSink.js +194 -0
- package/dist/wireMap.d.ts +3 -1
- package/dist/wireMap.js +29 -3
- package/package.json +9 -2
package/dist/hubCapability.js
CHANGED
|
@@ -1,15 +1,17 @@
|
|
|
1
|
-
import {
|
|
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
|
-
...(
|
|
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
|
-
|
|
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
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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
|
|
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
|
-
...(
|
|
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(
|
|
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
|
|
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
|
|
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
|
|
289
|
+
return assetsFromBody(body);
|
|
225
290
|
}
|
|
226
|
-
|
|
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
|
|
299
|
+
return { status: 'absent' };
|
|
230
300
|
const body = await this.http.call('POST', '/a2a/fetch', gepEnvelope('fetch', { asset_ids: [id] }));
|
|
231
|
-
|
|
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
|
-
*
|
|
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).
|
|
241
|
-
//
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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) =>
|
|
1119
|
+
return candidate.filter((asset) => isLearningAssetRecord(asset));
|
|
725
1120
|
}
|
|
726
1121
|
return [];
|
|
727
1122
|
}
|
|
728
|
-
function
|
|
729
|
-
|
|
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) =>
|
|
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
|
|
808
|
-
const
|
|
809
|
-
if (
|
|
810
|
-
return
|
|
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
|
|
864
|
-
//
|
|
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) {
|