@zackbart/connecta 0.7.9 → 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 +48 -0
- package/README.md +67 -144
- 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.map +1 -1
- package/dist/call-admission.js +9 -3
- package/dist/call-admission.js.map +1 -1
- 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.map +1 -1
- package/dist/connectors/api.js +22 -12
- package/dist/connectors/api.js.map +1 -1
- package/dist/connectors/remote-mcp.d.ts.map +1 -1
- package/dist/connectors/remote-mcp.js +44 -19
- 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 -321
- 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 +5 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +44 -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 +8 -86
- package/dist/meta-tools.d.ts.map +1 -1
- package/dist/meta-tools.js +145 -583
- 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 +22 -6
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +328 -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 +63 -1293
- 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 +9 -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 -1393
- 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/auth/bearer.ts +4 -4
- package/src/auth/clerk.ts +1 -1
- package/src/call-admission.ts +8 -5
- 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 +22 -12
- package/src/connectors/remote-mcp.ts +52 -22
- package/src/credential-health.ts +13 -10
- package/src/credentials.ts +2 -2
- package/src/errors.ts +1 -1
- package/src/execute.ts +194 -409
- 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 +49 -17
- package/src/invocation.ts +527 -0
- package/src/meta-tools.ts +366 -906
- 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 +434 -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 +66 -1703
- 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 +9 -2
- package/src/ui.ts +52 -1493
- package/src/version.ts +1 -1
package/src/registry.ts
CHANGED
|
@@ -23,15 +23,27 @@ import {
|
|
|
23
23
|
type CallAdmissionPermit,
|
|
24
24
|
type ConnectorCallAdmissionSnapshot,
|
|
25
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";
|
|
26
37
|
import type { DeferredWork } from "./connector-scope.js";
|
|
27
38
|
import { splitAddress, type Toolkit } from "./toolkits.js";
|
|
28
39
|
|
|
29
40
|
const ID_RE = /^[a-z0-9_-]+$/;
|
|
30
41
|
const DEFAULT_TTL_SECONDS = 300;
|
|
31
42
|
const DEFAULT_STALE_SECONDS = 3600;
|
|
32
|
-
|
|
43
|
+
const CATALOG_CHUNK_TTL_GRACE_SECONDS = 300;
|
|
44
|
+
const DEFAULT_MAX_RESULT_BYTES = 50_000;
|
|
33
45
|
/** Independent final-envelope boundary for `batch_call`. */
|
|
34
|
-
|
|
46
|
+
const DEFAULT_MAX_BATCH_RESULT_BYTES = 100_000;
|
|
35
47
|
|
|
36
48
|
/**
|
|
37
49
|
* Smallest accepted inline-result cap. One byte is pathological but harmless:
|
|
@@ -76,17 +88,41 @@ export function resolveMaxResultBytes(
|
|
|
76
88
|
|
|
77
89
|
interface CacheEntry {
|
|
78
90
|
tools: ToolDef[];
|
|
91
|
+
fingerprint: string;
|
|
79
92
|
exp: number; // epoch ms
|
|
80
93
|
staleUntil: number;
|
|
81
94
|
}
|
|
82
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
|
+
|
|
83
116
|
interface PersistedCatalog {
|
|
84
117
|
tools: ToolDef[];
|
|
118
|
+
fingerprint: string;
|
|
85
119
|
fetchedAt: number;
|
|
86
120
|
expiresAt: number;
|
|
87
121
|
staleUntil: number;
|
|
88
122
|
}
|
|
89
123
|
|
|
124
|
+
const CATALOG_CHUNK_IO_CONCURRENCY = 4;
|
|
125
|
+
|
|
90
126
|
export interface HealthObservation {
|
|
91
127
|
lastSuccessAt?: string;
|
|
92
128
|
lastFailureAt?: string;
|
|
@@ -111,13 +147,14 @@ export class HealthLog {
|
|
|
111
147
|
|
|
112
148
|
recordSuccess(id: string, latencyMs: number): void {
|
|
113
149
|
const previous = this.observations.get(id);
|
|
114
|
-
|
|
150
|
+
const observation: HealthObservation = {
|
|
115
151
|
...previous,
|
|
116
152
|
lastSuccessAt: new Date().toISOString(),
|
|
117
153
|
lastLatencyMs: latencyMs,
|
|
118
154
|
consecutiveFailures: 0,
|
|
119
|
-
|
|
120
|
-
|
|
155
|
+
};
|
|
156
|
+
delete observation.lastError;
|
|
157
|
+
this.observations.set(id, observation);
|
|
121
158
|
}
|
|
122
159
|
|
|
123
160
|
recordFailure(id: string, latencyMs: number, error: unknown): void {
|
|
@@ -171,7 +208,35 @@ function msg(err: unknown): string {
|
|
|
171
208
|
return err instanceof Error ? err.message : String(err);
|
|
172
209
|
}
|
|
173
210
|
|
|
174
|
-
|
|
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<
|
|
175
240
|
ConnectorContext,
|
|
176
241
|
"signal" | "timeoutMs"
|
|
177
242
|
>;
|
|
@@ -265,6 +330,11 @@ export class Registry implements RegistryView {
|
|
|
265
330
|
private readonly catalogGenerations = new Map<string, number>();
|
|
266
331
|
/** Serialize persisted catalog set/delete operations within this isolate. */
|
|
267
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
|
+
>();
|
|
268
338
|
/** Deployment-wide observations — every call, whatever view made it. */
|
|
269
339
|
private readonly health = new HealthLog();
|
|
270
340
|
private readonly ttlMs: number;
|
|
@@ -325,7 +395,9 @@ export class Registry implements RegistryView {
|
|
|
325
395
|
this.contextFor(id, baseUrl, requestScope),
|
|
326
396
|
storage: opts.storage,
|
|
327
397
|
logger: opts.logger,
|
|
328
|
-
|
|
398
|
+
...(opts.credentialVault !== undefined
|
|
399
|
+
? { credentialVault: opts.credentialVault }
|
|
400
|
+
: {}),
|
|
329
401
|
},
|
|
330
402
|
opts.credentialHealth,
|
|
331
403
|
);
|
|
@@ -500,47 +572,304 @@ export class Registry implements RegistryView {
|
|
|
500
572
|
return `catalog:${id}`;
|
|
501
573
|
}
|
|
502
574
|
|
|
503
|
-
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> {
|
|
504
679
|
if (!raw) return null;
|
|
680
|
+
let parsed: unknown;
|
|
505
681
|
try {
|
|
506
|
-
|
|
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);
|
|
507
695
|
if (
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
!value.tools.every(
|
|
513
|
-
(tool) =>
|
|
514
|
-
tool !== null &&
|
|
515
|
-
typeof tool === "object" &&
|
|
516
|
-
typeof (tool as ToolDef).name === "string",
|
|
517
|
-
)
|
|
696
|
+
legacy.tools.length > MAX_CATALOG_TOOLS ||
|
|
697
|
+
snapshot.serializedBytes.byteLength > MAX_SERIALIZED_CATALOG_BYTES ||
|
|
698
|
+
(legacy.fingerprint !== undefined &&
|
|
699
|
+
legacy.fingerprint !== snapshot.fingerprint)
|
|
518
700
|
) {
|
|
701
|
+
this.opts.logger.warn(
|
|
702
|
+
`[connecta] connector "${id}" legacy catalog is oversized or has a fingerprint mismatch; ignoring persisted catalog.`,
|
|
703
|
+
);
|
|
519
704
|
return null;
|
|
520
705
|
}
|
|
521
|
-
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);
|
|
522
772
|
} catch {
|
|
773
|
+
this.opts.logger.warn(
|
|
774
|
+
`[connecta] connector "${id}" catalog chunks are torn; ignoring persisted catalog.`,
|
|
775
|
+
);
|
|
523
776
|
return null;
|
|
524
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
|
+
);
|
|
788
|
+
return null;
|
|
789
|
+
}
|
|
790
|
+
return {
|
|
791
|
+
tools,
|
|
792
|
+
fingerprint: stored.fingerprint,
|
|
793
|
+
fetchedAt: manifest.fetchedAt,
|
|
794
|
+
expiresAt: manifest.expiresAt,
|
|
795
|
+
staleUntil: manifest.staleUntil,
|
|
796
|
+
};
|
|
525
797
|
}
|
|
526
798
|
|
|
527
|
-
private async storeCatalog(
|
|
799
|
+
private async storeCatalog(
|
|
800
|
+
id: string,
|
|
801
|
+
snapshot: CatalogSnapshot,
|
|
802
|
+
): Promise<void> {
|
|
528
803
|
if (!this.persistToolCatalog) return;
|
|
529
804
|
const fetchedAt = Date.now();
|
|
530
|
-
const
|
|
531
|
-
|
|
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,
|
|
532
834
|
fetchedAt,
|
|
533
|
-
expiresAt
|
|
534
|
-
staleUntil
|
|
835
|
+
expiresAt,
|
|
836
|
+
staleUntil,
|
|
535
837
|
};
|
|
536
|
-
await this.opts.storage.set(this.catalogKey(id), JSON.stringify(
|
|
537
|
-
ttlSeconds
|
|
538
|
-
60,
|
|
539
|
-
Math.ceil((this.ttlMs + this.staleMs) / 1000),
|
|
540
|
-
),
|
|
838
|
+
await this.opts.storage.set(this.catalogKey(id), JSON.stringify(manifest), {
|
|
839
|
+
ttlSeconds,
|
|
541
840
|
});
|
|
542
841
|
}
|
|
543
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
|
+
|
|
544
873
|
private catalogGeneration(id: string): number {
|
|
545
874
|
return this.catalogGenerations.get(id) ?? 0;
|
|
546
875
|
}
|
|
@@ -580,25 +909,43 @@ export class Registry implements RegistryView {
|
|
|
580
909
|
): Promise<ToolDef[]> {
|
|
581
910
|
const connector = this.connectors.get(id);
|
|
582
911
|
if (!connector) throw new Error(`Unknown connector "${id}"`);
|
|
912
|
+
if (connector.staticTools) return connector.staticTools;
|
|
583
913
|
const generation = this.catalogGeneration(id);
|
|
584
|
-
const tools = connector.
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
this.contextFor(id, baseUrl, requestScope, callOptions),
|
|
588
|
-
);
|
|
914
|
+
const tools = await connector.listTools(
|
|
915
|
+
this.contextFor(id, baseUrl, requestScope, callOptions),
|
|
916
|
+
);
|
|
589
917
|
// The caller that began this refresh may still use its result, but a
|
|
590
918
|
// credential/OAuth change that landed while listTools was in flight means
|
|
591
919
|
// the listing must not enter either shared cache layer.
|
|
592
920
|
if (generation !== this.catalogGeneration(id)) return tools;
|
|
593
|
-
|
|
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
|
+
}
|
|
594
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();
|
|
595
940
|
const catalogChanged =
|
|
596
|
-
!previous ||
|
|
941
|
+
!previous || previous.fingerprint !== snapshot.fingerprint;
|
|
597
942
|
const shouldPersist =
|
|
598
|
-
|
|
599
|
-
(
|
|
943
|
+
catalogChanged ||
|
|
944
|
+
(previous !== undefined && previous.exp <= now) ||
|
|
945
|
+
this.invalidated.has(id);
|
|
600
946
|
this.cache.set(id, {
|
|
601
947
|
tools,
|
|
948
|
+
fingerprint: snapshot.fingerprint,
|
|
602
949
|
exp: now + this.ttlMs,
|
|
603
950
|
staleUntil: now + this.ttlMs + this.staleMs,
|
|
604
951
|
});
|
|
@@ -607,7 +954,7 @@ export class Registry implements RegistryView {
|
|
|
607
954
|
await this.enqueueCatalogMutation(id, async () => {
|
|
608
955
|
if (generation !== this.catalogGeneration(id)) return;
|
|
609
956
|
try {
|
|
610
|
-
await this.storeCatalog(id,
|
|
957
|
+
await this.storeCatalog(id, snapshot);
|
|
611
958
|
} catch (err) {
|
|
612
959
|
this.opts.logger.warn(
|
|
613
960
|
`[connecta] connector "${id}" catalog persistence failed: ${msg(err)}`,
|
|
@@ -619,7 +966,7 @@ export class Registry implements RegistryView {
|
|
|
619
966
|
}
|
|
620
967
|
|
|
621
968
|
/** Cached listTools with in-memory + persisted serializable catalog layers. */
|
|
622
|
-
async
|
|
969
|
+
private async loadTools(
|
|
623
970
|
id: string,
|
|
624
971
|
baseUrl: string,
|
|
625
972
|
requestScope?: object,
|
|
@@ -639,8 +986,10 @@ export class Registry implements RegistryView {
|
|
|
639
986
|
const generation = this.catalogGeneration(id);
|
|
640
987
|
let persisted: PersistedCatalog | null = null;
|
|
641
988
|
try {
|
|
642
|
-
persisted = this.
|
|
989
|
+
persisted = await this.readCatalog(
|
|
990
|
+
id,
|
|
643
991
|
await this.opts.storage.get(this.catalogKey(id)),
|
|
992
|
+
now,
|
|
644
993
|
);
|
|
645
994
|
} catch (err) {
|
|
646
995
|
this.opts.logger.warn(
|
|
@@ -654,6 +1003,7 @@ export class Registry implements RegistryView {
|
|
|
654
1003
|
if (persisted && persisted.staleUntil > now) {
|
|
655
1004
|
this.cache.set(id, {
|
|
656
1005
|
tools: persisted.tools,
|
|
1006
|
+
fingerprint: persisted.fingerprint,
|
|
657
1007
|
exp: persisted.expiresAt,
|
|
658
1008
|
staleUntil: persisted.staleUntil,
|
|
659
1009
|
});
|
|
@@ -679,6 +1029,46 @@ export class Registry implements RegistryView {
|
|
|
679
1029
|
}
|
|
680
1030
|
}
|
|
681
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
|
+
|
|
682
1072
|
/** Return a cached catalog without performing storage or network I/O. */
|
|
683
1073
|
peekTools(id: string): ToolDef[] | undefined {
|
|
684
1074
|
const connector = this.connectors.get(id);
|
|
@@ -811,7 +1201,7 @@ export class Registry implements RegistryView {
|
|
|
811
1201
|
if (this.persistToolCatalog) {
|
|
812
1202
|
void this.enqueueCatalogMutation(id, async () => {
|
|
813
1203
|
try {
|
|
814
|
-
await this.
|
|
1204
|
+
await this.deleteCatalog(id);
|
|
815
1205
|
} catch (err) {
|
|
816
1206
|
this.opts.logger.warn(
|
|
817
1207
|
`[connecta] connector "${id}" catalog invalidation failed: ${msg(err)}`,
|
|
@@ -829,7 +1219,7 @@ export class Registry implements RegistryView {
|
|
|
829
1219
|
if (this.persistToolCatalog) {
|
|
830
1220
|
await this.enqueueCatalogMutation(id, async () => {
|
|
831
1221
|
try {
|
|
832
|
-
await this.
|
|
1222
|
+
await this.deleteCatalog(id);
|
|
833
1223
|
} catch (err) {
|
|
834
1224
|
this.opts.logger.warn(
|
|
835
1225
|
`[connecta] connector "${id}" catalog invalidation failed: ${msg(err)}`,
|