@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/dist/registry.js
CHANGED
|
@@ -1,13 +1,18 @@
|
|
|
1
1
|
import { storedCredentialShape, } from "./credentials.js";
|
|
2
2
|
import { ConnectorCallError } from "./errors.js";
|
|
3
3
|
import { CredentialHealthChecker, } from "./credential-health.js";
|
|
4
|
+
import { ConnectorCallAdmissionController, } from "./call-admission.js";
|
|
5
|
+
import { fingerprintSerializedCatalog, snapshotCatalog, } from "./catalog-fingerprint.js";
|
|
6
|
+
import { MAX_CATALOG_CHUNK_BYTES, MAX_CATALOG_TOOLS, MAX_SERIALIZED_CATALOG_BYTES, } from "./catalog-limits.js";
|
|
7
|
+
import { mapSettledWithConcurrency } from "./concurrency.js";
|
|
4
8
|
import { splitAddress } from "./toolkits.js";
|
|
5
9
|
const ID_RE = /^[a-z0-9_-]+$/;
|
|
6
10
|
const DEFAULT_TTL_SECONDS = 300;
|
|
7
11
|
const DEFAULT_STALE_SECONDS = 3600;
|
|
8
|
-
|
|
12
|
+
const CATALOG_CHUNK_TTL_GRACE_SECONDS = 300;
|
|
13
|
+
const DEFAULT_MAX_RESULT_BYTES = 50_000;
|
|
9
14
|
/** Independent final-envelope boundary for `batch_call`. */
|
|
10
|
-
|
|
15
|
+
const DEFAULT_MAX_BATCH_RESULT_BYTES = 100_000;
|
|
11
16
|
/**
|
|
12
17
|
* Smallest accepted inline-result cap. One byte is pathological but harmless:
|
|
13
18
|
* `alignEndToCharBoundary` widens a window narrower than the codepoint at the
|
|
@@ -43,6 +48,7 @@ export function resolveMaxResultBytes(value, inherited) {
|
|
|
43
48
|
? value
|
|
44
49
|
: inherited;
|
|
45
50
|
}
|
|
51
|
+
const CATALOG_CHUNK_IO_CONCURRENCY = 4;
|
|
46
52
|
/**
|
|
47
53
|
* Recent real-call outcomes per connector, as observed by ONE view.
|
|
48
54
|
*
|
|
@@ -58,13 +64,14 @@ export class HealthLog {
|
|
|
58
64
|
observations = new Map();
|
|
59
65
|
recordSuccess(id, latencyMs) {
|
|
60
66
|
const previous = this.observations.get(id);
|
|
61
|
-
|
|
67
|
+
const observation = {
|
|
62
68
|
...previous,
|
|
63
69
|
lastSuccessAt: new Date().toISOString(),
|
|
64
70
|
lastLatencyMs: latencyMs,
|
|
65
71
|
consecutiveFailures: 0,
|
|
66
|
-
|
|
67
|
-
|
|
72
|
+
};
|
|
73
|
+
delete observation.lastError;
|
|
74
|
+
this.observations.set(id, observation);
|
|
68
75
|
}
|
|
69
76
|
recordFailure(id, latencyMs, error) {
|
|
70
77
|
const previous = this.observations.get(id);
|
|
@@ -91,6 +98,35 @@ function namespaced(storage, prefix) {
|
|
|
91
98
|
function msg(err) {
|
|
92
99
|
return err instanceof Error ? err.message : String(err);
|
|
93
100
|
}
|
|
101
|
+
/** WHATWG TextEncoder byte length without allocating another full buffer. */
|
|
102
|
+
function utf8ByteLength(value) {
|
|
103
|
+
let bytes = 0;
|
|
104
|
+
for (let index = 0; index < value.length; index++) {
|
|
105
|
+
const code = value.charCodeAt(index);
|
|
106
|
+
if (code <= 0x7f) {
|
|
107
|
+
bytes++;
|
|
108
|
+
}
|
|
109
|
+
else if (code <= 0x7ff) {
|
|
110
|
+
bytes += 2;
|
|
111
|
+
}
|
|
112
|
+
else if (code >= 0xd800 &&
|
|
113
|
+
code <= 0xdbff &&
|
|
114
|
+
index + 1 < value.length) {
|
|
115
|
+
const next = value.charCodeAt(index + 1);
|
|
116
|
+
if (next >= 0xdc00 && next <= 0xdfff) {
|
|
117
|
+
bytes += 4;
|
|
118
|
+
index++;
|
|
119
|
+
}
|
|
120
|
+
else {
|
|
121
|
+
bytes += 3;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
else {
|
|
125
|
+
bytes += 3;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return bytes;
|
|
129
|
+
}
|
|
94
130
|
/**
|
|
95
131
|
* Holds the connector set, resolves addresses, and caches per-connector tool
|
|
96
132
|
* lists in memory with a TTL. Connector failures are isolated: a broken
|
|
@@ -99,12 +135,15 @@ function msg(err) {
|
|
|
99
135
|
export class Registry {
|
|
100
136
|
opts;
|
|
101
137
|
connectors = new Map();
|
|
138
|
+
callAdmission = new Map();
|
|
102
139
|
cache = new Map();
|
|
103
140
|
invalidated = new Set();
|
|
104
141
|
/** Per-connector epoch preventing a pre-invalidation refresh from publishing. */
|
|
105
142
|
catalogGenerations = new Map();
|
|
106
143
|
/** Serialize persisted catalog set/delete operations within this isolate. */
|
|
107
144
|
catalogMutations = new Map();
|
|
145
|
+
/** Same-request cold loads share one promise without retaining the request. */
|
|
146
|
+
requestCatalogLoads = new WeakMap();
|
|
108
147
|
/** Deployment-wide observations — every call, whatever view made it. */
|
|
109
148
|
health = new HealthLog();
|
|
110
149
|
ttlMs;
|
|
@@ -133,6 +172,9 @@ export class Registry {
|
|
|
133
172
|
throw new Error(`Duplicate connector id "${c.id}"`);
|
|
134
173
|
}
|
|
135
174
|
this.connectors.set(c.id, c);
|
|
175
|
+
if (c.callAdmission) {
|
|
176
|
+
this.callAdmission.set(c.id, new ConnectorCallAdmissionController(c.id, c.callAdmission));
|
|
177
|
+
}
|
|
136
178
|
}
|
|
137
179
|
this.checkConventions(opts.logger);
|
|
138
180
|
this.checkResultCaps(opts.logger, opts.maxResultBytes, opts.maxBatchResultBytes);
|
|
@@ -142,7 +184,9 @@ export class Registry {
|
|
|
142
184
|
contextFor: (id, baseUrl, requestScope) => this.contextFor(id, baseUrl, requestScope),
|
|
143
185
|
storage: opts.storage,
|
|
144
186
|
logger: opts.logger,
|
|
145
|
-
|
|
187
|
+
...(opts.credentialVault !== undefined
|
|
188
|
+
? { credentialVault: opts.credentialVault }
|
|
189
|
+
: {}),
|
|
146
190
|
}, opts.credentialHealth);
|
|
147
191
|
}
|
|
148
192
|
/**
|
|
@@ -236,6 +280,24 @@ export class Registry {
|
|
|
236
280
|
...callOptions,
|
|
237
281
|
};
|
|
238
282
|
}
|
|
283
|
+
admitCall(id, input) {
|
|
284
|
+
const admission = this.callAdmission.get(id);
|
|
285
|
+
if (admission)
|
|
286
|
+
return admission.acquire(input);
|
|
287
|
+
return Promise.resolve({ waitMs: 0, release() { } });
|
|
288
|
+
}
|
|
289
|
+
/** Payload-free aggregate state for the open health endpoint. */
|
|
290
|
+
callAdmissionSnapshot() {
|
|
291
|
+
return Object.fromEntries([...this.callAdmission].map(([id, admission]) => [
|
|
292
|
+
id,
|
|
293
|
+
admission.snapshot(),
|
|
294
|
+
]));
|
|
295
|
+
}
|
|
296
|
+
/** Reject queued/future downstream admission; active permits release safely. */
|
|
297
|
+
closeCallAdmission() {
|
|
298
|
+
for (const admission of this.callAdmission.values())
|
|
299
|
+
admission.close();
|
|
300
|
+
}
|
|
239
301
|
/**
|
|
240
302
|
* Storage namespaced to the meta-tool result store (`results:` prefix), kept
|
|
241
303
|
* separate from any connector's `conn:<id>:` namespace. Backs get_result.
|
|
@@ -256,40 +318,236 @@ export class Registry {
|
|
|
256
318
|
catalogKey(id) {
|
|
257
319
|
return `catalog:${id}`;
|
|
258
320
|
}
|
|
259
|
-
|
|
321
|
+
catalogChunkKey(id, revision, index) {
|
|
322
|
+
return `${this.catalogKey(id)}:chunk:${revision}:${index}`;
|
|
323
|
+
}
|
|
324
|
+
validLegacyCatalog(value) {
|
|
325
|
+
if (!value || typeof value !== "object")
|
|
326
|
+
return null;
|
|
327
|
+
const catalog = value;
|
|
328
|
+
if (!Array.isArray(catalog.tools) ||
|
|
329
|
+
typeof catalog.fetchedAt !== "number" ||
|
|
330
|
+
typeof catalog.expiresAt !== "number" ||
|
|
331
|
+
typeof catalog.staleUntil !== "number" ||
|
|
332
|
+
(catalog.fingerprint !== undefined &&
|
|
333
|
+
typeof catalog.fingerprint !== "string") ||
|
|
334
|
+
!this.validCatalogTools(catalog.tools)) {
|
|
335
|
+
return null;
|
|
336
|
+
}
|
|
337
|
+
return catalog;
|
|
338
|
+
}
|
|
339
|
+
validCatalogTools(value) {
|
|
340
|
+
return value.every((tool) => tool !== null &&
|
|
341
|
+
typeof tool === "object" &&
|
|
342
|
+
typeof tool.name === "string");
|
|
343
|
+
}
|
|
344
|
+
validCatalogManifest(value) {
|
|
345
|
+
if (!value || typeof value !== "object")
|
|
346
|
+
return null;
|
|
347
|
+
const manifest = value;
|
|
348
|
+
const maxChunks = Math.ceil(MAX_SERIALIZED_CATALOG_BYTES / MAX_CATALOG_CHUNK_BYTES) + 1;
|
|
349
|
+
if (manifest.version !== 2 ||
|
|
350
|
+
typeof manifest.revision !== "string" ||
|
|
351
|
+
!/^sha256:[0-9]{1,8}:[0-9a-f]{64}$/.test(manifest.revision) ||
|
|
352
|
+
!Number.isInteger(manifest.toolCount) ||
|
|
353
|
+
manifest.toolCount < 0 ||
|
|
354
|
+
manifest.toolCount > MAX_CATALOG_TOOLS ||
|
|
355
|
+
!Number.isInteger(manifest.byteCount) ||
|
|
356
|
+
manifest.byteCount < 2 ||
|
|
357
|
+
manifest.byteCount > MAX_SERIALIZED_CATALOG_BYTES ||
|
|
358
|
+
!manifest.revision.startsWith(`sha256:${manifest.byteCount}:`) ||
|
|
359
|
+
!Number.isInteger(manifest.chunkCount) ||
|
|
360
|
+
manifest.chunkCount < 1 ||
|
|
361
|
+
manifest.chunkCount > maxChunks ||
|
|
362
|
+
typeof manifest.fetchedAt !== "number" ||
|
|
363
|
+
typeof manifest.expiresAt !== "number" ||
|
|
364
|
+
typeof manifest.staleUntil !== "number") {
|
|
365
|
+
return null;
|
|
366
|
+
}
|
|
367
|
+
return manifest;
|
|
368
|
+
}
|
|
369
|
+
parseCatalogManifest(raw) {
|
|
260
370
|
if (!raw)
|
|
261
371
|
return null;
|
|
262
372
|
try {
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
373
|
+
return this.validCatalogManifest(JSON.parse(raw));
|
|
374
|
+
}
|
|
375
|
+
catch {
|
|
376
|
+
return null;
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
splitCatalogChunks(snapshot) {
|
|
380
|
+
const chunks = [];
|
|
381
|
+
const decoder = new TextDecoder("utf-8", {
|
|
382
|
+
fatal: true,
|
|
383
|
+
ignoreBOM: true,
|
|
384
|
+
});
|
|
385
|
+
let offset = 0;
|
|
386
|
+
while (offset < snapshot.serializedBytes.byteLength) {
|
|
387
|
+
let end = Math.min(offset + MAX_CATALOG_CHUNK_BYTES, snapshot.serializedBytes.byteLength);
|
|
388
|
+
// Move a boundary that landed inside a multibyte UTF-8 sequence back to
|
|
389
|
+
// the next character start. Every stored string then remains valid UTF-8.
|
|
390
|
+
while (end < snapshot.serializedBytes.byteLength &&
|
|
391
|
+
((snapshot.serializedBytes[end] ?? 0) & 0xc0) === 0x80) {
|
|
392
|
+
end--;
|
|
393
|
+
}
|
|
394
|
+
chunks.push(decoder.decode(snapshot.serializedBytes.subarray(offset, end)));
|
|
395
|
+
offset = end;
|
|
396
|
+
}
|
|
397
|
+
return chunks;
|
|
398
|
+
}
|
|
399
|
+
async readCatalog(id, raw, now) {
|
|
400
|
+
if (!raw)
|
|
401
|
+
return null;
|
|
402
|
+
let parsed;
|
|
403
|
+
try {
|
|
404
|
+
parsed = JSON.parse(raw);
|
|
405
|
+
}
|
|
406
|
+
catch {
|
|
407
|
+
return null;
|
|
408
|
+
}
|
|
409
|
+
if (!parsed ||
|
|
410
|
+
typeof parsed !== "object" ||
|
|
411
|
+
parsed.version !== 2) {
|
|
412
|
+
const legacy = this.validLegacyCatalog(parsed);
|
|
413
|
+
if (!legacy || legacy.staleUntil <= now)
|
|
414
|
+
return null;
|
|
415
|
+
const snapshot = await snapshotCatalog(legacy.tools);
|
|
416
|
+
if (legacy.tools.length > MAX_CATALOG_TOOLS ||
|
|
417
|
+
snapshot.serializedBytes.byteLength > MAX_SERIALIZED_CATALOG_BYTES ||
|
|
418
|
+
(legacy.fingerprint !== undefined &&
|
|
419
|
+
legacy.fingerprint !== snapshot.fingerprint)) {
|
|
420
|
+
this.opts.logger.warn(`[connecta] connector "${id}" legacy catalog is oversized or has a fingerprint mismatch; ignoring persisted catalog.`);
|
|
421
|
+
return null;
|
|
422
|
+
}
|
|
423
|
+
return {
|
|
424
|
+
tools: legacy.tools,
|
|
425
|
+
fingerprint: snapshot.fingerprint,
|
|
426
|
+
fetchedAt: legacy.fetchedAt,
|
|
427
|
+
expiresAt: legacy.expiresAt,
|
|
428
|
+
staleUntil: legacy.staleUntil,
|
|
429
|
+
};
|
|
430
|
+
}
|
|
431
|
+
const manifest = this.validCatalogManifest(parsed);
|
|
432
|
+
if (!manifest) {
|
|
433
|
+
this.opts.logger.warn(`[connecta] connector "${id}" catalog manifest is invalid; ignoring persisted catalog.`);
|
|
434
|
+
return null;
|
|
435
|
+
}
|
|
436
|
+
if (manifest.staleUntil <= now)
|
|
437
|
+
return null;
|
|
438
|
+
const chunkReads = await mapSettledWithConcurrency(Array.from({ length: manifest.chunkCount }, (_, index) => index), CATALOG_CHUNK_IO_CONCURRENCY, (index) => this.opts.storage.get(this.catalogChunkKey(id, manifest.revision, index)));
|
|
439
|
+
const chunks = [];
|
|
440
|
+
let chunkBytes = 0;
|
|
441
|
+
for (const [index, result] of chunkReads.entries()) {
|
|
442
|
+
if (result.status === "rejected")
|
|
443
|
+
throw result.reason;
|
|
444
|
+
const chunk = result.value;
|
|
445
|
+
if (chunk === null) {
|
|
446
|
+
this.opts.logger.warn(`[connecta] connector "${id}" catalog chunk ${index + 1}/${manifest.chunkCount} is missing; ignoring persisted catalog.`);
|
|
447
|
+
return null;
|
|
448
|
+
}
|
|
449
|
+
const byteLength = utf8ByteLength(chunk);
|
|
450
|
+
chunkBytes += byteLength;
|
|
451
|
+
if (byteLength > MAX_CATALOG_CHUNK_BYTES ||
|
|
452
|
+
chunkBytes > manifest.byteCount) {
|
|
453
|
+
this.opts.logger.warn(`[connecta] connector "${id}" catalog chunk bounds do not match its manifest; ignoring persisted catalog.`);
|
|
271
454
|
return null;
|
|
272
455
|
}
|
|
273
|
-
|
|
456
|
+
chunks.push(chunk);
|
|
457
|
+
}
|
|
458
|
+
const serializedTools = chunks.join("");
|
|
459
|
+
const stored = await fingerprintSerializedCatalog(serializedTools);
|
|
460
|
+
if (stored.byteLength !== manifest.byteCount ||
|
|
461
|
+
stored.fingerprint !== manifest.revision) {
|
|
462
|
+
this.opts.logger.warn(`[connecta] connector "${id}" catalog fingerprint mismatch; ignoring persisted catalog.`);
|
|
463
|
+
return null;
|
|
464
|
+
}
|
|
465
|
+
let tools;
|
|
466
|
+
try {
|
|
467
|
+
tools = JSON.parse(serializedTools);
|
|
274
468
|
}
|
|
275
469
|
catch {
|
|
470
|
+
this.opts.logger.warn(`[connecta] connector "${id}" catalog chunks are torn; ignoring persisted catalog.`);
|
|
276
471
|
return null;
|
|
277
472
|
}
|
|
473
|
+
if (!Array.isArray(tools) || !this.validCatalogTools(tools)) {
|
|
474
|
+
this.opts.logger.warn(`[connecta] connector "${id}" catalog chunks contain invalid tools; ignoring persisted catalog.`);
|
|
475
|
+
return null;
|
|
476
|
+
}
|
|
477
|
+
if (tools.length !== manifest.toolCount) {
|
|
478
|
+
this.opts.logger.warn(`[connecta] connector "${id}" catalog tool count does not match its manifest; ignoring persisted catalog.`);
|
|
479
|
+
return null;
|
|
480
|
+
}
|
|
481
|
+
return {
|
|
482
|
+
tools,
|
|
483
|
+
fingerprint: stored.fingerprint,
|
|
484
|
+
fetchedAt: manifest.fetchedAt,
|
|
485
|
+
expiresAt: manifest.expiresAt,
|
|
486
|
+
staleUntil: manifest.staleUntil,
|
|
487
|
+
};
|
|
278
488
|
}
|
|
279
|
-
async storeCatalog(id,
|
|
489
|
+
async storeCatalog(id, snapshot) {
|
|
280
490
|
if (!this.persistToolCatalog)
|
|
281
491
|
return;
|
|
282
492
|
const fetchedAt = Date.now();
|
|
283
|
-
const
|
|
284
|
-
|
|
493
|
+
const expiresAt = fetchedAt + this.ttlMs;
|
|
494
|
+
const staleUntil = expiresAt + this.staleMs;
|
|
495
|
+
const ttlSeconds = Math.max(60, Math.ceil((this.ttlMs + this.staleMs) / 1000));
|
|
496
|
+
const chunks = this.splitCatalogChunks(snapshot);
|
|
497
|
+
const chunkWrites = await mapSettledWithConcurrency(chunks, CATALOG_CHUNK_IO_CONCURRENCY, (chunk, index) => this.opts.storage.set(this.catalogChunkKey(id, snapshot.fingerprint, index), chunk, { ttlSeconds: ttlSeconds + CATALOG_CHUNK_TTL_GRACE_SECONDS }));
|
|
498
|
+
for (const result of chunkWrites) {
|
|
499
|
+
if (result.status === "rejected")
|
|
500
|
+
throw result.reason;
|
|
501
|
+
}
|
|
502
|
+
// The manifest is the only publication point. A failed/partial chunk write
|
|
503
|
+
// therefore leaves the previous manifest authoritative (or no catalog);
|
|
504
|
+
// unreachable chunks carry a bounded TTL and require no prefix scan.
|
|
505
|
+
const manifest = {
|
|
506
|
+
version: 2,
|
|
507
|
+
revision: snapshot.fingerprint,
|
|
508
|
+
toolCount: snapshot.tools.length,
|
|
509
|
+
byteCount: snapshot.serializedBytes.byteLength,
|
|
510
|
+
chunkCount: chunks.length,
|
|
285
511
|
fetchedAt,
|
|
286
|
-
expiresAt
|
|
287
|
-
staleUntil
|
|
512
|
+
expiresAt,
|
|
513
|
+
staleUntil,
|
|
288
514
|
};
|
|
289
|
-
await this.opts.storage.set(this.catalogKey(id), JSON.stringify(
|
|
290
|
-
ttlSeconds
|
|
515
|
+
await this.opts.storage.set(this.catalogKey(id), JSON.stringify(manifest), {
|
|
516
|
+
ttlSeconds,
|
|
291
517
|
});
|
|
292
518
|
}
|
|
519
|
+
async deleteCatalog(id) {
|
|
520
|
+
let raw = null;
|
|
521
|
+
let readError;
|
|
522
|
+
try {
|
|
523
|
+
raw = await this.opts.storage.get(this.catalogKey(id));
|
|
524
|
+
}
|
|
525
|
+
catch (err) {
|
|
526
|
+
readError = err;
|
|
527
|
+
}
|
|
528
|
+
const manifest = this.parseCatalogManifest(raw);
|
|
529
|
+
// The root is authoritative, so attempt its deletion even when the
|
|
530
|
+
// best-effort read needed for physical chunk cleanup failed.
|
|
531
|
+
await this.opts.storage.delete(this.catalogKey(id));
|
|
532
|
+
if (!manifest) {
|
|
533
|
+
if (readError)
|
|
534
|
+
throw readError;
|
|
535
|
+
return;
|
|
536
|
+
}
|
|
537
|
+
let firstError;
|
|
538
|
+
for (let index = 0; index < manifest.chunkCount; index++) {
|
|
539
|
+
try {
|
|
540
|
+
await this.opts.storage.delete(this.catalogChunkKey(id, manifest.revision, index));
|
|
541
|
+
}
|
|
542
|
+
catch (err) {
|
|
543
|
+
firstError ??= err;
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
if (readError)
|
|
547
|
+
throw readError;
|
|
548
|
+
if (firstError)
|
|
549
|
+
throw firstError;
|
|
550
|
+
}
|
|
293
551
|
catalogGeneration(id) {
|
|
294
552
|
return this.catalogGenerations.get(id) ?? 0;
|
|
295
553
|
}
|
|
@@ -319,22 +577,40 @@ export class Registry {
|
|
|
319
577
|
const connector = this.connectors.get(id);
|
|
320
578
|
if (!connector)
|
|
321
579
|
throw new Error(`Unknown connector "${id}"`);
|
|
580
|
+
if (connector.staticTools)
|
|
581
|
+
return connector.staticTools;
|
|
322
582
|
const generation = this.catalogGeneration(id);
|
|
323
|
-
const tools = connector.
|
|
324
|
-
? connector.staticTools
|
|
325
|
-
: await connector.listTools(this.contextFor(id, baseUrl, requestScope, callOptions));
|
|
583
|
+
const tools = await connector.listTools(this.contextFor(id, baseUrl, requestScope, callOptions));
|
|
326
584
|
// The caller that began this refresh may still use its result, but a
|
|
327
585
|
// credential/OAuth change that landed while listTools was in flight means
|
|
328
586
|
// the listing must not enter either shared cache layer.
|
|
329
587
|
if (generation !== this.catalogGeneration(id))
|
|
330
588
|
return tools;
|
|
331
|
-
|
|
589
|
+
if (tools.length > MAX_CATALOG_TOOLS) {
|
|
590
|
+
const message = `Connector "${id}" returned ${tools.length} tools, over the ` +
|
|
591
|
+
`${MAX_CATALOG_TOOLS}-tool catalog ceiling; refusing the complete catalog.`;
|
|
592
|
+
this.opts.logger.warn(`[connecta] ${message}`);
|
|
593
|
+
throw new Error(message);
|
|
594
|
+
}
|
|
332
595
|
const previous = this.cache.get(id);
|
|
333
|
-
const
|
|
334
|
-
|
|
335
|
-
|
|
596
|
+
const snapshot = await snapshotCatalog(tools);
|
|
597
|
+
if (generation !== this.catalogGeneration(id))
|
|
598
|
+
return tools;
|
|
599
|
+
if (snapshot.serializedBytes.byteLength > MAX_SERIALIZED_CATALOG_BYTES) {
|
|
600
|
+
const message = `Connector "${id}" returned a ${snapshot.serializedBytes.byteLength}-byte ` +
|
|
601
|
+
`serialized catalog, over the ${MAX_SERIALIZED_CATALOG_BYTES}-byte ceiling; ` +
|
|
602
|
+
"refusing the complete catalog.";
|
|
603
|
+
this.opts.logger.warn(`[connecta] ${message}`);
|
|
604
|
+
throw new Error(message);
|
|
605
|
+
}
|
|
606
|
+
const now = Date.now();
|
|
607
|
+
const catalogChanged = !previous || previous.fingerprint !== snapshot.fingerprint;
|
|
608
|
+
const shouldPersist = catalogChanged ||
|
|
609
|
+
(previous !== undefined && previous.exp <= now) ||
|
|
610
|
+
this.invalidated.has(id);
|
|
336
611
|
this.cache.set(id, {
|
|
337
612
|
tools,
|
|
613
|
+
fingerprint: snapshot.fingerprint,
|
|
338
614
|
exp: now + this.ttlMs,
|
|
339
615
|
staleUntil: now + this.ttlMs + this.staleMs,
|
|
340
616
|
});
|
|
@@ -344,7 +620,7 @@ export class Registry {
|
|
|
344
620
|
if (generation !== this.catalogGeneration(id))
|
|
345
621
|
return;
|
|
346
622
|
try {
|
|
347
|
-
await this.storeCatalog(id,
|
|
623
|
+
await this.storeCatalog(id, snapshot);
|
|
348
624
|
}
|
|
349
625
|
catch (err) {
|
|
350
626
|
this.opts.logger.warn(`[connecta] connector "${id}" catalog persistence failed: ${msg(err)}`);
|
|
@@ -354,7 +630,7 @@ export class Registry {
|
|
|
354
630
|
return tools;
|
|
355
631
|
}
|
|
356
632
|
/** Cached listTools with in-memory + persisted serializable catalog layers. */
|
|
357
|
-
async
|
|
633
|
+
async loadTools(id, baseUrl, requestScope, callOptions = {}) {
|
|
358
634
|
const connector = this.connectors.get(id);
|
|
359
635
|
if (!connector)
|
|
360
636
|
throw new Error(`Unknown connector "${id}"`);
|
|
@@ -370,7 +646,7 @@ export class Registry {
|
|
|
370
646
|
const generation = this.catalogGeneration(id);
|
|
371
647
|
let persisted = null;
|
|
372
648
|
try {
|
|
373
|
-
persisted = this.
|
|
649
|
+
persisted = await this.readCatalog(id, await this.opts.storage.get(this.catalogKey(id)), now);
|
|
374
650
|
}
|
|
375
651
|
catch (err) {
|
|
376
652
|
this.opts.logger.warn(`[connecta] connector "${id}" catalog read failed: ${msg(err)}`);
|
|
@@ -382,6 +658,7 @@ export class Registry {
|
|
|
382
658
|
if (persisted && persisted.staleUntil > now) {
|
|
383
659
|
this.cache.set(id, {
|
|
384
660
|
tools: persisted.tools,
|
|
661
|
+
fingerprint: persisted.fingerprint,
|
|
385
662
|
exp: persisted.expiresAt,
|
|
386
663
|
staleUntil: persisted.staleUntil,
|
|
387
664
|
});
|
|
@@ -403,6 +680,45 @@ export class Registry {
|
|
|
403
680
|
throw err;
|
|
404
681
|
}
|
|
405
682
|
}
|
|
683
|
+
/**
|
|
684
|
+
* Coalesce one connector's cold load inside one inbound request. The WeakMap
|
|
685
|
+
* neither roots the request scope nor lets its connector context escape into
|
|
686
|
+
* another request; settled entries are also removed eagerly.
|
|
687
|
+
*
|
|
688
|
+
* The first caller's `callOptions` govern the shared load: a later caller's
|
|
689
|
+
* signal or timeout neither cancels nor extends it, and an abort by the
|
|
690
|
+
* first caller rejects every coalesced caller. Within one request that is
|
|
691
|
+
* the deal being made — one fetch, one deadline.
|
|
692
|
+
*/
|
|
693
|
+
async getTools(id, baseUrl, requestScope, callOptions = {}) {
|
|
694
|
+
const connector = this.connectors.get(id);
|
|
695
|
+
if (!connector)
|
|
696
|
+
throw new Error(`Unknown connector "${id}"`);
|
|
697
|
+
if (connector.staticTools)
|
|
698
|
+
return connector.staticTools;
|
|
699
|
+
if (!requestScope) {
|
|
700
|
+
return this.loadTools(id, baseUrl, requestScope, callOptions);
|
|
701
|
+
}
|
|
702
|
+
let loads = this.requestCatalogLoads.get(requestScope);
|
|
703
|
+
if (!loads) {
|
|
704
|
+
loads = new Map();
|
|
705
|
+
this.requestCatalogLoads.set(requestScope, loads);
|
|
706
|
+
}
|
|
707
|
+
const existing = loads.get(id);
|
|
708
|
+
if (existing)
|
|
709
|
+
return existing;
|
|
710
|
+
const loading = this.loadTools(id, baseUrl, requestScope, callOptions);
|
|
711
|
+
loads.set(id, loading);
|
|
712
|
+
try {
|
|
713
|
+
return await loading;
|
|
714
|
+
}
|
|
715
|
+
finally {
|
|
716
|
+
if (loads.get(id) === loading)
|
|
717
|
+
loads.delete(id);
|
|
718
|
+
if (loads.size === 0)
|
|
719
|
+
this.requestCatalogLoads.delete(requestScope);
|
|
720
|
+
}
|
|
721
|
+
}
|
|
406
722
|
/** Return a cached catalog without performing storage or network I/O. */
|
|
407
723
|
peekTools(id) {
|
|
408
724
|
const connector = this.connectors.get(id);
|
|
@@ -512,7 +828,7 @@ export class Registry {
|
|
|
512
828
|
if (this.persistToolCatalog) {
|
|
513
829
|
void this.enqueueCatalogMutation(id, async () => {
|
|
514
830
|
try {
|
|
515
|
-
await this.
|
|
831
|
+
await this.deleteCatalog(id);
|
|
516
832
|
}
|
|
517
833
|
catch (err) {
|
|
518
834
|
this.opts.logger.warn(`[connecta] connector "${id}" catalog invalidation failed: ${msg(err)}`);
|
|
@@ -528,7 +844,7 @@ export class Registry {
|
|
|
528
844
|
if (this.persistToolCatalog) {
|
|
529
845
|
await this.enqueueCatalogMutation(id, async () => {
|
|
530
846
|
try {
|
|
531
|
-
await this.
|
|
847
|
+
await this.deleteCatalog(id);
|
|
532
848
|
}
|
|
533
849
|
catch (err) {
|
|
534
850
|
this.opts.logger.warn(`[connecta] connector "${id}" catalog invalidation failed: ${msg(err)}`);
|
|
@@ -655,6 +971,11 @@ export class ScopedRegistry {
|
|
|
655
971
|
throw this.unknownConnector(id);
|
|
656
972
|
return this.base.contextFor(id, baseUrl, requestScope, callOptions);
|
|
657
973
|
}
|
|
974
|
+
admitCall(id, input) {
|
|
975
|
+
if (!this.visible(id))
|
|
976
|
+
return Promise.reject(this.unknownConnector(id));
|
|
977
|
+
return this.base.admitCall(id, input);
|
|
978
|
+
}
|
|
658
979
|
/**
|
|
659
980
|
* Stashed oversized results are bound to the scope that produced them: a
|
|
660
981
|
* scoped session cannot page a result it could not have produced, and an id
|