@zackbart/connecta 0.7.8 → 0.8.0
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/CHANGELOG.md +94 -0
- package/README.md +67 -144
- package/dist/activity.d.ts +1 -1
- package/dist/activity.d.ts.map +1 -1
- package/dist/activity.js.map +1 -1
- package/dist/auth/bearer.d.ts +1 -1
- package/dist/auth/bearer.js +4 -4
- package/dist/auth/bearer.js.map +1 -1
- package/dist/auth/clerk.d.ts +1 -1
- package/dist/auth/clerk.js +1 -1
- package/dist/call-admission.d.ts +81 -0
- package/dist/call-admission.d.ts.map +1 -0
- package/dist/call-admission.js +345 -0
- package/dist/call-admission.js.map +1 -0
- package/dist/catalog-fingerprint.d.ts +20 -0
- package/dist/catalog-fingerprint.d.ts.map +1 -0
- package/dist/catalog-fingerprint.js +34 -0
- package/dist/catalog-fingerprint.js.map +1 -0
- package/dist/catalog-limits.d.ts +10 -0
- package/dist/catalog-limits.d.ts.map +1 -0
- package/dist/catalog-limits.js +10 -0
- package/dist/catalog-limits.js.map +1 -0
- package/dist/catalog-service.d.ts +138 -0
- package/dist/catalog-service.d.ts.map +1 -0
- package/dist/catalog-service.js +407 -0
- package/dist/catalog-service.js.map +1 -0
- package/dist/catalog.js +2 -2
- package/dist/catalog.js.map +1 -1
- package/dist/concurrency.d.ts +4 -0
- package/dist/concurrency.d.ts.map +1 -0
- package/dist/concurrency.js +31 -0
- package/dist/concurrency.js.map +1 -0
- package/dist/connectors/api.d.ts +3 -1
- package/dist/connectors/api.d.ts.map +1 -1
- package/dist/connectors/api.js +22 -11
- package/dist/connectors/api.js.map +1 -1
- package/dist/connectors/remote-mcp.d.ts +3 -1
- package/dist/connectors/remote-mcp.d.ts.map +1 -1
- package/dist/connectors/remote-mcp.js +44 -18
- package/dist/connectors/remote-mcp.js.map +1 -1
- package/dist/credential-health.d.ts +0 -22
- package/dist/credential-health.d.ts.map +1 -1
- package/dist/credential-health.js +14 -10
- package/dist/credential-health.js.map +1 -1
- package/dist/credentials.d.ts +3 -2
- package/dist/credentials.d.ts.map +1 -1
- package/dist/errors.d.ts +1 -1
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js.map +1 -1
- package/dist/execute.d.ts +9 -8
- package/dist/execute.d.ts.map +1 -1
- package/dist/execute.js +144 -291
- package/dist/execute.js.map +1 -1
- package/dist/executor-result.d.ts +0 -3
- package/dist/executor-result.d.ts.map +1 -1
- package/dist/executor-result.js +2 -2
- package/dist/executor-result.js.map +1 -1
- package/dist/executors/quickjs-child.js +3 -3
- package/dist/executors/quickjs-child.js.map +1 -1
- package/dist/executors/quickjs-protocol.d.ts +4 -1
- package/dist/executors/quickjs-protocol.d.ts.map +1 -1
- package/dist/executors/quickjs-runtime.d.ts.map +1 -1
- package/dist/executors/quickjs-runtime.js +7 -2
- package/dist/executors/quickjs-runtime.js.map +1 -1
- package/dist/executors/quickjs.d.ts.map +1 -1
- package/dist/executors/quickjs.js +4 -1
- package/dist/executors/quickjs.js.map +1 -1
- package/dist/favicon.js +2 -2
- package/dist/favicon.js.map +1 -1
- package/dist/index.d.ts +6 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +45 -17
- package/dist/index.js.map +1 -1
- package/dist/invocation.d.ts +96 -0
- package/dist/invocation.d.ts.map +1 -0
- package/dist/invocation.js +369 -0
- package/dist/invocation.js.map +1 -0
- package/dist/meta-tools.d.ts +11 -86
- package/dist/meta-tools.d.ts.map +1 -1
- package/dist/meta-tools.js +145 -485
- package/dist/meta-tools.js.map +1 -1
- package/dist/operator-ui/generated.d.ts +3 -0
- package/dist/operator-ui/generated.d.ts.map +1 -0
- package/dist/operator-ui/generated.js +5 -0
- package/dist/operator-ui/generated.js.map +1 -0
- package/dist/operator-ui/model.d.ts +94 -0
- package/dist/operator-ui/model.d.ts.map +1 -0
- package/dist/operator-ui/model.js +28 -0
- package/dist/operator-ui/model.js.map +1 -0
- package/dist/registry.d.ts +47 -6
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +356 -35
- package/dist/registry.js.map +1 -1
- package/dist/routes/activity.d.ts +3 -0
- package/dist/routes/activity.d.ts.map +1 -0
- package/dist/routes/activity.js +173 -0
- package/dist/routes/activity.js.map +1 -0
- package/dist/routes/credentials.d.ts +3 -0
- package/dist/routes/credentials.d.ts.map +1 -0
- package/dist/routes/credentials.js +181 -0
- package/dist/routes/credentials.js.map +1 -0
- package/dist/routes/mcp.d.ts +8 -0
- package/dist/routes/mcp.d.ts.map +1 -0
- package/dist/routes/mcp.js +383 -0
- package/dist/routes/mcp.js.map +1 -0
- package/dist/routes/oauth.d.ts +4 -0
- package/dist/routes/oauth.d.ts.map +1 -0
- package/dist/routes/oauth.js +268 -0
- package/dist/routes/oauth.js.map +1 -0
- package/dist/routes/shared.d.ts +97 -0
- package/dist/routes/shared.d.ts.map +1 -0
- package/dist/routes/shared.js +199 -0
- package/dist/routes/shared.js.map +1 -0
- package/dist/routes/ui.d.ts +3 -0
- package/dist/routes/ui.d.ts.map +1 -0
- package/dist/routes/ui.js +122 -0
- package/dist/routes/ui.js.map +1 -0
- package/dist/server.d.ts +9 -40
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +67 -1292
- package/dist/server.js.map +1 -1
- package/dist/skills.d.ts +0 -15
- package/dist/skills.d.ts.map +1 -1
- package/dist/skills.js +6 -5
- package/dist/skills.js.map +1 -1
- package/dist/storage/file.d.ts.map +1 -1
- package/dist/storage/file.js +3 -1
- package/dist/storage/file.js.map +1 -1
- package/dist/storage/memory.d.ts.map +1 -1
- package/dist/storage/memory.js +3 -1
- package/dist/storage/memory.js.map +1 -1
- package/dist/toolkits.d.ts +0 -22
- package/dist/toolkits.d.ts.map +1 -1
- package/dist/toolkits.js +2 -2
- package/dist/toolkits.js.map +1 -1
- package/dist/types.d.ts +64 -2
- package/dist/types.d.ts.map +1 -1
- package/dist/ui.d.ts +3 -93
- package/dist/ui.d.ts.map +1 -1
- package/dist/ui.js +26 -1389
- package/dist/ui.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +15 -8
- package/src/activity.ts +5 -1
- package/src/auth/bearer.ts +4 -4
- package/src/auth/clerk.ts +1 -1
- package/src/call-admission.ts +522 -0
- package/src/catalog-fingerprint.ts +52 -0
- package/src/catalog-limits.ts +11 -0
- package/src/catalog-service.ts +601 -0
- package/src/catalog.ts +2 -2
- package/src/concurrency.ts +39 -0
- package/src/connectors/api.ts +25 -11
- package/src/connectors/remote-mcp.ts +55 -21
- package/src/credential-health.ts +13 -10
- package/src/credentials.ts +2 -2
- package/src/errors.ts +1 -1
- package/src/execute.ts +194 -377
- package/src/executor-result.ts +2 -2
- package/src/executors/quickjs-child.ts +8 -4
- package/src/executors/quickjs-protocol.ts +1 -1
- package/src/executors/quickjs-runtime.ts +5 -1
- package/src/executors/quickjs.ts +11 -8
- package/src/favicon.ts +2 -2
- package/src/index.ts +54 -17
- package/src/invocation.ts +527 -0
- package/src/meta-tools.ts +369 -797
- package/src/operator-ui/browser.css +413 -0
- package/src/operator-ui/browser.ts +1121 -0
- package/src/operator-ui/generated.ts +4 -0
- package/src/operator-ui/model.ts +127 -0
- package/src/registry.ts +489 -44
- package/src/routes/activity.ts +220 -0
- package/src/routes/credentials.ts +266 -0
- package/src/routes/mcp.ts +485 -0
- package/src/routes/oauth.ts +328 -0
- package/src/routes/shared.ts +330 -0
- package/src/routes/ui.ts +153 -0
- package/src/server.ts +70 -1702
- package/src/skills.ts +6 -5
- package/src/storage/file.ts +3 -1
- package/src/storage/memory.ts +3 -1
- package/src/toolkits.ts +2 -2
- package/src/types.ts +70 -2
- package/src/ui.ts +52 -1489
- package/src/version.ts +1 -1
package/src/registry.ts
CHANGED
|
@@ -18,15 +18,32 @@ import {
|
|
|
18
18
|
type CredentialHealthConfig,
|
|
19
19
|
type CredentialHealthRecord,
|
|
20
20
|
} from "./credential-health.js";
|
|
21
|
+
import {
|
|
22
|
+
ConnectorCallAdmissionController,
|
|
23
|
+
type CallAdmissionPermit,
|
|
24
|
+
type ConnectorCallAdmissionSnapshot,
|
|
25
|
+
} from "./call-admission.js";
|
|
26
|
+
import {
|
|
27
|
+
fingerprintSerializedCatalog,
|
|
28
|
+
snapshotCatalog,
|
|
29
|
+
type CatalogSnapshot,
|
|
30
|
+
} from "./catalog-fingerprint.js";
|
|
31
|
+
import {
|
|
32
|
+
MAX_CATALOG_CHUNK_BYTES,
|
|
33
|
+
MAX_CATALOG_TOOLS,
|
|
34
|
+
MAX_SERIALIZED_CATALOG_BYTES,
|
|
35
|
+
} from "./catalog-limits.js";
|
|
36
|
+
import { mapSettledWithConcurrency } from "./concurrency.js";
|
|
21
37
|
import type { DeferredWork } from "./connector-scope.js";
|
|
22
38
|
import { splitAddress, type Toolkit } from "./toolkits.js";
|
|
23
39
|
|
|
24
40
|
const ID_RE = /^[a-z0-9_-]+$/;
|
|
25
41
|
const DEFAULT_TTL_SECONDS = 300;
|
|
26
42
|
const DEFAULT_STALE_SECONDS = 3600;
|
|
27
|
-
|
|
43
|
+
const CATALOG_CHUNK_TTL_GRACE_SECONDS = 300;
|
|
44
|
+
const DEFAULT_MAX_RESULT_BYTES = 50_000;
|
|
28
45
|
/** Independent final-envelope boundary for `batch_call`. */
|
|
29
|
-
|
|
46
|
+
const DEFAULT_MAX_BATCH_RESULT_BYTES = 100_000;
|
|
30
47
|
|
|
31
48
|
/**
|
|
32
49
|
* Smallest accepted inline-result cap. One byte is pathological but harmless:
|
|
@@ -71,17 +88,41 @@ export function resolveMaxResultBytes(
|
|
|
71
88
|
|
|
72
89
|
interface CacheEntry {
|
|
73
90
|
tools: ToolDef[];
|
|
91
|
+
fingerprint: string;
|
|
74
92
|
exp: number; // epoch ms
|
|
75
93
|
staleUntil: number;
|
|
76
94
|
}
|
|
77
95
|
|
|
96
|
+
interface LegacyPersistedCatalog {
|
|
97
|
+
tools: ToolDef[];
|
|
98
|
+
/** Optional only for catalogs written before fingerprints were introduced. */
|
|
99
|
+
fingerprint?: string;
|
|
100
|
+
fetchedAt: number;
|
|
101
|
+
expiresAt: number;
|
|
102
|
+
staleUntil: number;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
interface PersistedCatalogManifest {
|
|
106
|
+
version: 2;
|
|
107
|
+
revision: string;
|
|
108
|
+
toolCount: number;
|
|
109
|
+
byteCount: number;
|
|
110
|
+
chunkCount: number;
|
|
111
|
+
fetchedAt: number;
|
|
112
|
+
expiresAt: number;
|
|
113
|
+
staleUntil: number;
|
|
114
|
+
}
|
|
115
|
+
|
|
78
116
|
interface PersistedCatalog {
|
|
79
117
|
tools: ToolDef[];
|
|
118
|
+
fingerprint: string;
|
|
80
119
|
fetchedAt: number;
|
|
81
120
|
expiresAt: number;
|
|
82
121
|
staleUntil: number;
|
|
83
122
|
}
|
|
84
123
|
|
|
124
|
+
const CATALOG_CHUNK_IO_CONCURRENCY = 4;
|
|
125
|
+
|
|
85
126
|
export interface HealthObservation {
|
|
86
127
|
lastSuccessAt?: string;
|
|
87
128
|
lastFailureAt?: string;
|
|
@@ -106,13 +147,14 @@ export class HealthLog {
|
|
|
106
147
|
|
|
107
148
|
recordSuccess(id: string, latencyMs: number): void {
|
|
108
149
|
const previous = this.observations.get(id);
|
|
109
|
-
|
|
150
|
+
const observation: HealthObservation = {
|
|
110
151
|
...previous,
|
|
111
152
|
lastSuccessAt: new Date().toISOString(),
|
|
112
153
|
lastLatencyMs: latencyMs,
|
|
113
154
|
consecutiveFailures: 0,
|
|
114
|
-
|
|
115
|
-
|
|
155
|
+
};
|
|
156
|
+
delete observation.lastError;
|
|
157
|
+
this.observations.set(id, observation);
|
|
116
158
|
}
|
|
117
159
|
|
|
118
160
|
recordFailure(id: string, latencyMs: number, error: unknown): void {
|
|
@@ -166,7 +208,35 @@ function msg(err: unknown): string {
|
|
|
166
208
|
return err instanceof Error ? err.message : String(err);
|
|
167
209
|
}
|
|
168
210
|
|
|
169
|
-
|
|
211
|
+
/** WHATWG TextEncoder byte length without allocating another full buffer. */
|
|
212
|
+
function utf8ByteLength(value: string): number {
|
|
213
|
+
let bytes = 0;
|
|
214
|
+
for (let index = 0; index < value.length; index++) {
|
|
215
|
+
const code = value.charCodeAt(index);
|
|
216
|
+
if (code <= 0x7f) {
|
|
217
|
+
bytes++;
|
|
218
|
+
} else if (code <= 0x7ff) {
|
|
219
|
+
bytes += 2;
|
|
220
|
+
} else if (
|
|
221
|
+
code >= 0xd800 &&
|
|
222
|
+
code <= 0xdbff &&
|
|
223
|
+
index + 1 < value.length
|
|
224
|
+
) {
|
|
225
|
+
const next = value.charCodeAt(index + 1);
|
|
226
|
+
if (next >= 0xdc00 && next <= 0xdfff) {
|
|
227
|
+
bytes += 4;
|
|
228
|
+
index++;
|
|
229
|
+
} else {
|
|
230
|
+
bytes += 3;
|
|
231
|
+
}
|
|
232
|
+
} else {
|
|
233
|
+
bytes += 3;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
return bytes;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
export type ConnectorOperationOptions = Pick<
|
|
170
240
|
ConnectorContext,
|
|
171
241
|
"signal" | "timeoutMs"
|
|
172
242
|
>;
|
|
@@ -212,6 +282,14 @@ export interface RegistryView {
|
|
|
212
282
|
requestScope?: object,
|
|
213
283
|
callOptions?: ConnectorOperationOptions,
|
|
214
284
|
): ConnectorContext;
|
|
285
|
+
/**
|
|
286
|
+
* Acquire the connector's shared downstream-call permit. Scoped views
|
|
287
|
+
* delegate to the base registry so every toolkit contends on the same pool.
|
|
288
|
+
*/
|
|
289
|
+
admitCall(
|
|
290
|
+
id: string,
|
|
291
|
+
input: { toolName: string; args: unknown; signal?: AbortSignal },
|
|
292
|
+
): Promise<CallAdmissionPermit>;
|
|
215
293
|
resultsStorage(): KVStorage;
|
|
216
294
|
recordSuccess(id: string, latencyMs: number): void;
|
|
217
295
|
recordFailure(id: string, latencyMs: number, error: unknown): void;
|
|
@@ -242,12 +320,21 @@ export interface RegistryView {
|
|
|
242
320
|
*/
|
|
243
321
|
export class Registry implements RegistryView {
|
|
244
322
|
private readonly connectors = new Map<string, Connector>();
|
|
323
|
+
private readonly callAdmission = new Map<
|
|
324
|
+
string,
|
|
325
|
+
ConnectorCallAdmissionController
|
|
326
|
+
>();
|
|
245
327
|
private readonly cache = new Map<string, CacheEntry>();
|
|
246
328
|
private readonly invalidated = new Set<string>();
|
|
247
329
|
/** Per-connector epoch preventing a pre-invalidation refresh from publishing. */
|
|
248
330
|
private readonly catalogGenerations = new Map<string, number>();
|
|
249
331
|
/** Serialize persisted catalog set/delete operations within this isolate. */
|
|
250
332
|
private readonly catalogMutations = new Map<string, Promise<void>>();
|
|
333
|
+
/** Same-request cold loads share one promise without retaining the request. */
|
|
334
|
+
private readonly requestCatalogLoads = new WeakMap<
|
|
335
|
+
object,
|
|
336
|
+
Map<string, Promise<ToolDef[]>>
|
|
337
|
+
>();
|
|
251
338
|
/** Deployment-wide observations — every call, whatever view made it. */
|
|
252
339
|
private readonly health = new HealthLog();
|
|
253
340
|
private readonly ttlMs: number;
|
|
@@ -287,6 +374,12 @@ export class Registry implements RegistryView {
|
|
|
287
374
|
throw new Error(`Duplicate connector id "${c.id}"`);
|
|
288
375
|
}
|
|
289
376
|
this.connectors.set(c.id, c);
|
|
377
|
+
if (c.callAdmission) {
|
|
378
|
+
this.callAdmission.set(
|
|
379
|
+
c.id,
|
|
380
|
+
new ConnectorCallAdmissionController(c.id, c.callAdmission),
|
|
381
|
+
);
|
|
382
|
+
}
|
|
290
383
|
}
|
|
291
384
|
this.checkConventions(opts.logger);
|
|
292
385
|
this.checkResultCaps(
|
|
@@ -302,7 +395,9 @@ export class Registry implements RegistryView {
|
|
|
302
395
|
this.contextFor(id, baseUrl, requestScope),
|
|
303
396
|
storage: opts.storage,
|
|
304
397
|
logger: opts.logger,
|
|
305
|
-
|
|
398
|
+
...(opts.credentialVault !== undefined
|
|
399
|
+
? { credentialVault: opts.credentialVault }
|
|
400
|
+
: {}),
|
|
306
401
|
},
|
|
307
402
|
opts.credentialHealth,
|
|
308
403
|
);
|
|
@@ -430,6 +525,30 @@ export class Registry implements RegistryView {
|
|
|
430
525
|
};
|
|
431
526
|
}
|
|
432
527
|
|
|
528
|
+
admitCall(
|
|
529
|
+
id: string,
|
|
530
|
+
input: { toolName: string; args: unknown; signal?: AbortSignal },
|
|
531
|
+
): Promise<CallAdmissionPermit> {
|
|
532
|
+
const admission = this.callAdmission.get(id);
|
|
533
|
+
if (admission) return admission.acquire(input);
|
|
534
|
+
return Promise.resolve({ waitMs: 0, release() {} });
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/** Payload-free aggregate state for the open health endpoint. */
|
|
538
|
+
callAdmissionSnapshot(): Record<string, ConnectorCallAdmissionSnapshot> {
|
|
539
|
+
return Object.fromEntries(
|
|
540
|
+
[...this.callAdmission].map(([id, admission]) => [
|
|
541
|
+
id,
|
|
542
|
+
admission.snapshot(),
|
|
543
|
+
]),
|
|
544
|
+
);
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/** Reject queued/future downstream admission; active permits release safely. */
|
|
548
|
+
closeCallAdmission(): void {
|
|
549
|
+
for (const admission of this.callAdmission.values()) admission.close();
|
|
550
|
+
}
|
|
551
|
+
|
|
433
552
|
/**
|
|
434
553
|
* Storage namespaced to the meta-tool result store (`results:` prefix), kept
|
|
435
554
|
* separate from any connector's `conn:<id>:` namespace. Backs get_result.
|
|
@@ -453,47 +572,304 @@ export class Registry implements RegistryView {
|
|
|
453
572
|
return `catalog:${id}`;
|
|
454
573
|
}
|
|
455
574
|
|
|
456
|
-
private
|
|
575
|
+
private catalogChunkKey(id: string, revision: string, index: number): string {
|
|
576
|
+
return `${this.catalogKey(id)}:chunk:${revision}:${index}`;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
private validLegacyCatalog(value: unknown): LegacyPersistedCatalog | null {
|
|
580
|
+
if (!value || typeof value !== "object") return null;
|
|
581
|
+
const catalog = value as Partial<LegacyPersistedCatalog>;
|
|
582
|
+
if (
|
|
583
|
+
!Array.isArray(catalog.tools) ||
|
|
584
|
+
typeof catalog.fetchedAt !== "number" ||
|
|
585
|
+
typeof catalog.expiresAt !== "number" ||
|
|
586
|
+
typeof catalog.staleUntil !== "number" ||
|
|
587
|
+
(catalog.fingerprint !== undefined &&
|
|
588
|
+
typeof catalog.fingerprint !== "string") ||
|
|
589
|
+
!this.validCatalogTools(catalog.tools)
|
|
590
|
+
) {
|
|
591
|
+
return null;
|
|
592
|
+
}
|
|
593
|
+
return catalog as LegacyPersistedCatalog;
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
private validCatalogTools(value: unknown[]): value is ToolDef[] {
|
|
597
|
+
return value.every(
|
|
598
|
+
(tool) =>
|
|
599
|
+
tool !== null &&
|
|
600
|
+
typeof tool === "object" &&
|
|
601
|
+
typeof (tool as ToolDef).name === "string",
|
|
602
|
+
);
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
private validCatalogManifest(
|
|
606
|
+
value: unknown,
|
|
607
|
+
): PersistedCatalogManifest | null {
|
|
608
|
+
if (!value || typeof value !== "object") return null;
|
|
609
|
+
const manifest = value as Partial<PersistedCatalogManifest>;
|
|
610
|
+
const maxChunks =
|
|
611
|
+
Math.ceil(MAX_SERIALIZED_CATALOG_BYTES / MAX_CATALOG_CHUNK_BYTES) + 1;
|
|
612
|
+
if (
|
|
613
|
+
manifest.version !== 2 ||
|
|
614
|
+
typeof manifest.revision !== "string" ||
|
|
615
|
+
!/^sha256:[0-9]{1,8}:[0-9a-f]{64}$/.test(manifest.revision) ||
|
|
616
|
+
!Number.isInteger(manifest.toolCount) ||
|
|
617
|
+
manifest.toolCount! < 0 ||
|
|
618
|
+
manifest.toolCount! > MAX_CATALOG_TOOLS ||
|
|
619
|
+
!Number.isInteger(manifest.byteCount) ||
|
|
620
|
+
manifest.byteCount! < 2 ||
|
|
621
|
+
manifest.byteCount! > MAX_SERIALIZED_CATALOG_BYTES ||
|
|
622
|
+
!manifest.revision.startsWith(`sha256:${manifest.byteCount}:`) ||
|
|
623
|
+
!Number.isInteger(manifest.chunkCount) ||
|
|
624
|
+
manifest.chunkCount! < 1 ||
|
|
625
|
+
manifest.chunkCount! > maxChunks ||
|
|
626
|
+
typeof manifest.fetchedAt !== "number" ||
|
|
627
|
+
typeof manifest.expiresAt !== "number" ||
|
|
628
|
+
typeof manifest.staleUntil !== "number"
|
|
629
|
+
) {
|
|
630
|
+
return null;
|
|
631
|
+
}
|
|
632
|
+
return manifest as PersistedCatalogManifest;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
private parseCatalogManifest(
|
|
636
|
+
raw: string | null,
|
|
637
|
+
): PersistedCatalogManifest | null {
|
|
638
|
+
if (!raw) return null;
|
|
639
|
+
try {
|
|
640
|
+
return this.validCatalogManifest(JSON.parse(raw));
|
|
641
|
+
} catch {
|
|
642
|
+
return null;
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
private splitCatalogChunks(snapshot: CatalogSnapshot): string[] {
|
|
647
|
+
const chunks: string[] = [];
|
|
648
|
+
const decoder = new TextDecoder("utf-8", {
|
|
649
|
+
fatal: true,
|
|
650
|
+
ignoreBOM: true,
|
|
651
|
+
});
|
|
652
|
+
let offset = 0;
|
|
653
|
+
while (offset < snapshot.serializedBytes.byteLength) {
|
|
654
|
+
let end = Math.min(
|
|
655
|
+
offset + MAX_CATALOG_CHUNK_BYTES,
|
|
656
|
+
snapshot.serializedBytes.byteLength,
|
|
657
|
+
);
|
|
658
|
+
// Move a boundary that landed inside a multibyte UTF-8 sequence back to
|
|
659
|
+
// the next character start. Every stored string then remains valid UTF-8.
|
|
660
|
+
while (
|
|
661
|
+
end < snapshot.serializedBytes.byteLength &&
|
|
662
|
+
((snapshot.serializedBytes[end] ?? 0) & 0xc0) === 0x80
|
|
663
|
+
) {
|
|
664
|
+
end--;
|
|
665
|
+
}
|
|
666
|
+
chunks.push(
|
|
667
|
+
decoder.decode(snapshot.serializedBytes.subarray(offset, end)),
|
|
668
|
+
);
|
|
669
|
+
offset = end;
|
|
670
|
+
}
|
|
671
|
+
return chunks;
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
private async readCatalog(
|
|
675
|
+
id: string,
|
|
676
|
+
raw: string | null,
|
|
677
|
+
now: number,
|
|
678
|
+
): Promise<PersistedCatalog | null> {
|
|
457
679
|
if (!raw) return null;
|
|
680
|
+
let parsed: unknown;
|
|
458
681
|
try {
|
|
459
|
-
|
|
682
|
+
parsed = JSON.parse(raw);
|
|
683
|
+
} catch {
|
|
684
|
+
return null;
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
if (
|
|
688
|
+
!parsed ||
|
|
689
|
+
typeof parsed !== "object" ||
|
|
690
|
+
(parsed as { version?: unknown }).version !== 2
|
|
691
|
+
) {
|
|
692
|
+
const legacy = this.validLegacyCatalog(parsed);
|
|
693
|
+
if (!legacy || legacy.staleUntil <= now) return null;
|
|
694
|
+
const snapshot = await snapshotCatalog(legacy.tools);
|
|
460
695
|
if (
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
!value.tools.every(
|
|
466
|
-
(tool) =>
|
|
467
|
-
tool !== null &&
|
|
468
|
-
typeof tool === "object" &&
|
|
469
|
-
typeof (tool as ToolDef).name === "string",
|
|
470
|
-
)
|
|
696
|
+
legacy.tools.length > MAX_CATALOG_TOOLS ||
|
|
697
|
+
snapshot.serializedBytes.byteLength > MAX_SERIALIZED_CATALOG_BYTES ||
|
|
698
|
+
(legacy.fingerprint !== undefined &&
|
|
699
|
+
legacy.fingerprint !== snapshot.fingerprint)
|
|
471
700
|
) {
|
|
701
|
+
this.opts.logger.warn(
|
|
702
|
+
`[connecta] connector "${id}" legacy catalog is oversized or has a fingerprint mismatch; ignoring persisted catalog.`,
|
|
703
|
+
);
|
|
472
704
|
return null;
|
|
473
705
|
}
|
|
474
|
-
return
|
|
706
|
+
return {
|
|
707
|
+
tools: legacy.tools,
|
|
708
|
+
fingerprint: snapshot.fingerprint,
|
|
709
|
+
fetchedAt: legacy.fetchedAt,
|
|
710
|
+
expiresAt: legacy.expiresAt,
|
|
711
|
+
staleUntil: legacy.staleUntil,
|
|
712
|
+
};
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
const manifest = this.validCatalogManifest(parsed);
|
|
716
|
+
if (!manifest) {
|
|
717
|
+
this.opts.logger.warn(
|
|
718
|
+
`[connecta] connector "${id}" catalog manifest is invalid; ignoring persisted catalog.`,
|
|
719
|
+
);
|
|
720
|
+
return null;
|
|
721
|
+
}
|
|
722
|
+
if (manifest.staleUntil <= now) return null;
|
|
723
|
+
|
|
724
|
+
const chunkReads = await mapSettledWithConcurrency(
|
|
725
|
+
Array.from({ length: manifest.chunkCount }, (_, index) => index),
|
|
726
|
+
CATALOG_CHUNK_IO_CONCURRENCY,
|
|
727
|
+
(index) =>
|
|
728
|
+
this.opts.storage.get(
|
|
729
|
+
this.catalogChunkKey(id, manifest.revision, index),
|
|
730
|
+
),
|
|
731
|
+
);
|
|
732
|
+
const chunks: string[] = [];
|
|
733
|
+
let chunkBytes = 0;
|
|
734
|
+
for (const [index, result] of chunkReads.entries()) {
|
|
735
|
+
if (result.status === "rejected") throw result.reason;
|
|
736
|
+
const chunk = result.value;
|
|
737
|
+
if (chunk === null) {
|
|
738
|
+
this.opts.logger.warn(
|
|
739
|
+
`[connecta] connector "${id}" catalog chunk ${index + 1}/${manifest.chunkCount} is missing; ignoring persisted catalog.`,
|
|
740
|
+
);
|
|
741
|
+
return null;
|
|
742
|
+
}
|
|
743
|
+
const byteLength = utf8ByteLength(chunk);
|
|
744
|
+
chunkBytes += byteLength;
|
|
745
|
+
if (
|
|
746
|
+
byteLength > MAX_CATALOG_CHUNK_BYTES ||
|
|
747
|
+
chunkBytes > manifest.byteCount
|
|
748
|
+
) {
|
|
749
|
+
this.opts.logger.warn(
|
|
750
|
+
`[connecta] connector "${id}" catalog chunk bounds do not match its manifest; ignoring persisted catalog.`,
|
|
751
|
+
);
|
|
752
|
+
return null;
|
|
753
|
+
}
|
|
754
|
+
chunks.push(chunk);
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
const serializedTools = chunks.join("");
|
|
758
|
+
const stored = await fingerprintSerializedCatalog(serializedTools);
|
|
759
|
+
if (
|
|
760
|
+
stored.byteLength !== manifest.byteCount ||
|
|
761
|
+
stored.fingerprint !== manifest.revision
|
|
762
|
+
) {
|
|
763
|
+
this.opts.logger.warn(
|
|
764
|
+
`[connecta] connector "${id}" catalog fingerprint mismatch; ignoring persisted catalog.`,
|
|
765
|
+
);
|
|
766
|
+
return null;
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
let tools: unknown;
|
|
770
|
+
try {
|
|
771
|
+
tools = JSON.parse(serializedTools);
|
|
475
772
|
} catch {
|
|
773
|
+
this.opts.logger.warn(
|
|
774
|
+
`[connecta] connector "${id}" catalog chunks are torn; ignoring persisted catalog.`,
|
|
775
|
+
);
|
|
776
|
+
return null;
|
|
777
|
+
}
|
|
778
|
+
if (!Array.isArray(tools) || !this.validCatalogTools(tools)) {
|
|
779
|
+
this.opts.logger.warn(
|
|
780
|
+
`[connecta] connector "${id}" catalog chunks contain invalid tools; ignoring persisted catalog.`,
|
|
781
|
+
);
|
|
782
|
+
return null;
|
|
783
|
+
}
|
|
784
|
+
if (tools.length !== manifest.toolCount) {
|
|
785
|
+
this.opts.logger.warn(
|
|
786
|
+
`[connecta] connector "${id}" catalog tool count does not match its manifest; ignoring persisted catalog.`,
|
|
787
|
+
);
|
|
476
788
|
return null;
|
|
477
789
|
}
|
|
790
|
+
return {
|
|
791
|
+
tools,
|
|
792
|
+
fingerprint: stored.fingerprint,
|
|
793
|
+
fetchedAt: manifest.fetchedAt,
|
|
794
|
+
expiresAt: manifest.expiresAt,
|
|
795
|
+
staleUntil: manifest.staleUntil,
|
|
796
|
+
};
|
|
478
797
|
}
|
|
479
798
|
|
|
480
|
-
private async storeCatalog(
|
|
799
|
+
private async storeCatalog(
|
|
800
|
+
id: string,
|
|
801
|
+
snapshot: CatalogSnapshot,
|
|
802
|
+
): Promise<void> {
|
|
481
803
|
if (!this.persistToolCatalog) return;
|
|
482
804
|
const fetchedAt = Date.now();
|
|
483
|
-
const
|
|
484
|
-
|
|
805
|
+
const expiresAt = fetchedAt + this.ttlMs;
|
|
806
|
+
const staleUntil = expiresAt + this.staleMs;
|
|
807
|
+
const ttlSeconds = Math.max(
|
|
808
|
+
60,
|
|
809
|
+
Math.ceil((this.ttlMs + this.staleMs) / 1000),
|
|
810
|
+
);
|
|
811
|
+
const chunks = this.splitCatalogChunks(snapshot);
|
|
812
|
+
const chunkWrites = await mapSettledWithConcurrency(
|
|
813
|
+
chunks,
|
|
814
|
+
CATALOG_CHUNK_IO_CONCURRENCY,
|
|
815
|
+
(chunk, index) =>
|
|
816
|
+
this.opts.storage.set(
|
|
817
|
+
this.catalogChunkKey(id, snapshot.fingerprint, index),
|
|
818
|
+
chunk,
|
|
819
|
+
{ ttlSeconds: ttlSeconds + CATALOG_CHUNK_TTL_GRACE_SECONDS },
|
|
820
|
+
),
|
|
821
|
+
);
|
|
822
|
+
for (const result of chunkWrites) {
|
|
823
|
+
if (result.status === "rejected") throw result.reason;
|
|
824
|
+
}
|
|
825
|
+
// The manifest is the only publication point. A failed/partial chunk write
|
|
826
|
+
// therefore leaves the previous manifest authoritative (or no catalog);
|
|
827
|
+
// unreachable chunks carry a bounded TTL and require no prefix scan.
|
|
828
|
+
const manifest: PersistedCatalogManifest = {
|
|
829
|
+
version: 2,
|
|
830
|
+
revision: snapshot.fingerprint,
|
|
831
|
+
toolCount: snapshot.tools.length,
|
|
832
|
+
byteCount: snapshot.serializedBytes.byteLength,
|
|
833
|
+
chunkCount: chunks.length,
|
|
485
834
|
fetchedAt,
|
|
486
|
-
expiresAt
|
|
487
|
-
staleUntil
|
|
835
|
+
expiresAt,
|
|
836
|
+
staleUntil,
|
|
488
837
|
};
|
|
489
|
-
await this.opts.storage.set(this.catalogKey(id), JSON.stringify(
|
|
490
|
-
ttlSeconds
|
|
491
|
-
60,
|
|
492
|
-
Math.ceil((this.ttlMs + this.staleMs) / 1000),
|
|
493
|
-
),
|
|
838
|
+
await this.opts.storage.set(this.catalogKey(id), JSON.stringify(manifest), {
|
|
839
|
+
ttlSeconds,
|
|
494
840
|
});
|
|
495
841
|
}
|
|
496
842
|
|
|
843
|
+
private async deleteCatalog(id: string): Promise<void> {
|
|
844
|
+
let raw: string | null = null;
|
|
845
|
+
let readError: unknown;
|
|
846
|
+
try {
|
|
847
|
+
raw = await this.opts.storage.get(this.catalogKey(id));
|
|
848
|
+
} catch (err) {
|
|
849
|
+
readError = err;
|
|
850
|
+
}
|
|
851
|
+
const manifest = this.parseCatalogManifest(raw);
|
|
852
|
+
// The root is authoritative, so attempt its deletion even when the
|
|
853
|
+
// best-effort read needed for physical chunk cleanup failed.
|
|
854
|
+
await this.opts.storage.delete(this.catalogKey(id));
|
|
855
|
+
if (!manifest) {
|
|
856
|
+
if (readError) throw readError;
|
|
857
|
+
return;
|
|
858
|
+
}
|
|
859
|
+
let firstError: unknown;
|
|
860
|
+
for (let index = 0; index < manifest.chunkCount; index++) {
|
|
861
|
+
try {
|
|
862
|
+
await this.opts.storage.delete(
|
|
863
|
+
this.catalogChunkKey(id, manifest.revision, index),
|
|
864
|
+
);
|
|
865
|
+
} catch (err) {
|
|
866
|
+
firstError ??= err;
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
if (readError) throw readError;
|
|
870
|
+
if (firstError) throw firstError;
|
|
871
|
+
}
|
|
872
|
+
|
|
497
873
|
private catalogGeneration(id: string): number {
|
|
498
874
|
return this.catalogGenerations.get(id) ?? 0;
|
|
499
875
|
}
|
|
@@ -533,25 +909,43 @@ export class Registry implements RegistryView {
|
|
|
533
909
|
): Promise<ToolDef[]> {
|
|
534
910
|
const connector = this.connectors.get(id);
|
|
535
911
|
if (!connector) throw new Error(`Unknown connector "${id}"`);
|
|
912
|
+
if (connector.staticTools) return connector.staticTools;
|
|
536
913
|
const generation = this.catalogGeneration(id);
|
|
537
|
-
const tools = connector.
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
this.contextFor(id, baseUrl, requestScope, callOptions),
|
|
541
|
-
);
|
|
914
|
+
const tools = await connector.listTools(
|
|
915
|
+
this.contextFor(id, baseUrl, requestScope, callOptions),
|
|
916
|
+
);
|
|
542
917
|
// The caller that began this refresh may still use its result, but a
|
|
543
918
|
// credential/OAuth change that landed while listTools was in flight means
|
|
544
919
|
// the listing must not enter either shared cache layer.
|
|
545
920
|
if (generation !== this.catalogGeneration(id)) return tools;
|
|
546
|
-
|
|
921
|
+
if (tools.length > MAX_CATALOG_TOOLS) {
|
|
922
|
+
const message =
|
|
923
|
+
`Connector "${id}" returned ${tools.length} tools, over the ` +
|
|
924
|
+
`${MAX_CATALOG_TOOLS}-tool catalog ceiling; refusing the complete catalog.`;
|
|
925
|
+
this.opts.logger.warn(`[connecta] ${message}`);
|
|
926
|
+
throw new Error(message);
|
|
927
|
+
}
|
|
547
928
|
const previous = this.cache.get(id);
|
|
929
|
+
const snapshot = await snapshotCatalog(tools);
|
|
930
|
+
if (generation !== this.catalogGeneration(id)) return tools;
|
|
931
|
+
if (snapshot.serializedBytes.byteLength > MAX_SERIALIZED_CATALOG_BYTES) {
|
|
932
|
+
const message =
|
|
933
|
+
`Connector "${id}" returned a ${snapshot.serializedBytes.byteLength}-byte ` +
|
|
934
|
+
`serialized catalog, over the ${MAX_SERIALIZED_CATALOG_BYTES}-byte ceiling; ` +
|
|
935
|
+
"refusing the complete catalog.";
|
|
936
|
+
this.opts.logger.warn(`[connecta] ${message}`);
|
|
937
|
+
throw new Error(message);
|
|
938
|
+
}
|
|
939
|
+
const now = Date.now();
|
|
548
940
|
const catalogChanged =
|
|
549
|
-
!previous ||
|
|
941
|
+
!previous || previous.fingerprint !== snapshot.fingerprint;
|
|
550
942
|
const shouldPersist =
|
|
551
|
-
|
|
552
|
-
(
|
|
943
|
+
catalogChanged ||
|
|
944
|
+
(previous !== undefined && previous.exp <= now) ||
|
|
945
|
+
this.invalidated.has(id);
|
|
553
946
|
this.cache.set(id, {
|
|
554
947
|
tools,
|
|
948
|
+
fingerprint: snapshot.fingerprint,
|
|
555
949
|
exp: now + this.ttlMs,
|
|
556
950
|
staleUntil: now + this.ttlMs + this.staleMs,
|
|
557
951
|
});
|
|
@@ -560,7 +954,7 @@ export class Registry implements RegistryView {
|
|
|
560
954
|
await this.enqueueCatalogMutation(id, async () => {
|
|
561
955
|
if (generation !== this.catalogGeneration(id)) return;
|
|
562
956
|
try {
|
|
563
|
-
await this.storeCatalog(id,
|
|
957
|
+
await this.storeCatalog(id, snapshot);
|
|
564
958
|
} catch (err) {
|
|
565
959
|
this.opts.logger.warn(
|
|
566
960
|
`[connecta] connector "${id}" catalog persistence failed: ${msg(err)}`,
|
|
@@ -572,7 +966,7 @@ export class Registry implements RegistryView {
|
|
|
572
966
|
}
|
|
573
967
|
|
|
574
968
|
/** Cached listTools with in-memory + persisted serializable catalog layers. */
|
|
575
|
-
async
|
|
969
|
+
private async loadTools(
|
|
576
970
|
id: string,
|
|
577
971
|
baseUrl: string,
|
|
578
972
|
requestScope?: object,
|
|
@@ -592,8 +986,10 @@ export class Registry implements RegistryView {
|
|
|
592
986
|
const generation = this.catalogGeneration(id);
|
|
593
987
|
let persisted: PersistedCatalog | null = null;
|
|
594
988
|
try {
|
|
595
|
-
persisted = this.
|
|
989
|
+
persisted = await this.readCatalog(
|
|
990
|
+
id,
|
|
596
991
|
await this.opts.storage.get(this.catalogKey(id)),
|
|
992
|
+
now,
|
|
597
993
|
);
|
|
598
994
|
} catch (err) {
|
|
599
995
|
this.opts.logger.warn(
|
|
@@ -607,6 +1003,7 @@ export class Registry implements RegistryView {
|
|
|
607
1003
|
if (persisted && persisted.staleUntil > now) {
|
|
608
1004
|
this.cache.set(id, {
|
|
609
1005
|
tools: persisted.tools,
|
|
1006
|
+
fingerprint: persisted.fingerprint,
|
|
610
1007
|
exp: persisted.expiresAt,
|
|
611
1008
|
staleUntil: persisted.staleUntil,
|
|
612
1009
|
});
|
|
@@ -632,6 +1029,46 @@ export class Registry implements RegistryView {
|
|
|
632
1029
|
}
|
|
633
1030
|
}
|
|
634
1031
|
|
|
1032
|
+
/**
|
|
1033
|
+
* Coalesce one connector's cold load inside one inbound request. The WeakMap
|
|
1034
|
+
* neither roots the request scope nor lets its connector context escape into
|
|
1035
|
+
* another request; settled entries are also removed eagerly.
|
|
1036
|
+
*
|
|
1037
|
+
* The first caller's `callOptions` govern the shared load: a later caller's
|
|
1038
|
+
* signal or timeout neither cancels nor extends it, and an abort by the
|
|
1039
|
+
* first caller rejects every coalesced caller. Within one request that is
|
|
1040
|
+
* the deal being made — one fetch, one deadline.
|
|
1041
|
+
*/
|
|
1042
|
+
async getTools(
|
|
1043
|
+
id: string,
|
|
1044
|
+
baseUrl: string,
|
|
1045
|
+
requestScope?: object,
|
|
1046
|
+
callOptions: ConnectorOperationOptions = {},
|
|
1047
|
+
): Promise<ToolDef[]> {
|
|
1048
|
+
const connector = this.connectors.get(id);
|
|
1049
|
+
if (!connector) throw new Error(`Unknown connector "${id}"`);
|
|
1050
|
+
if (connector.staticTools) return connector.staticTools;
|
|
1051
|
+
if (!requestScope) {
|
|
1052
|
+
return this.loadTools(id, baseUrl, requestScope, callOptions);
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
let loads = this.requestCatalogLoads.get(requestScope);
|
|
1056
|
+
if (!loads) {
|
|
1057
|
+
loads = new Map();
|
|
1058
|
+
this.requestCatalogLoads.set(requestScope, loads);
|
|
1059
|
+
}
|
|
1060
|
+
const existing = loads.get(id);
|
|
1061
|
+
if (existing) return existing;
|
|
1062
|
+
const loading = this.loadTools(id, baseUrl, requestScope, callOptions);
|
|
1063
|
+
loads.set(id, loading);
|
|
1064
|
+
try {
|
|
1065
|
+
return await loading;
|
|
1066
|
+
} finally {
|
|
1067
|
+
if (loads.get(id) === loading) loads.delete(id);
|
|
1068
|
+
if (loads.size === 0) this.requestCatalogLoads.delete(requestScope);
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
1071
|
+
|
|
635
1072
|
/** Return a cached catalog without performing storage or network I/O. */
|
|
636
1073
|
peekTools(id: string): ToolDef[] | undefined {
|
|
637
1074
|
const connector = this.connectors.get(id);
|
|
@@ -764,7 +1201,7 @@ export class Registry implements RegistryView {
|
|
|
764
1201
|
if (this.persistToolCatalog) {
|
|
765
1202
|
void this.enqueueCatalogMutation(id, async () => {
|
|
766
1203
|
try {
|
|
767
|
-
await this.
|
|
1204
|
+
await this.deleteCatalog(id);
|
|
768
1205
|
} catch (err) {
|
|
769
1206
|
this.opts.logger.warn(
|
|
770
1207
|
`[connecta] connector "${id}" catalog invalidation failed: ${msg(err)}`,
|
|
@@ -782,7 +1219,7 @@ export class Registry implements RegistryView {
|
|
|
782
1219
|
if (this.persistToolCatalog) {
|
|
783
1220
|
await this.enqueueCatalogMutation(id, async () => {
|
|
784
1221
|
try {
|
|
785
|
-
await this.
|
|
1222
|
+
await this.deleteCatalog(id);
|
|
786
1223
|
} catch (err) {
|
|
787
1224
|
this.opts.logger.warn(
|
|
788
1225
|
`[connecta] connector "${id}" catalog invalidation failed: ${msg(err)}`,
|
|
@@ -946,6 +1383,14 @@ export class ScopedRegistry implements RegistryView {
|
|
|
946
1383
|
return this.base.contextFor(id, baseUrl, requestScope, callOptions);
|
|
947
1384
|
}
|
|
948
1385
|
|
|
1386
|
+
admitCall(
|
|
1387
|
+
id: string,
|
|
1388
|
+
input: { toolName: string; args: unknown; signal?: AbortSignal },
|
|
1389
|
+
): Promise<CallAdmissionPermit> {
|
|
1390
|
+
if (!this.visible(id)) return Promise.reject(this.unknownConnector(id));
|
|
1391
|
+
return this.base.admitCall(id, input);
|
|
1392
|
+
}
|
|
1393
|
+
|
|
949
1394
|
/**
|
|
950
1395
|
* Stashed oversized results are bound to the scope that produced them: a
|
|
951
1396
|
* scoped session cannot page a result it could not have produced, and an id
|