@evomap/evolver-adapter-public 2.0.0-beta.18 → 2.0.0-beta.19

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/hubFetch.js CHANGED
@@ -5,6 +5,29 @@ export const HUB_ERROR_TEXT_MAX_BYTES = 8 * 1024;
5
5
  export const HUB_JSON_TEXT_MAX_BYTES = 4 * 1024 * 1024;
6
6
  export const HUB_UNREACHABLE_BACKOFF_BASE_MS = 60_000;
7
7
  export const HUB_UNREACHABLE_BACKOFF_MAX_MS = 10 * 60_000;
8
+ export const HUB_GENERAL_TIMEOUT_MS = 15_000;
9
+ export const HUB_SEARCH_TIMEOUT_MS = 8_000;
10
+ export const HUB_HEARTBEAT_TIMEOUT_MS = 10_000;
11
+ export const HUB_EVENT_POLL_TIMEOUT_MS = 60_000;
12
+ export const HUB_HELLO_TIMEOUT_MS = 15_000;
13
+ const HUB_OPERATION_TIMEOUT_KEYS = {
14
+ general: 'generalMs',
15
+ search: 'searchMs',
16
+ heartbeat: 'heartbeatMs',
17
+ poll: 'pollMs',
18
+ hello: 'helloMs',
19
+ };
20
+ const DEFAULT_AUTH_TIMEOUT_MS = 20_000;
21
+ const DEFAULT_DEADLINE_SCHEDULER = {
22
+ set(callback, delayMs) {
23
+ const timer = setTimeout(callback, delayMs);
24
+ timer.unref?.();
25
+ return timer;
26
+ },
27
+ clear(handle) {
28
+ clearTimeout(handle);
29
+ },
30
+ };
8
31
  export class AuthError extends Error {
9
32
  status;
10
33
  body;
@@ -21,10 +44,12 @@ export class AuthError extends Error {
21
44
  export class HubClientError extends Error {
22
45
  status;
23
46
  body;
24
- constructor(status, body) {
47
+ retryAfterMs;
48
+ constructor(status, body, retryAfterMs) {
25
49
  super(`hub ${status}`);
26
50
  this.status = status;
27
51
  this.body = body;
52
+ this.retryAfterMs = retryAfterMs;
28
53
  this.name = 'HubClientError';
29
54
  }
30
55
  }
@@ -52,8 +77,17 @@ function mergeRequestHeaders(requestHeaders, signedHeaders) {
52
77
  const headers = {};
53
78
  for (const [name, value] of Object.entries(requestHeaders ?? {})) {
54
79
  const normalized = name.toLowerCase();
55
- if (!PROTECTED_REQUEST_HEADERS.has(normalized) && !signedNames.has(normalized))
80
+ if (PROTECTED_REQUEST_HEADERS.has(normalized) || signedNames.has(normalized))
81
+ continue;
82
+ if (normalized === 'idempotency-key') {
83
+ const trimmed = value.trim();
84
+ if (!trimmed)
85
+ throw new Error('idempotency-key must be non-empty');
86
+ headers[normalized] = trimmed;
87
+ }
88
+ else {
56
89
  headers[normalized] = value;
90
+ }
57
91
  }
58
92
  headers['content-type'] = 'application/json';
59
93
  return { ...headers, ...signedHeaders };
@@ -67,69 +101,206 @@ function mergeRequestHeaders(requestHeaders, signedHeaders) {
67
101
  */
68
102
  export class HubFetch {
69
103
  deps;
104
+ operationTimeouts;
105
+ deadlineScheduler;
70
106
  constructor(deps) {
71
107
  this.deps = deps;
108
+ this.operationTimeouts = resolveHubOperationTimeouts(deps.env ?? process.env, deps.operationTimeouts);
109
+ this.deadlineScheduler = deps.deadlineScheduler ?? DEFAULT_DEADLINE_SCHEDULER;
72
110
  }
73
111
  async call(method, path, bodyObj, query, requestHeaders) {
112
+ const operation = hubOperationForRequest(path, bodyObj);
74
113
  const draft = bodyObj !== undefined ? JSON.stringify(bodyObj) : '';
75
- const signed = await this.deps.auth.authenticate({ method, path, ...(draft ? { body: draft } : {}) });
76
- const sender = this.deps.senderId();
77
- const creds = signed.bodyFields ?? {};
78
- let url = `${this.deps.baseUrl}${path}`;
79
- assertHubUrlSecure(url); // request-level scheme guard (defense in depth): even a misconfigured injected fetchFn cannot egress in plaintext
80
- let body;
81
- const headers = mergeRequestHeaders(requestHeaders, signed.headers);
82
- if (method === 'GET') {
83
- const qs = new URLSearchParams();
84
- if (sender)
85
- qs.set('sender_id', sender); // identifier, not a credential — query is fine
86
- if (query)
87
- for (const [k, v] of Object.entries(query))
88
- if (v !== undefined)
89
- qs.set(k, String(v)); // non-credential GET params (e.g. semantic-search q)
90
- // #8: credentials must NOT go in the query (leaks to access logs / proxies even over https).
91
- // node_secret travels via Authorization: Bearer; the hub reads it there, never from the query.
92
- const nodeSecret = creds['node_secret'];
93
- if (nodeSecret !== undefined && headers['authorization'] === undefined)
94
- headers['authorization'] = `Bearer ${String(nodeSecret)}`;
95
- const q = qs.toString();
96
- if (q)
97
- url += `?${q}`;
114
+ const authDeadline = createHubDeadline(this.deadlineScheduler, method, path, operation, this.deps.authTimeoutMs ?? DEFAULT_AUTH_TIMEOUT_MS);
115
+ let signed;
116
+ try {
117
+ signed = await awaitWithAbort(this.deps.auth.authenticate({ method, path, ...(draft ? { body: draft } : {}), signal: authDeadline.signal }), authDeadline.signal);
98
118
  }
99
- else {
100
- const postCreds = { ...creds };
101
- const nodeSecret = postCreds['node_secret'];
102
- if ((path === '/a2a/hello' || path === '/a2a/mailbox/outbound') && nodeSecret !== undefined) {
103
- if (headers['authorization'] === undefined)
104
- headers['authorization'] = `Bearer ${String(nodeSecret)}`;
105
- delete postCreds['node_secret'];
106
- }
107
- if (path === '/a2a/mailbox/outbound' && sender) {
108
- const qs = new URLSearchParams({ sender_id: sender });
109
- url += `?${qs.toString()}`;
119
+ catch (error) {
120
+ if (isAuthTransportTimeout(error)) {
121
+ throw new HubUnreachableError('hub authentication timed out', {
122
+ context: `${method} ${path}`,
123
+ retryAfterMs: HUB_UNREACHABLE_BACKOFF_BASE_MS,
124
+ operation,
125
+ });
110
126
  }
111
- body = JSON.stringify({ ...(sender ? { sender_id: sender } : {}), ...postCreds, ...(bodyObj ?? {}) });
127
+ throw error;
112
128
  }
113
- let res;
114
- try {
115
- res = await this.deps.fetchFn(url, { method, headers, ...(body ? { body } : {}) });
129
+ finally {
130
+ authDeadline.dispose();
116
131
  }
117
- catch (err) {
118
- if (isHubUnreachableError(err)) {
119
- throw new HubUnreachableError(`${method} ${path} failed before a Hub API response arrived`, { context: `${method} ${path}`, retryAfterMs: HUB_UNREACHABLE_BACKOFF_BASE_MS });
132
+ const timeoutMs = this.operationTimeouts[HUB_OPERATION_TIMEOUT_KEYS[operation]];
133
+ const deadline = createHubDeadline(this.deadlineScheduler, method, path, operation, timeoutMs);
134
+ try {
135
+ const sender = this.deps.senderId();
136
+ const creds = signed.bodyFields ?? {};
137
+ let url = `${this.deps.baseUrl}${path}`;
138
+ assertHubUrlSecure(url); // request-level scheme guard (defense in depth): even a misconfigured injected fetchFn cannot egress in plaintext
139
+ let body;
140
+ const headers = mergeRequestHeaders(requestHeaders, signed.headers);
141
+ if (method === 'GET') {
142
+ const qs = new URLSearchParams();
143
+ if (sender)
144
+ qs.set('sender_id', sender); // identifier, not a credential — query is fine
145
+ if (query)
146
+ for (const [k, v] of Object.entries(query))
147
+ if (v !== undefined)
148
+ qs.set(k, String(v)); // non-credential GET params (e.g. semantic-search q)
149
+ // #8: credentials must NOT go in the query (leaks to access logs / proxies even over https).
150
+ // node_secret travels via Authorization: Bearer; the hub reads it there, never from the query.
151
+ const nodeSecret = creds['node_secret'];
152
+ if (nodeSecret !== undefined && headers['authorization'] === undefined)
153
+ headers['authorization'] = `Bearer ${String(nodeSecret)}`;
154
+ const q = qs.toString();
155
+ if (q)
156
+ url += `?${q}`;
120
157
  }
121
- throw err;
158
+ else {
159
+ const postCreds = { ...creds };
160
+ const nodeSecret = postCreds['node_secret'];
161
+ if ((path === '/a2a/hello' || path === '/a2a/mailbox/outbound') && nodeSecret !== undefined) {
162
+ if (headers['authorization'] === undefined)
163
+ headers['authorization'] = `Bearer ${String(nodeSecret)}`;
164
+ delete postCreds['node_secret'];
165
+ }
166
+ if (path === '/a2a/mailbox/outbound' && sender) {
167
+ const qs = new URLSearchParams({ sender_id: sender });
168
+ url += `?${qs.toString()}`;
169
+ }
170
+ body = JSON.stringify({ ...(sender ? { sender_id: sender } : {}), ...postCreds, ...(bodyObj ?? {}) });
171
+ }
172
+ let res;
173
+ try {
174
+ res = await awaitWithAbort(this.deps.fetchFn(url, {
175
+ method,
176
+ headers,
177
+ ...(body ? { body } : {}),
178
+ signal: deadline.signal,
179
+ redirect: 'manual',
180
+ }), deadline.signal);
181
+ }
182
+ catch (err) {
183
+ if (deadline.signal.aborted)
184
+ throw deadline.error;
185
+ if (isHubUnreachableError(err)) {
186
+ throw new HubUnreachableError(`${method} ${path} failed before a Hub API response arrived`, { context: `${method} ${path}`, retryAfterMs: HUB_UNREACHABLE_BACKOFF_BASE_MS });
187
+ }
188
+ throw err;
189
+ }
190
+ if (deadline.signal.aborted)
191
+ throw deadline.error;
192
+ const retryAfterMs = parseRetryAfterMs(headerValue(res.headers, 'retry-after'), this.deps.now?.() ?? Date.now());
193
+ if (res.status >= 300 && res.status < 400) {
194
+ await drainHubResponse(res, { signal: deadline.signal });
195
+ if (deadline.signal.aborted)
196
+ throw deadline.error;
197
+ throw new HubUnreachableError(`${method} ${path} refused an unexpected Hub redirect`, { status: res.status, context: `${method} ${path}`, retryAfterMs: retryAfterMs ?? HUB_UNREACHABLE_BACKOFF_BASE_MS });
198
+ }
199
+ const parsed = await readHubResponseJsonForClassification(res, deadline.signal);
200
+ if (deadline.signal.aborted)
201
+ throw deadline.error;
202
+ throwIfParsedHubUnreachableResponse(res, parsed, `${method} ${path}`, retryAfterMs);
203
+ if (res.status === 401 || res.status === 403)
204
+ throw new AuthError(res.status, parsed.body);
205
+ if (res.status >= 400 && res.status < 500) {
206
+ throw new HubClientError(res.status, parsed.ok ? parsed.body : {}, retryAfterMs);
207
+ }
208
+ if (res.status >= 500)
209
+ throw new Error(`hub ${res.status}`);
210
+ return parsed.body;
122
211
  }
123
- const parsed = await readHubResponseJsonForClassification(res);
124
- throwIfParsedHubUnreachableResponse(res, parsed, `${method} ${path}`);
125
- if (res.status === 401 || res.status === 403)
126
- throw new AuthError(res.status, parsed.body);
127
- if (res.status >= 400 && res.status < 500)
128
- throw new HubClientError(res.status, parsed.ok ? parsed.body : {});
129
- if (res.status >= 500)
130
- throw new Error(`hub ${res.status}`);
131
- return parsed.body;
212
+ finally {
213
+ deadline.dispose();
214
+ }
215
+ }
216
+ }
217
+ function positiveTimeoutMs(value, fallback) {
218
+ if (value === undefined || value === '')
219
+ return fallback;
220
+ const raw = String(value);
221
+ if (!/^\d+$/.test(raw))
222
+ return fallback;
223
+ const parsed = Number(raw);
224
+ return Number.isFinite(parsed) && parsed > 0 && parsed < 2 ** 31 ? parsed : fallback;
225
+ }
226
+ export function resolveHubOperationTimeouts(env = process.env, overrides = {}) {
227
+ const fromEnv = {
228
+ generalMs: positiveTimeoutMs(env['EVOLVER_HTTP_TRANSPORT_TIMEOUT_MS'], HUB_GENERAL_TIMEOUT_MS),
229
+ searchMs: positiveTimeoutMs(env['EVOLVER_HUB_SEARCH_TIMEOUT_MS'], HUB_SEARCH_TIMEOUT_MS),
230
+ heartbeatMs: positiveTimeoutMs(env['EVOLVER_HEARTBEAT_TIMEOUT_MS'], HUB_HEARTBEAT_TIMEOUT_MS),
231
+ pollMs: positiveTimeoutMs(env['EVOLVER_EVENT_POLL_TIMEOUT_MS'], HUB_EVENT_POLL_TIMEOUT_MS),
232
+ helloMs: positiveTimeoutMs(env['EVOLVER_HELLO_TIMEOUT_MS'], HUB_HELLO_TIMEOUT_MS),
233
+ };
234
+ return {
235
+ generalMs: positiveTimeoutMs(overrides.generalMs, fromEnv.generalMs),
236
+ searchMs: positiveTimeoutMs(overrides.searchMs, fromEnv.searchMs),
237
+ heartbeatMs: positiveTimeoutMs(overrides.heartbeatMs, fromEnv.heartbeatMs),
238
+ pollMs: positiveTimeoutMs(overrides.pollMs, fromEnv.pollMs),
239
+ helloMs: positiveTimeoutMs(overrides.helloMs, fromEnv.helloMs),
240
+ };
241
+ }
242
+ function hubOperationForRequest(path, bodyObj) {
243
+ if (path === '/a2a/fetch') {
244
+ const payload = bodyObj?.['payload'];
245
+ if (payload !== null && typeof payload === 'object' && !Array.isArray(payload)
246
+ && payload['search_only'] === true)
247
+ return 'search';
248
+ }
249
+ if (path === '/a2a/assets/semantic-search' || path === '/a2a/directory/search')
250
+ return 'search';
251
+ if (path === '/a2a/heartbeat')
252
+ return 'heartbeat';
253
+ if (path === '/a2a/events/poll')
254
+ return 'poll';
255
+ if (path === '/a2a/hello')
256
+ return 'hello';
257
+ return 'general';
258
+ }
259
+ function createHubDeadline(scheduler, method, path, operation, timeoutMs) {
260
+ const controller = new AbortController();
261
+ const error = new HubUnreachableError(`${method} ${path} timed out after ${timeoutMs}ms`, {
262
+ context: `${method} ${path}`,
263
+ retryAfterMs: HUB_UNREACHABLE_BACKOFF_BASE_MS,
264
+ operation,
265
+ timeoutMs,
266
+ });
267
+ const handle = scheduler.set(() => controller.abort(error), timeoutMs);
268
+ return {
269
+ signal: controller.signal,
270
+ error,
271
+ dispose: () => scheduler.clear(handle),
272
+ };
273
+ }
274
+ function abortReason(signal) {
275
+ if (signal.reason instanceof Error)
276
+ return signal.reason;
277
+ const error = new Error('Hub request aborted');
278
+ error.name = 'AbortError';
279
+ return error;
280
+ }
281
+ async function awaitWithAbort(promise, signal) {
282
+ const pending = Promise.resolve(promise);
283
+ if (!signal)
284
+ return await pending;
285
+ if (signal.aborted) {
286
+ void pending.catch(() => { });
287
+ throw abortReason(signal);
132
288
  }
289
+ return await new Promise((resolve, reject) => {
290
+ const onAbort = () => {
291
+ cleanup();
292
+ reject(abortReason(signal));
293
+ };
294
+ const cleanup = () => signal.removeEventListener('abort', onAbort);
295
+ signal.addEventListener('abort', onAbort, { once: true });
296
+ void pending.then((value) => {
297
+ cleanup();
298
+ resolve(value);
299
+ }, (error) => {
300
+ cleanup();
301
+ reject(error);
302
+ });
303
+ });
133
304
  }
134
305
  function hubErrorCode(body) {
135
306
  const record = body && typeof body === 'object' && !Array.isArray(body) ? body : undefined;
@@ -167,6 +338,19 @@ function headerValue(headers, name) {
167
338
  return '';
168
339
  }
169
340
  }
341
+ function parseRetryAfterMs(value, now) {
342
+ const raw = value.trim();
343
+ if (!raw || !Number.isFinite(now))
344
+ return undefined;
345
+ if (/^\d+$/.test(raw)) {
346
+ const milliseconds = Number(raw) * 1_000;
347
+ return Number.isSafeInteger(milliseconds) ? milliseconds : undefined;
348
+ }
349
+ if (/^[+-]?\d+(?:\.\d+)?$/.test(raw))
350
+ return undefined;
351
+ const retryAt = Date.parse(raw);
352
+ return Number.isFinite(retryAt) ? Math.max(0, retryAt - now) : undefined;
353
+ }
170
354
  export function hubResponseContentType(res) {
171
355
  return headerValue(res?.headers, 'content-type').toLowerCase();
172
356
  }
@@ -194,6 +378,10 @@ const NETWORK_DISRUPTION_CODES = new Set([
194
378
  'UND_ERR_HEADERS_TIMEOUT',
195
379
  'UND_ERR_BODY_TIMEOUT',
196
380
  ]);
381
+ function isAuthTransportTimeout(error) {
382
+ return error instanceof Error
383
+ && (error.message === 'oauth_refresh_timeout' || error.message === 'device_flow_timeout');
384
+ }
197
385
  export function isHubUnreachableError(err) {
198
386
  const e = err;
199
387
  if (!e)
@@ -225,11 +413,11 @@ function toBytes(value) {
225
413
  return Buffer.from(value, 'utf8');
226
414
  return Buffer.from(String(value ?? ''), 'utf8');
227
415
  }
228
- export async function drainHubResponse(res) {
416
+ export async function drainHubResponse(res, opts = {}) {
229
417
  const body = res?.body;
230
418
  try {
231
419
  if (body && typeof body.cancel === 'function') {
232
- await body.cancel();
420
+ await awaitWithAbort(Promise.resolve(body.cancel()), opts.signal);
233
421
  }
234
422
  }
235
423
  catch {
@@ -246,7 +434,7 @@ export async function readHubResponseText(res, opts = {}) {
246
434
  let truncated = false;
247
435
  try {
248
436
  for (;;) {
249
- const part = await reader.read();
437
+ const part = await awaitWithAbort(reader.read(), opts.signal);
250
438
  if (part.done)
251
439
  break;
252
440
  const bytes = toBytes(part.value);
@@ -257,7 +445,7 @@ export async function readHubResponseText(res, opts = {}) {
257
445
  total += remaining;
258
446
  }
259
447
  truncated = true;
260
- await reader.cancel?.();
448
+ await cancelReader(reader, opts.signal);
261
449
  break;
262
450
  }
263
451
  chunks.push(bytes);
@@ -265,7 +453,7 @@ export async function readHubResponseText(res, opts = {}) {
265
453
  }
266
454
  }
267
455
  catch (err) {
268
- await reader.cancel?.();
456
+ await cancelReader(reader, opts.signal);
269
457
  throw err;
270
458
  }
271
459
  finally {
@@ -278,14 +466,27 @@ export async function readHubResponseText(res, opts = {}) {
278
466
  return '';
279
467
  throw new Error('hub response body stream missing');
280
468
  }
469
+ async function cancelReader(reader, signal) {
470
+ try {
471
+ if (reader.cancel)
472
+ await awaitWithAbort(Promise.resolve(reader.cancel()), signal);
473
+ }
474
+ catch {
475
+ // Cancellation is best-effort; preserve the read/timeout error.
476
+ }
477
+ }
281
478
  export async function readHubResponseJson(res, opts = {}) {
282
- const text = await readHubResponseText(res, { maxBytes: opts.maxBytes ?? HUB_JSON_TEXT_MAX_BYTES });
479
+ const text = await readHubResponseText(res, {
480
+ maxBytes: opts.maxBytes ?? HUB_JSON_TEXT_MAX_BYTES,
481
+ ...(opts.signal ? { signal: opts.signal } : {}),
482
+ });
283
483
  return JSON.parse(text);
284
484
  }
285
485
  export async function throwIfHubUnreachableResponse(res, context = 'hub') {
486
+ const retryAfterMs = parseRetryAfterMs(headerValue(res.headers, 'retry-after'), Date.now());
286
487
  if (!isHubUnreachableResponse(res)) {
287
488
  const parsed = await readHubResponseJsonForClassification(res);
288
- throwIfParsedHubUnreachableResponse(res, parsed, context);
489
+ throwIfParsedHubUnreachableResponse(res, parsed, context, retryAfterMs);
289
490
  return;
290
491
  }
291
492
  const status = Number(res.status) || undefined;
@@ -296,22 +497,30 @@ export async function throwIfHubUnreachableResponse(res, context = 'hub') {
296
497
  catch {
297
498
  // Best-effort pool hygiene only.
298
499
  }
299
- throw new HubUnreachableError(`${context} returned a non-API Hub response (${status ?? 'unknown status'}, ${contentType})`, { ...(status !== undefined ? { status } : {}), contentType, context, retryAfterMs: HUB_UNREACHABLE_BACKOFF_BASE_MS });
500
+ throw new HubUnreachableError(`${context} returned a non-API Hub response (${status ?? 'unknown status'}, ${contentType})`, {
501
+ ...(status !== undefined ? { status } : {}),
502
+ contentType,
503
+ context,
504
+ retryAfterMs: retryAfterMs ?? HUB_UNREACHABLE_BACKOFF_BASE_MS,
505
+ });
300
506
  }
301
- async function readHubResponseJsonForClassification(res) {
507
+ async function readHubResponseJsonForClassification(res, signal) {
302
508
  if (isHubUnreachableResponse(res)) {
303
- await drainHubResponse(res);
509
+ await drainHubResponse(res, { ...(signal ? { signal } : {}) });
304
510
  return { ok: false, reason: 'non_api_content_type' };
305
511
  }
306
512
  try {
307
- const text = await readHubResponseText(res, { maxBytes: HUB_JSON_TEXT_MAX_BYTES });
513
+ const text = await readHubResponseText(res, {
514
+ maxBytes: HUB_JSON_TEXT_MAX_BYTES,
515
+ ...(signal ? { signal } : {}),
516
+ });
308
517
  return { ok: true, body: JSON.parse(text) };
309
518
  }
310
519
  catch (err) {
311
520
  return { ok: false, reason: err instanceof Error ? err.message : String(err) };
312
521
  }
313
522
  }
314
- function throwIfParsedHubUnreachableResponse(res, parsed, context) {
523
+ function throwIfParsedHubUnreachableResponse(res, parsed, context, retryAfterMs) {
315
524
  const status = Number(res.status) || undefined;
316
525
  const contentType = hubResponseContentType(res);
317
526
  if (!isHubApiResponse(res) || !parsed.ok) {
@@ -319,7 +528,7 @@ function throwIfParsedHubUnreachableResponse(res, parsed, context) {
319
528
  ...(status !== undefined ? { status } : {}),
320
529
  contentType: contentType || 'unknown content-type',
321
530
  context,
322
- retryAfterMs: HUB_UNREACHABLE_BACKOFF_BASE_MS,
531
+ retryAfterMs: retryAfterMs ?? HUB_UNREACHABLE_BACKOFF_BASE_MS,
323
532
  });
324
533
  }
325
534
  }
@@ -355,6 +564,7 @@ export function assertHubUrlSecure(url, env = process.env) {
355
564
  }
356
565
  export const HUB_CONNECT_TIMEOUT_MS = 10_000;
357
566
  export const HUB_IPV4FIRST_PRIMARY_CONNECT_TIMEOUT_MS = 2_500;
567
+ export const HUB_TCP_KEEPALIVE_IDLE_MS = 15_000;
358
568
  export function resolveHubIpFamily(env = process.env) {
359
569
  const raw = String(env['EVOMAP_HUB_IP_FAMILY'] ?? 'ipv4first').trim().toLowerCase();
360
570
  if (raw === 'ipv4' || raw === 'v4' || raw === '4' || raw === 'ipv4first' || raw === 'ipv4-first')
@@ -419,24 +629,41 @@ function errorCode(err) {
419
629
  function shouldFallbackFromIpv4(err, hubIpFamily) {
420
630
  return hubIpFamily === 'ipv4first' && IPV4_FALLBACK_CODES.has(errorCode(err) ?? '');
421
631
  }
632
+ function configureHubSocket(socket) {
633
+ if (socket === null || typeof socket !== 'object')
634
+ return;
635
+ const setKeepAlive = socket.setKeepAlive;
636
+ if (typeof setKeepAlive !== 'function')
637
+ return;
638
+ try {
639
+ setKeepAlive.call(socket, true, HUB_TCP_KEEPALIVE_IDLE_MS);
640
+ }
641
+ catch {
642
+ // Kernel/socket support varies. A keepalive tuning failure must never turn a valid TLS connection into an outage.
643
+ }
644
+ }
422
645
  function makeHubConnector(config) {
423
646
  const primaryConnect = buildConnector(config.primaryConnectOpts);
424
647
  const fallbackConnect = config.fallbackConnectOpts ? buildConnector(config.fallbackConnectOpts) : null;
425
648
  const connector = (opts, cb) => {
426
- primaryConnect(opts, (err, socket) => {
427
- if (err && fallbackConnect && shouldFallbackFromIpv4(err, config.hubIpFamily)) {
428
- fallbackConnect(opts, cb);
429
- return;
430
- }
649
+ const finish = (err, socket) => {
431
650
  if (err) {
432
651
  cb(err, null);
433
652
  return;
434
653
  }
435
- if (socket) {
436
- cb(null, socket);
654
+ if (!socket) {
655
+ cb(new Error('[hubFetch] undici connector returned no socket'), null);
656
+ return;
657
+ }
658
+ configureHubSocket(socket);
659
+ cb(null, socket);
660
+ };
661
+ primaryConnect(opts, (err, socket) => {
662
+ if (err && fallbackConnect && shouldFallbackFromIpv4(err, config.hubIpFamily)) {
663
+ fallbackConnect(opts, finish);
437
664
  return;
438
665
  }
439
- cb(new Error('[hubFetch] undici connector returned no socket'), null);
666
+ finish(err, socket);
440
667
  });
441
668
  };
442
669
  const marked = connector;
@@ -447,7 +674,12 @@ const HUB_FETCH_CONFIG = makeHubFetchTransportConfig(process.env);
447
674
  // Singleton TLS-enforcing dispatcher: overrides a global NODE_TLS_REJECT_UNAUTHORIZED=0. The Agent and
448
675
  // fetch MUST come from the same undici package (mixing an npm-undici Agent with Node's built-in global.fetch
449
676
  // throws UND_ERR_INVALID_ARG). The connector applies TLS verification plus the selected Hub IP-family policy.
450
- const STRICT_TLS_AGENT = new Agent({ connect: makeHubConnector(HUB_FETCH_CONFIG) });
677
+ const STRICT_TLS_AGENT = new Agent({
678
+ connect: makeHubConnector(HUB_FETCH_CONFIG),
679
+ keepAliveTimeout: 10_000,
680
+ keepAliveMaxTimeout: 60_000,
681
+ pipelining: 1,
682
+ });
451
683
  export function _getHubFetchConfigForTest(env) {
452
684
  return env ? makeHubFetchTransportConfig(env) : {
453
685
  ...HUB_FETCH_CONFIG,
@@ -459,6 +691,9 @@ export function _getHubFetchConfigForTest(env) {
459
691
  export function _shouldFallbackFromIpv4ForTest(err, hubIpFamily = HUB_FETCH_CONFIG.hubIpFamily) {
460
692
  return shouldFallbackFromIpv4(err, hubIpFamily);
461
693
  }
694
+ export function _configureHubSocketForTest(socket) {
695
+ configureHubSocket(socket);
696
+ }
462
697
  // Test seam: lets unit tests swap the underlying fetch without forking the call path; production must never reassign it from outside.
463
698
  let _fetchImpl = undiciFetch;
464
699
  export function _setFetchImplForTest(fn) { _fetchImpl = fn ?? undiciFetch; }
@@ -476,7 +711,7 @@ function warnInsecureOnce() {
476
711
  export function _resetInsecureWarningForTest() { _insecureWarned = false; }
477
712
  /** Default production transport: secure mode = https guard + forced TLS dispatcher; escape-hatch mode = skip both (local dev). */
478
713
  export const globalFetchLike = async (url, init) => {
479
- const raw = init;
714
+ const raw = { ...init, redirect: 'manual' };
480
715
  if (insecureAllowed(process.env)) {
481
716
  warnInsecureOnce();
482
717
  return (await _fetchImpl(url, raw));
@@ -5,6 +5,9 @@ type GeneCandidateInput = algo.GeneCandidateInput;
5
5
  export declare const SEARCH_CACHE_TTL_MS: number;
6
6
  export declare const SEARCH_CACHE_MAX = 200;
7
7
  export declare const PAYLOAD_CACHE_MAX = 100;
8
+ export declare const SEMANTIC_SEARCH_LIMIT = 10;
9
+ export declare const SEMANTIC_QUERY_MAX_TERMS = 12;
10
+ export declare const SEMANTIC_QUERY_MAX_CHARS = 512;
8
11
  /** Default reuse mode (ported from v1): 'reference' injects the asset as a strong hint; 'direct' applies it. */
9
12
  export type ReuseMode = 'direct' | 'reference';
10
13
  export declare const DEFAULT_REUSE_MODE: ReuseMode;
@@ -12,8 +15,23 @@ export declare const DEFAULT_REUSE_MODE: ReuseMode;
12
15
  export declare function getMinReuseScore(env?: NodeJS.ProcessEnv): number;
13
16
  /** Reads EVOLVER_REUSE_MODE here (adapter concern). */
14
17
  export declare function getReuseMode(env?: NodeJS.ProcessEnv): ReuseMode;
18
+ /** V1-compatible kill-switch. Semantic recall is on unless explicitly disabled. */
19
+ export declare function isSemanticSearchEnabled(env?: NodeJS.ProcessEnv): boolean;
20
+ /**
21
+ * Derive a bounded public semantic query from structured signal tags. Error signatures, paths, prose, and other
22
+ * unstructured values are excluded so the vector-search leg cannot become a side channel for local diagnostics.
23
+ */
24
+ export declare function buildSemanticQuery(signals: readonly string[]): string;
15
25
  /** Stable signal fingerprint (ported from v1 _cacheKey: sort + join). */
16
26
  export declare function signalFingerprint(signals: readonly string[]): string;
27
+ export declare const TASK_DOMAIN_SIGNAL_PREFIX = "task_domain:";
28
+ /**
29
+ * Resolve the hub-side domain fence from this turn's signals. Exactly one domain is used and only
30
+ * when the turn is unambiguous: with two or more distinct task_domain:* signals the turn spans
31
+ * domains, and scoping recall to either one would hide the other's assets — so we return null and
32
+ * fall back to unscoped recall (today's behaviour).
33
+ */
34
+ export declare function hubDomainFromSignals(signals: readonly string[]): string | null;
17
35
  /**
18
36
  * The two-layer reuse cache. Bounded + TTL'd, per-process. A search-cache hit means phase 1 makes ZERO hub
19
37
  * calls; a payload-cache hit means phase 3 makes ZERO hub calls. The clock is injected for deterministic tests.
@@ -55,6 +73,8 @@ export interface ReuseBeforeSolveOptions {
55
73
  searchLimit?: number;
56
74
  /** Reuse mode label carried into the result (direct/reference). */
57
75
  mode?: ReuseMode;
76
+ /** Environment snapshot for adapter-owned reuse settings and the semantic-search kill-switch. */
77
+ env?: NodeJS.ProcessEnv;
58
78
  /** Observability sink (asset-call log). Receives structured records; never throws. */
59
79
  log?: {
60
80
  append(entry: Record<string, unknown>): void;
@@ -97,6 +117,26 @@ export interface ReuseBeforeSolveResult {
97
117
  /** Why we didn't reuse, when action === 'solve-fresh' (no_signals / no_results / below_threshold). */
98
118
  reason?: string;
99
119
  }
120
+ export interface HubMetadataSearchOptions {
121
+ /** Environment snapshot for the semantic-search kill-switch. */
122
+ env?: NodeJS.ProcessEnv;
123
+ /** Cap on the signal-search leg. The semantic leg keeps its own bounded limit. */
124
+ searchLimit?: number;
125
+ }
126
+ export interface HubMetadataSearchResult {
127
+ signals: string[];
128
+ fingerprint: string;
129
+ metadata: hub.HubMetadata[];
130
+ searchCached: boolean;
131
+ /** False when either free search leg failed. Incomplete results must never prove a miss. */
132
+ complete: boolean;
133
+ error?: unknown;
134
+ }
135
+ /**
136
+ * Run the complete free-search phase shared by reuse and economic miss probes. This function never performs the
137
+ * paid fetch. Only complete dual-leg results enter the cache, so a partial outage cannot become a verified miss.
138
+ */
139
+ export declare function searchHubMetadata(cap: hub.HubCapability, cache: ReuseCache, signals: readonly string[], opts?: HubMetadataSearchOptions): Promise<HubMetadataSearchResult>;
100
140
  /**
101
141
  * The reuse-before-solve flow. Returns the single winner (already fetched) as a selection candidate, or a
102
142
  * solve-fresh verdict. Never throws on a hub error — reuse is an optimization, not a hard dependency: