@zackbart/connecta 0.24.4 → 0.26.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/AGENTS.md +43 -12
- package/CHANGELOG.md +524 -0
- package/README.md +32 -15
- package/SECURITY.md +6 -5
- package/bin/connecta.mjs +5 -1
- package/dist/activity.d.ts +10 -3
- package/dist/activity.js +10 -1
- package/dist/advertised-schema.js +53 -0
- package/dist/artifacts/connector.d.ts +55 -0
- package/dist/artifacts/connector.js +724 -0
- package/dist/artifacts/document.js +76 -0
- package/dist/artifacts/guide.js +140 -0
- package/dist/artifacts/html-scan.js +329 -0
- package/dist/artifacts/json.js +68 -0
- package/dist/artifacts/kv-store.d.ts +20 -0
- package/dist/artifacts/kv-store.js +140 -0
- package/dist/artifacts/markdown.js +449 -0
- package/dist/artifacts/module.d.ts +37 -0
- package/dist/artifacts/module.js +60 -0
- package/dist/artifacts/operations.d.ts +195 -0
- package/dist/artifacts/operations.js +944 -0
- package/dist/artifacts/refresh.d.ts +53 -0
- package/dist/artifacts/refresh.js +235 -0
- package/dist/artifacts/routes.js +194 -0
- package/dist/artifacts/types.d.ts +214 -0
- package/dist/artifacts/types.js +22 -0
- package/dist/artifacts/validate.d.ts +62 -0
- package/dist/artifacts/validate.js +652 -0
- package/dist/artifacts.d.ts +6 -0
- package/dist/artifacts.js +9 -0
- package/dist/auth/downstream-oauth.js +490 -106
- package/dist/call-admission.js +139 -113
- package/dist/catalog-service.js +152 -106
- package/dist/catalog.js +297 -38
- package/dist/concurrency.js +24 -23
- package/dist/connector-access.d.ts +18 -4
- package/dist/connector-access.js +65 -20
- package/dist/connector-caller.d.ts +8 -0
- package/dist/connector-caller.js +19 -0
- package/dist/connector-scope.js +6 -37
- package/dist/connectors/api.d.ts +2 -2
- package/dist/connectors/remote-mcp.d.ts +6 -5
- package/dist/connectors/remote-mcp.js +330 -240
- package/dist/credential-contract.d.ts +9 -0
- package/dist/credential-rules.js +2 -2
- package/dist/credentials.d.ts +6 -1
- package/dist/credentials.js +45 -3
- package/dist/errors.d.ts +16 -1
- package/dist/errors.js +28 -0
- package/dist/execute.js +745 -282
- package/dist/executor-admission.d.ts +11 -1
- package/dist/executor-admission.js +145 -63
- package/dist/executors/quickjs-runtime.js +4 -2
- package/dist/executors/quickjs.js +255 -212
- package/dist/fix-prompt.js +37 -0
- package/dist/index.d.ts +72 -7
- package/dist/index.js +281 -26
- package/dist/invocation.js +371 -286
- package/dist/meta-tools.js +213 -94
- package/dist/module-contracts.d.ts +21 -1
- package/dist/oauth-callback-outcome.js +94 -0
- package/dist/oauth-sealing.js +29 -0
- package/dist/operator-ui/fix-prompts.js +87 -0
- package/dist/operator-ui/generated.js +2 -2
- package/dist/operator-ui/model.d.ts +103 -1
- package/dist/operator-ui/setup-commands.js +53 -0
- package/dist/operator-ui/view.js +128 -3
- package/dist/program-source.js +321 -0
- package/dist/providers/notion.js +2 -2
- package/dist/providers/revenuecat.js +32 -5
- package/dist/providers/stripe.d.ts +3 -2
- package/dist/providers/vercel.js +1 -1
- package/dist/registry.d.ts +43 -7
- package/dist/registry.js +434 -288
- package/dist/resumable.js +1291 -0
- package/dist/routes/activity.js +74 -154
- package/dist/routes/actor-labels.js +114 -0
- package/dist/routes/credentials.js +196 -163
- package/dist/routes/mcp.js +228 -112
- package/dist/routes/oauth-management.js +73 -71
- package/dist/routes/oauth.js +44 -16
- package/dist/routes/operator.js +83 -0
- package/dist/routes/shared.d.ts +25 -1
- package/dist/routes/shared.js +20 -2
- package/dist/routes/ui-data.js +211 -0
- package/dist/routes/ui.js +76 -35
- package/dist/run-journal.js +347 -0
- package/dist/runtime/admission.js +43 -0
- package/dist/runtime/call-admission.js +67 -0
- package/dist/runtime/connector-scope.js +72 -0
- package/dist/runtime/run.js +174 -0
- package/dist/runtime/services.js +130 -0
- package/dist/runtime/shared-read.js +106 -0
- package/dist/runtime/storage.js +66 -0
- package/dist/server.js +137 -89
- package/dist/skills.js +28 -9
- package/dist/storage/file.js +39 -2
- package/dist/storage/memory.js +28 -16
- package/dist/timeout.js +39 -43
- package/dist/tool-safety.d.ts +27 -1
- package/dist/tool-safety.js +31 -0
- package/dist/types.d.ts +45 -7
- package/dist/ui.d.ts +25 -4
- package/dist/ui.js +77 -167
- package/dist/url-safety.js +157 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/documentation/architecture.md +367 -16
- package/documentation/auth.md +163 -2
- package/documentation/code-mode.md +274 -56
- package/documentation/meta-tools.md +200 -44
- package/ethos.md +46 -47
- package/examples/worker/AGENTS.md +6 -0
- package/examples/worker/README.md +224 -14
- package/examples/worker/src/d1-activity-row.ts +14 -2
- package/examples/worker/src/d1-activity.ts +4 -2
- package/examples/worker/src/d1-storage.ts +116 -0
- package/examples/worker/src/index.ts +42 -7
- package/examples/worker/src/r2-artifact-blobs.ts +29 -0
- package/examples/worker/wrangler.jsonc +21 -5
- package/package.json +16 -4
- package/templates/node/AGENTS.md +9 -2
- package/templates/node/README.md +31 -3
- package/templates/node/package.json +1 -1
- package/templates/node/src/index.ts +27 -0
- package/dist/auth/downstream-oauth.d.ts +0 -200
- package/dist/catalog-fingerprint.d.ts +0 -19
- package/dist/catalog-limits.d.ts +0 -9
- package/dist/catalog-service.d.ts +0 -263
- package/dist/catalog.d.ts +0 -87
- package/dist/concurrency.d.ts +0 -3
- package/dist/connectors/guarded-fetch.d.ts +0 -96
- package/dist/credential-rules.d.ts +0 -85
- package/dist/execute.d.ts +0 -154
- package/dist/executor-result.d.ts +0 -10
- package/dist/executors/quickjs-child.d.ts +0 -1
- package/dist/executors/quickjs-protocol.d.ts +0 -67
- package/dist/favicon.d.ts +0 -1
- package/dist/identity.d.ts +0 -4
- package/dist/invocation.d.ts +0 -74
- package/dist/mcp-result.d.ts +0 -8
- package/dist/meta-tools.d.ts +0 -129
- package/dist/operator-ui/generated.d.ts +0 -2
- package/dist/operator-ui/view.d.ts +0 -188
- package/dist/result-shapes.d.ts +0 -13
- package/dist/retired-toolkits.d.ts +0 -2
- package/dist/routes/activity.d.ts +0 -2
- package/dist/routes/credentials.d.ts +0 -2
- package/dist/routes/mcp.d.ts +0 -10
- package/dist/routes/oauth-management.d.ts +0 -2
- package/dist/routes/oauth.d.ts +0 -2
- package/dist/routes/ui.d.ts +0 -2
- package/dist/server.d.ts +0 -9
- package/dist/skills.d.ts +0 -44
- package/dist/timeout.d.ts +0 -21
package/dist/registry.js
CHANGED
|
@@ -1,19 +1,26 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { Cause, Clock, Deferred, Duration, Effect, Exit, Option, Result, } from "effect";
|
|
2
2
|
import { storedCredentialShape, } from "./credential-rules.js";
|
|
3
3
|
import { ConnectorCallError, msg } from "./errors.js";
|
|
4
4
|
import { ConnectorCallAdmissionController, aggregateCallAdmissionSnapshots, } from "./call-admission.js";
|
|
5
5
|
import { boundedCatalogDrift } from "./catalog-drift.js";
|
|
6
6
|
import { fingerprintSerializedCatalog, snapshotCatalog, } from "./catalog-fingerprint.js";
|
|
7
7
|
import { MAX_CATALOG_CHUNK_BYTES, MAX_CATALOG_TOOLS, MAX_SERIALIZED_CATALOG_BYTES, } from "./catalog-limits.js";
|
|
8
|
-
import { mapSettledWithConcurrency } from "./concurrency.js";
|
|
9
8
|
import { ObservedOutputSchemas } from "./result-shapes.js";
|
|
10
9
|
import { GUIDE_SUMMARY_LENGTH, normalizeGuideSummary, } from "./skills.js";
|
|
11
|
-
import {
|
|
10
|
+
import { attachOAuthSealer, vaultOAuthSealer } from "./oauth-sealing.js";
|
|
11
|
+
import { attachCaller } from "./connector-caller.js";
|
|
12
|
+
import { closeScopeOnExit } from "./runtime/connector-scope.js";
|
|
13
|
+
import { detach, runEdge, withDeadlineEffect } from "./runtime/run.js";
|
|
14
|
+
import { SharedRead } from "./runtime/shared-read.js";
|
|
15
|
+
import { Logger as LoggerService } from "./runtime/services.js";
|
|
16
|
+
import { runOnPartition, storageDelete, storageGet, storageSet, } from "./runtime/storage.js";
|
|
17
|
+
import { DEFAULT_PROBE_TIMEOUT_MS, normalizeTimeoutMs } from "./timeout.js";
|
|
18
|
+
import { isExplicitlyReadOnly } from "./tool-safety.js";
|
|
12
19
|
const ID_RE = /^[a-z0-9_-]+$/;
|
|
13
20
|
const DEFAULT_TTL_SECONDS = 300;
|
|
14
21
|
const DEFAULT_STALE_SECONDS = 3600;
|
|
15
22
|
const CATALOG_CHUNK_TTL_GRACE_SECONDS = 300;
|
|
16
|
-
const DEFAULT_MAX_RESULT_BYTES =
|
|
23
|
+
const DEFAULT_MAX_RESULT_BYTES = 24_000;
|
|
17
24
|
const encoder = new TextEncoder();
|
|
18
25
|
/**
|
|
19
26
|
* Split `"<connectorId>.<toolName>"` on the first dot. Connector ids contain
|
|
@@ -66,7 +73,34 @@ export function resolveMaxResultBytes(value, inherited) {
|
|
|
66
73
|
? value
|
|
67
74
|
: inherited;
|
|
68
75
|
}
|
|
76
|
+
/**
|
|
77
|
+
* What a joiner hears from a flight that will not answer for it: one that
|
|
78
|
+
* passed its bound, or failed for a reason that was its owner's alone. It
|
|
79
|
+
* never reaches a caller; a joiner that hears it makes a fresh attempt.
|
|
80
|
+
*/
|
|
81
|
+
class AbandonedCatalogRefresh extends Error {
|
|
82
|
+
constructor(id) {
|
|
83
|
+
super(`The catalog refresh of "${id}" was abandoned.`);
|
|
84
|
+
this.name = "AbandonedCatalogRefresh";
|
|
85
|
+
}
|
|
86
|
+
}
|
|
69
87
|
const CATALOG_CHUNK_IO_CONCURRENCY = 4;
|
|
88
|
+
/**
|
|
89
|
+
* Run `operation` over every chunk index or chunk with the fixed chunk I/O
|
|
90
|
+
* bound, settling each: one failure neither interrupts its siblings nor
|
|
91
|
+
* leaves their storage calls running behind the caller. Results keep input
|
|
92
|
+
* order, so the first failure by index is the one a caller reports.
|
|
93
|
+
*/
|
|
94
|
+
function forEachChunk(items, operation) {
|
|
95
|
+
return Effect.forEach(items, (item, index) => Effect.result(operation(item, index)), { concurrency: CATALOG_CHUNK_IO_CONCURRENCY });
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* A persisted-catalog mutation that logs its storage failure rather than
|
|
99
|
+
* failing, so its turn in the mutation queue always ends.
|
|
100
|
+
*/
|
|
101
|
+
function warnOnFailure(mutation, failed) {
|
|
102
|
+
return Effect.catch(mutation, (err) => LoggerService.use((logger) => Effect.sync(() => logger.warn(`[connecta] ${failed}: ${msg(err)}`))));
|
|
103
|
+
}
|
|
70
104
|
function namespaced(storage, prefix) {
|
|
71
105
|
return {
|
|
72
106
|
get: (k) => storage.get(prefix + k),
|
|
@@ -77,6 +111,11 @@ function namespaced(storage, prefix) {
|
|
|
77
111
|
list: async (keyPrefix) => (await storage.list(prefix + keyPrefix)).map((key) => key.slice(prefix.length)),
|
|
78
112
|
}
|
|
79
113
|
: {}),
|
|
114
|
+
...(storage.compareAndSet
|
|
115
|
+
? {
|
|
116
|
+
compareAndSet: (k, expected, next, o) => storage.compareAndSet(prefix + k, expected, next, o),
|
|
117
|
+
}
|
|
118
|
+
: {}),
|
|
80
119
|
};
|
|
81
120
|
}
|
|
82
121
|
const MAX_PERSONAL_REGISTRIES = 1_024;
|
|
@@ -101,9 +140,11 @@ export class Registry {
|
|
|
101
140
|
invalidated = new Set();
|
|
102
141
|
/** Per-connector epoch preventing a pre-invalidation refresh from publishing. */
|
|
103
142
|
catalogGenerations = new Map();
|
|
143
|
+
// The last queued mutation per connector, as the turn its own request
|
|
144
|
+
// completes once its storage work is done; see enqueueCatalogMutation.
|
|
104
145
|
/** Serialize persisted catalog set/delete operations within this isolate. */
|
|
105
146
|
catalogMutations = new Map();
|
|
106
|
-
/** Same-request cold loads share one
|
|
147
|
+
/** Same-request cold loads share one read without retaining the request.
|
|
107
148
|
* See documentation/architecture.md#the-two-lifetimes. */
|
|
108
149
|
requestCatalogLoads = new WeakMap();
|
|
109
150
|
/** One live refresh per connector across agent and operator requests. */
|
|
@@ -181,9 +222,15 @@ export class Registry {
|
|
|
181
222
|
}
|
|
182
223
|
if (this.personalRegistries.size >= MAX_PERSONAL_REGISTRIES) {
|
|
183
224
|
// Eviction must not reset a live rolling budget or orphan queued calls.
|
|
184
|
-
|
|
225
|
+
// Nor may it drop catalog work in flight: a replacement for the same
|
|
226
|
+
// principal starts with no generations and no mutation queue, so a
|
|
227
|
+
// refresh the evicted registry finishes later would persist a listing
|
|
228
|
+
// that a credential change on the replacement had just deleted.
|
|
229
|
+
const idle = [...this.personalRegistries].find(([, candidate]) => [...candidate.callAdmission.values()].every(admission => admission.isIdle()) &&
|
|
230
|
+
candidate.catalogRefreshes.size === 0 &&
|
|
231
|
+
candidate.catalogMutations.size === 0);
|
|
185
232
|
if (!idle) {
|
|
186
|
-
throw new Error("Personal connector capacity is exhausted; retry after calls
|
|
233
|
+
throw new Error("Personal connector capacity is exhausted; retry after calls, rolling budgets, and catalog refreshes drain.");
|
|
187
234
|
}
|
|
188
235
|
idle[1].closeCallAdmission();
|
|
189
236
|
this.personalRegistries.delete(idle[0]);
|
|
@@ -346,7 +393,7 @@ export class Registry {
|
|
|
346
393
|
getAll: readValues,
|
|
347
394
|
};
|
|
348
395
|
}
|
|
349
|
-
|
|
396
|
+
const context = {
|
|
350
397
|
storage: namespaced(this.opts.storage, `conn:${id}:`),
|
|
351
398
|
logger: this.opts.logger,
|
|
352
399
|
baseUrl,
|
|
@@ -354,6 +401,11 @@ export class Registry {
|
|
|
354
401
|
requestScope,
|
|
355
402
|
...callOptions,
|
|
356
403
|
};
|
|
404
|
+
// Downstream OAuth state is sealed under the vault key, bound to this
|
|
405
|
+
// connector and owner. The sealer rides beside the context, not on it.
|
|
406
|
+
return this.opts.credentialVault
|
|
407
|
+
? attachOAuthSealer(context, vaultOAuthSealer(this.opts.credentialVault, id, this.opts.credentialOwner, this.opts.logger))
|
|
408
|
+
: context;
|
|
357
409
|
}
|
|
358
410
|
admitCall(id, input) {
|
|
359
411
|
const admission = this.callAdmission.get(id);
|
|
@@ -455,53 +507,56 @@ export class Registry {
|
|
|
455
507
|
* read-cost decision, not a capacity one: however many keys an envelope
|
|
456
508
|
* occupies, it is one stash entry charged its total ASCII length.
|
|
457
509
|
*/
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
//
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
if (
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
this
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
510
|
+
stashResult(key, chunks, ttlSeconds, prefix = "results:") {
|
|
511
|
+
// The fiber runs synchronously up to its first storage call. With nothing
|
|
512
|
+
// expired to reclaim, that is the first chunk write, after the
|
|
513
|
+
// reservation: a concurrent stash already sees this one's charge.
|
|
514
|
+
return runOnPartition(Effect.gen({ self: this }, function* () {
|
|
515
|
+
const maxBytes = this.opts.results?.maxStashBytes ?? 8 * 1024 * 1024;
|
|
516
|
+
const maxEntries = this.opts.results?.maxStashEntries ?? 64;
|
|
517
|
+
// The paging envelope is ASCII, so its string length is its stored byte count.
|
|
518
|
+
const bytes = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
|
|
519
|
+
if (bytes > maxBytes || maxEntries === 0)
|
|
520
|
+
return false;
|
|
521
|
+
const now = yield* Clock.currentTimeMillis;
|
|
522
|
+
for (const [oldKey, entry] of this.resultStash) {
|
|
523
|
+
if (entry.busy || entry.expiresAt > now)
|
|
524
|
+
continue;
|
|
525
|
+
entry.busy = true;
|
|
526
|
+
// TTL alone cannot reclaim a lazy backend. Keep the charge until
|
|
527
|
+
// deletion succeeds, including writes which persisted before throwing;
|
|
528
|
+
// a failed delete fails this stash, and the entry waits for the next.
|
|
529
|
+
yield* Effect.gen({ self: this }, function* () {
|
|
530
|
+
for (const staleKey of entry.keys)
|
|
531
|
+
yield* storageDelete(staleKey);
|
|
532
|
+
this.resultStash.delete(oldKey);
|
|
533
|
+
this.resultStashBytes -= entry.bytes;
|
|
534
|
+
}).pipe(Effect.ensuring(Effect.sync(() => { entry.busy = false; })));
|
|
480
535
|
}
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
this.resultStashBytes += bytes;
|
|
489
|
-
try {
|
|
536
|
+
if (this.resultStash.size >= maxEntries || this.resultStashBytes + bytes > maxBytes)
|
|
537
|
+
return false;
|
|
538
|
+
const fullKey = prefix + key;
|
|
539
|
+
const keys = chunks.map((_, index) => index === 0 ? fullKey : `${fullKey}#${index}`);
|
|
540
|
+
const entry = { keys, bytes, expiresAt: Infinity, busy: true };
|
|
541
|
+
this.resultStash.set(fullKey, entry);
|
|
542
|
+
this.resultStashBytes += bytes;
|
|
490
543
|
// Trailing chunks first: the header chunk is what makes an id readable, so
|
|
491
544
|
// a write that fails midway leaves no envelope pointing at absent chunks.
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
545
|
+
yield* Effect.gen(function* () {
|
|
546
|
+
for (let index = keys.length - 1; index >= 0; index--) {
|
|
547
|
+
yield* storageSet(keys[index], chunks[index], { ttlSeconds });
|
|
548
|
+
}
|
|
549
|
+
}).pipe(
|
|
550
|
+
// A failed write expires at once, still charged, so the next stash
|
|
551
|
+
// reclaims every key it may have persisted before failing.
|
|
552
|
+
Effect.onExit((exit) => Clock.currentTimeMillis.pipe(Effect.map((writtenAt) => {
|
|
553
|
+
entry.expiresAt = Exit.isSuccess(exit)
|
|
554
|
+
? writtenAt + ttlSeconds * 1000
|
|
555
|
+
: 0;
|
|
556
|
+
entry.busy = false;
|
|
557
|
+
}))));
|
|
496
558
|
return true;
|
|
497
|
-
}
|
|
498
|
-
catch (error) {
|
|
499
|
-
entry.expiresAt = 0;
|
|
500
|
-
throw error;
|
|
501
|
-
}
|
|
502
|
-
finally {
|
|
503
|
-
entry.busy = false;
|
|
504
|
-
}
|
|
559
|
+
}), this.opts);
|
|
505
560
|
}
|
|
506
561
|
/**
|
|
507
562
|
* Storage namespaced to the meta-tool result store (`results:` prefix), kept
|
|
@@ -596,135 +651,145 @@ export class Registry {
|
|
|
596
651
|
}
|
|
597
652
|
return chunks;
|
|
598
653
|
}
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
}
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
}
|
|
609
|
-
const manifest = this.validCatalogManifest(parsed);
|
|
610
|
-
if (!manifest) {
|
|
611
|
-
this.opts.logger.warn(`[connecta] connector "${id}" catalog manifest is invalid; ignoring persisted catalog.`);
|
|
612
|
-
return null;
|
|
613
|
-
}
|
|
614
|
-
if (manifest.staleUntil <= now)
|
|
615
|
-
return null;
|
|
616
|
-
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)));
|
|
617
|
-
const chunks = [];
|
|
618
|
-
let chunkBytes = 0;
|
|
619
|
-
for (const [index, result] of chunkReads.entries()) {
|
|
620
|
-
if (result.status === "rejected")
|
|
621
|
-
throw result.reason;
|
|
622
|
-
const chunk = result.value;
|
|
623
|
-
if (chunk === null) {
|
|
624
|
-
this.opts.logger.warn(`[connecta] connector "${id}" catalog chunk ${index + 1}/${manifest.chunkCount} is missing; ignoring persisted catalog.`);
|
|
654
|
+
// The persisted catalog's one reader: the manifest at `catalog:<id>`, then
|
|
655
|
+
// the chunks it names. A storage failure fails the effect, and the caller
|
|
656
|
+
// logs it; a manifest or chunk that is invalid, missing, torn, or does not
|
|
657
|
+
// match its fingerprint is logged here and read as no catalog, because a
|
|
658
|
+
// catalog is complete or it is nothing.
|
|
659
|
+
readCatalog(id, now) {
|
|
660
|
+
return Effect.gen({ self: this }, function* () {
|
|
661
|
+
const raw = yield* storageGet(this.catalogKey(id));
|
|
662
|
+
if (!raw)
|
|
625
663
|
return null;
|
|
664
|
+
let parsed;
|
|
665
|
+
try {
|
|
666
|
+
parsed = JSON.parse(raw);
|
|
626
667
|
}
|
|
627
|
-
|
|
628
|
-
chunkBytes += byteLength;
|
|
629
|
-
if (byteLength > MAX_CATALOG_CHUNK_BYTES ||
|
|
630
|
-
chunkBytes > manifest.byteCount) {
|
|
631
|
-
this.opts.logger.warn(`[connecta] connector "${id}" catalog chunk bounds do not match its manifest; ignoring persisted catalog.`);
|
|
668
|
+
catch {
|
|
632
669
|
return null;
|
|
633
670
|
}
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
671
|
+
const logger = yield* LoggerService;
|
|
672
|
+
const manifest = this.validCatalogManifest(parsed);
|
|
673
|
+
if (!manifest) {
|
|
674
|
+
logger.warn(`[connecta] connector "${id}" catalog manifest is invalid; ignoring persisted catalog.`);
|
|
675
|
+
return null;
|
|
676
|
+
}
|
|
677
|
+
if (manifest.staleUntil <= now)
|
|
678
|
+
return null;
|
|
679
|
+
const chunkReads = yield* forEachChunk(Array.from({ length: manifest.chunkCount }, (_, index) => index), (index) => storageGet(this.catalogChunkKey(id, manifest.revision, index)));
|
|
680
|
+
const chunks = [];
|
|
681
|
+
let chunkBytes = 0;
|
|
682
|
+
for (const [index, read] of chunkReads.entries()) {
|
|
683
|
+
if (Result.isFailure(read))
|
|
684
|
+
return yield* Effect.fail(read.failure);
|
|
685
|
+
const chunk = read.success;
|
|
686
|
+
if (chunk === null) {
|
|
687
|
+
logger.warn(`[connecta] connector "${id}" catalog chunk ${index + 1}/${manifest.chunkCount} is missing; ignoring persisted catalog.`);
|
|
688
|
+
return null;
|
|
689
|
+
}
|
|
690
|
+
const byteLength = encoder.encode(chunk).byteLength;
|
|
691
|
+
chunkBytes += byteLength;
|
|
692
|
+
if (byteLength > MAX_CATALOG_CHUNK_BYTES ||
|
|
693
|
+
chunkBytes > manifest.byteCount) {
|
|
694
|
+
logger.warn(`[connecta] connector "${id}" catalog chunk bounds do not match its manifest; ignoring persisted catalog.`);
|
|
695
|
+
return null;
|
|
696
|
+
}
|
|
697
|
+
chunks.push(chunk);
|
|
698
|
+
}
|
|
699
|
+
const serializedTools = chunks.join("");
|
|
700
|
+
const stored = yield* Effect.tryPromise({
|
|
701
|
+
try: () => fingerprintSerializedCatalog(serializedTools),
|
|
702
|
+
catch: (error) => error,
|
|
703
|
+
});
|
|
704
|
+
if (stored.byteLength !== manifest.byteCount ||
|
|
705
|
+
stored.fingerprint !== manifest.revision) {
|
|
706
|
+
logger.warn(`[connecta] connector "${id}" catalog fingerprint mismatch; ignoring persisted catalog.`);
|
|
707
|
+
return null;
|
|
708
|
+
}
|
|
709
|
+
let tools;
|
|
710
|
+
try {
|
|
711
|
+
tools = JSON.parse(serializedTools);
|
|
712
|
+
}
|
|
713
|
+
catch {
|
|
714
|
+
logger.warn(`[connecta] connector "${id}" catalog chunks are torn; ignoring persisted catalog.`);
|
|
715
|
+
return null;
|
|
716
|
+
}
|
|
717
|
+
if (!Array.isArray(tools) || !this.validCatalogTools(tools)) {
|
|
718
|
+
logger.warn(`[connecta] connector "${id}" catalog chunks contain invalid tools; ignoring persisted catalog.`);
|
|
719
|
+
return null;
|
|
720
|
+
}
|
|
721
|
+
if (tools.length !== manifest.toolCount) {
|
|
722
|
+
logger.warn(`[connecta] connector "${id}" catalog tool count does not match its manifest; ignoring persisted catalog.`);
|
|
723
|
+
return null;
|
|
724
|
+
}
|
|
725
|
+
return {
|
|
726
|
+
tools,
|
|
727
|
+
fingerprint: stored.fingerprint,
|
|
728
|
+
fetchedAt: manifest.fetchedAt,
|
|
729
|
+
expiresAt: manifest.expiresAt,
|
|
730
|
+
staleUntil: manifest.staleUntil,
|
|
731
|
+
};
|
|
695
732
|
});
|
|
696
733
|
}
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
}
|
|
715
|
-
let firstError;
|
|
716
|
-
for (let index = 0; index < manifest.chunkCount; index++) {
|
|
717
|
-
try {
|
|
718
|
-
await this.opts.storage.delete(this.catalogChunkKey(id, manifest.revision, index));
|
|
734
|
+
// Persist one complete catalog: every chunk, then the manifest. A snapshot
|
|
735
|
+
// only reaches here after the tool and byte ceilings accepted it whole.
|
|
736
|
+
storeCatalog(id, snapshot) {
|
|
737
|
+
return Effect.gen({ self: this }, function* () {
|
|
738
|
+
if (!this.persistToolCatalog)
|
|
739
|
+
return;
|
|
740
|
+
const fetchedAt = yield* Clock.currentTimeMillis;
|
|
741
|
+
const expiresAt = fetchedAt + this.ttlMs;
|
|
742
|
+
const staleUntil = expiresAt + this.staleMs;
|
|
743
|
+
const ttlSeconds = Math.max(60, Math.ceil((this.ttlMs + this.staleMs) / 1000));
|
|
744
|
+
const chunks = this.splitCatalogChunks(snapshot);
|
|
745
|
+
const chunkWrites = yield* forEachChunk(chunks, (chunk, index) => storageSet(this.catalogChunkKey(id, snapshot.fingerprint, index), chunk, {
|
|
746
|
+
ttlSeconds: ttlSeconds + CATALOG_CHUNK_TTL_GRACE_SECONDS,
|
|
747
|
+
}));
|
|
748
|
+
for (const write of chunkWrites) {
|
|
749
|
+
if (Result.isFailure(write))
|
|
750
|
+
return yield* Effect.fail(write.failure);
|
|
719
751
|
}
|
|
720
|
-
|
|
721
|
-
|
|
752
|
+
// The manifest is the only publication point. A failed/partial chunk write
|
|
753
|
+
// therefore leaves the previous manifest authoritative (or no catalog);
|
|
754
|
+
// unreachable chunks carry a bounded TTL and require no prefix scan.
|
|
755
|
+
const manifest = {
|
|
756
|
+
version: 2,
|
|
757
|
+
revision: snapshot.fingerprint,
|
|
758
|
+
toolCount: snapshot.tools.length,
|
|
759
|
+
byteCount: snapshot.serializedBytes.byteLength,
|
|
760
|
+
chunkCount: chunks.length,
|
|
761
|
+
fetchedAt,
|
|
762
|
+
expiresAt,
|
|
763
|
+
staleUntil,
|
|
764
|
+
};
|
|
765
|
+
yield* storageSet(this.catalogKey(id), JSON.stringify(manifest), {
|
|
766
|
+
ttlSeconds,
|
|
767
|
+
});
|
|
768
|
+
});
|
|
769
|
+
}
|
|
770
|
+
// Remove the persisted catalog: the manifest always, then the chunks it
|
|
771
|
+
// names. Every delete is attempted; the effect fails with the manifest
|
|
772
|
+
// read's error first, then the first chunk delete's.
|
|
773
|
+
deleteCatalog(id) {
|
|
774
|
+
return Effect.gen({ self: this }, function* () {
|
|
775
|
+
const read = yield* Effect.result(storageGet(this.catalogKey(id)));
|
|
776
|
+
const manifest = Result.isSuccess(read)
|
|
777
|
+
? this.parseCatalogManifest(read.success)
|
|
778
|
+
: null;
|
|
779
|
+
// The root is authoritative, so attempt its deletion even when the
|
|
780
|
+
// best-effort read needed for physical chunk cleanup failed.
|
|
781
|
+
yield* storageDelete(this.catalogKey(id));
|
|
782
|
+
let firstError;
|
|
783
|
+
for (let index = 0; manifest && index < manifest.chunkCount; index++) {
|
|
784
|
+
const deleted = yield* Effect.result(storageDelete(this.catalogChunkKey(id, manifest.revision, index)));
|
|
785
|
+
if (Result.isFailure(deleted))
|
|
786
|
+
firstError ??= deleted;
|
|
722
787
|
}
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
788
|
+
if (Result.isFailure(read))
|
|
789
|
+
return yield* Effect.fail(read.failure);
|
|
790
|
+
if (firstError)
|
|
791
|
+
return yield* Effect.fail(firstError.failure);
|
|
792
|
+
});
|
|
728
793
|
}
|
|
729
794
|
catalogGeneration(id) {
|
|
730
795
|
return this.catalogGenerations.get(id) ?? 0;
|
|
@@ -737,20 +802,31 @@ export class Registry {
|
|
|
737
802
|
* old refresh can finish its storage.set after a credential change deletes
|
|
738
803
|
* the catalog and resurrect the pre-change listing.
|
|
739
804
|
*/
|
|
740
|
-
enqueueCatalogMutation(id, operation) {
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
805
|
+
async enqueueCatalogMutation(id, operation) {
|
|
806
|
+
// Mutations run in arrival order, each taking its turn from the one
|
|
807
|
+
// queued before it. That one may belong to another request, and
|
|
808
|
+
// completing a Deferred resumes its waiters inside the completing call,
|
|
809
|
+
// so the wait is an edge of its own and this request's storage work
|
|
810
|
+
// starts only after it. (Effect's Semaphore would do neither: it resumes
|
|
811
|
+
// a waiter from the releasing fiber, and a newcomer can take the permit
|
|
812
|
+
// before a waiter that queued earlier.) An operation logs its own storage
|
|
813
|
+
// failure, so a turn always ends.
|
|
814
|
+
const previous = this.catalogMutations.get(id);
|
|
815
|
+
const turn = Deferred.makeUnsafe();
|
|
816
|
+
this.catalogMutations.set(id, turn);
|
|
817
|
+
try {
|
|
818
|
+
if (previous)
|
|
819
|
+
await runEdge(Deferred.await(previous));
|
|
820
|
+
await runOnPartition(operation, this.opts);
|
|
821
|
+
}
|
|
822
|
+
finally {
|
|
823
|
+
if (this.catalogMutations.get(id) === turn) {
|
|
747
824
|
this.catalogMutations.delete(id);
|
|
748
825
|
}
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
return next;
|
|
826
|
+
Deferred.doneUnsafe(turn, Exit.void);
|
|
827
|
+
}
|
|
752
828
|
}
|
|
753
|
-
async refreshToolsWithContext(id, connector, ctx, skipPublicationWhenAborted = false) {
|
|
829
|
+
async refreshToolsWithContext(id, connector, ctx, skipPublicationWhenAborted = false, flight) {
|
|
754
830
|
const generation = this.catalogGeneration(id);
|
|
755
831
|
const tools = await connector.listTools(ctx);
|
|
756
832
|
// The listing a maintained proxy just served is also the only catalog
|
|
@@ -765,9 +841,10 @@ export class Registry {
|
|
|
765
841
|
if (skipPublicationWhenAborted && ctx.signal?.aborted)
|
|
766
842
|
return tools;
|
|
767
843
|
// The caller that began this refresh may still use its result, but a
|
|
768
|
-
// credential/OAuth change that landed while listTools was in flight
|
|
769
|
-
// the listing must not enter
|
|
770
|
-
|
|
844
|
+
// credential/OAuth change that landed while listTools was in flight, or a
|
|
845
|
+
// flight abandoned while it listed, means the listing must not enter
|
|
846
|
+
// either shared cache layer.
|
|
847
|
+
if (!this.mayPublish(id, generation, flight))
|
|
771
848
|
return tools;
|
|
772
849
|
if (tools.length > MAX_CATALOG_TOOLS) {
|
|
773
850
|
const message = `Connector "${id}" returned ${tools.length} tools, over the ` +
|
|
@@ -777,7 +854,7 @@ export class Registry {
|
|
|
777
854
|
}
|
|
778
855
|
const previous = this.cache.get(id);
|
|
779
856
|
const snapshot = await snapshotCatalog(tools);
|
|
780
|
-
if (
|
|
857
|
+
if (!this.mayPublish(id, generation, flight) ||
|
|
781
858
|
(skipPublicationWhenAborted && ctx.signal?.aborted)) {
|
|
782
859
|
return tools;
|
|
783
860
|
}
|
|
@@ -801,54 +878,97 @@ export class Registry {
|
|
|
801
878
|
});
|
|
802
879
|
this.invalidated.delete(id);
|
|
803
880
|
if (shouldPersist) {
|
|
804
|
-
await this.enqueueCatalogMutation(id,
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
}
|
|
810
|
-
catch (err) {
|
|
811
|
-
this.opts.logger.warn(`[connecta] connector "${id}" catalog persistence failed: ${msg(err)}`);
|
|
812
|
-
}
|
|
813
|
-
});
|
|
881
|
+
await this.enqueueCatalogMutation(id,
|
|
882
|
+
// The generation is read when the turn comes, not when it is queued:
|
|
883
|
+
// an invalidation queued meanwhile has already deleted the catalog.
|
|
884
|
+
warnOnFailure(Effect.suspend(() => generation === this.catalogGeneration(id)
|
|
885
|
+
? this.storeCatalog(id, snapshot)
|
|
886
|
+
: Effect.void), `connector "${id}" catalog persistence failed`));
|
|
814
887
|
}
|
|
815
888
|
return tools;
|
|
816
889
|
}
|
|
817
890
|
/**
|
|
818
|
-
*
|
|
819
|
-
*
|
|
820
|
-
*
|
|
891
|
+
* Whether a refresh may still enter the shared cache layers: its generation
|
|
892
|
+
* is current, and the flight it runs in has been neither abandoned nor
|
|
893
|
+
* outlived its bound. Asked at each publication point, since listing and
|
|
894
|
+
* snapshotting both yield, and a late result must never overwrite the one a
|
|
895
|
+
* fresh attempt published in the meantime.
|
|
821
896
|
*/
|
|
822
|
-
|
|
897
|
+
mayPublish(id, generation, flight) {
|
|
898
|
+
return (generation === this.catalogGeneration(id) &&
|
|
899
|
+
(!flight || (!flight.abandoned && Date.now() < flight.abandonAt)));
|
|
900
|
+
}
|
|
901
|
+
/**
|
|
902
|
+
* Publish one shared refresh before starting its connector work. The first
|
|
903
|
+
* caller owns the scope and signal, and the flight lives `boundMs` from now
|
|
904
|
+
* at most; every later caller joins the result without gaining access to
|
|
905
|
+
* that context. A flight found past its bound is abandoned here, and this
|
|
906
|
+
* caller starts the fresh attempt.
|
|
907
|
+
*/
|
|
908
|
+
startCatalogRefresh(id, generation, boundMs, work, ownerLeft = () => false) {
|
|
823
909
|
const existing = this.catalogRefreshes.get(id);
|
|
824
|
-
if (existing?.generation === generation)
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
})
|
|
838
|
-
.finally(() => {
|
|
910
|
+
if (existing?.generation === generation) {
|
|
911
|
+
if (Date.now() < existing.abandonAt)
|
|
912
|
+
return { flight: existing };
|
|
913
|
+
this.abandonCatalogRefresh(id, existing);
|
|
914
|
+
}
|
|
915
|
+
const flight = {
|
|
916
|
+
generation,
|
|
917
|
+
abandonAt: Date.now() + boundMs,
|
|
918
|
+
abandoned: false,
|
|
919
|
+
outcome: Deferred.makeUnsafe(),
|
|
920
|
+
};
|
|
921
|
+
this.catalogRefreshes.set(id, flight);
|
|
922
|
+
const owner = Effect.onExit(work(flight), (exit) => Effect.sync(() => {
|
|
839
923
|
if (this.catalogRefreshes.get(id) === flight) {
|
|
840
924
|
this.catalogRefreshes.delete(id);
|
|
841
925
|
}
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
926
|
+
// A failure that was the owner's alone is no answer for anyone
|
|
927
|
+
// else: its own cancellation (its signal, read here in its own
|
|
928
|
+
// request), or anything that arrives once the flight is past its
|
|
929
|
+
// bound. Joiners make a fresh attempt under their own deadlines.
|
|
930
|
+
const ownersAlone = Exit.isFailure(exit) &&
|
|
931
|
+
(flight.abandoned ||
|
|
932
|
+
Date.now() >= flight.abandonAt ||
|
|
933
|
+
ownerLeft());
|
|
934
|
+
// Last, because joined fibers resume inside this call.
|
|
935
|
+
Deferred.doneUnsafe(flight.outcome, ownersAlone ? Exit.fail(new AbandonedCatalogRefresh(id)) : exit);
|
|
936
|
+
}));
|
|
937
|
+
return { flight, owner };
|
|
938
|
+
}
|
|
939
|
+
/**
|
|
940
|
+
* Give up on a flight that outlived its bound. Later readers start a fresh
|
|
941
|
+
* attempt, its joiners are told to, and nothing its owner lists from now on
|
|
942
|
+
* reaches a cache. The owner's work is left alone: it belongs to another
|
|
943
|
+
* request, which nothing here may reach into, and it can no longer publish.
|
|
944
|
+
*/
|
|
945
|
+
abandonCatalogRefresh(id, flight) {
|
|
946
|
+
if (flight.abandoned)
|
|
947
|
+
return;
|
|
948
|
+
flight.abandoned = true;
|
|
949
|
+
if (this.catalogRefreshes.get(id) === flight) {
|
|
950
|
+
this.catalogRefreshes.delete(id);
|
|
850
951
|
}
|
|
851
|
-
|
|
952
|
+
this.opts.logger.warn(`[connecta] connector "${id}" catalog refresh outlived its bound; abandoning it for a fresh attempt.`);
|
|
953
|
+
Deferred.doneUnsafe(flight.outcome, Exit.fail(new AbandonedCatalogRefresh(id)));
|
|
954
|
+
}
|
|
955
|
+
/**
|
|
956
|
+
* Wait on another caller's flight, no longer than its bound. Succeeds with
|
|
957
|
+
* its tools, or with `undefined` when it will not answer for this caller —
|
|
958
|
+
* past its bound, when this waiter abandons it, or failed for its owner
|
|
959
|
+
* alone — so the caller makes a fresh attempt. The timer is this waiter's
|
|
960
|
+
* own: on workerd it is what keeps a request that waits on another alive,
|
|
961
|
+
* and bounded.
|
|
962
|
+
*/
|
|
963
|
+
joinCatalogRefresh(id, flight) {
|
|
964
|
+
return Effect.suspend(() => Deferred.await(flight.outcome).pipe(Effect.timeoutOption(Duration.millis(Math.max(0, flight.abandonAt - Date.now()))), Effect.flatMap((joined) => Option.isSome(joined)
|
|
965
|
+
? Effect.succeed(joined.value)
|
|
966
|
+
: Effect.sync(() => {
|
|
967
|
+
this.abandonCatalogRefresh(id, flight);
|
|
968
|
+
return undefined;
|
|
969
|
+
})), Effect.catch((error) => error instanceof AbandonedCatalogRefresh
|
|
970
|
+
? Effect.succeed(undefined)
|
|
971
|
+
: Effect.fail(error))));
|
|
852
972
|
}
|
|
853
973
|
/** Force a live listTools refresh and replace both catalog cache layers. */
|
|
854
974
|
async refreshTools(id, baseUrl, requestScope, callOptions = {}) {
|
|
@@ -857,8 +977,27 @@ export class Registry {
|
|
|
857
977
|
throw new Error(`Unknown connector "${id}"`);
|
|
858
978
|
if (connector.staticTools)
|
|
859
979
|
return connector.staticTools;
|
|
860
|
-
|
|
861
|
-
|
|
980
|
+
// A flight lives as long as its owner will wait for it. Past that, the
|
|
981
|
+
// owner may have answered and gone, and on workerd its I/O went with it.
|
|
982
|
+
const boundMs = normalizeTimeoutMs(callOptions.timeoutMs) ?? DEFAULT_PROBE_TIMEOUT_MS;
|
|
983
|
+
for (;;) {
|
|
984
|
+
const { flight, owner } = this.startCatalogRefresh(id, this.catalogGeneration(id), boundMs, (flight) => Effect.tryPromise({
|
|
985
|
+
try: () => this.refreshToolsWithContext(id, connector, this.contextFor(id, baseUrl, requestScope, callOptions), false, flight),
|
|
986
|
+
catch: (error) => error,
|
|
987
|
+
}), () => callOptions.signal?.aborted === true);
|
|
988
|
+
// The owner lists in its own request. A joiner waits through an edge of
|
|
989
|
+
// its own, under its own signal, and whatever it does with the tools
|
|
990
|
+
// happens after that, back in its request (see "Effect inside" in
|
|
991
|
+
// documentation/architecture.md). A flight that will not answer for it
|
|
992
|
+
// sends it round again, to join a fresh attempt or to own one.
|
|
993
|
+
if (owner)
|
|
994
|
+
return runEdge(owner);
|
|
995
|
+
const joined = await runEdge(this.joinCatalogRefresh(id, flight), {
|
|
996
|
+
signal: callOptions.signal,
|
|
997
|
+
});
|
|
998
|
+
if (joined)
|
|
999
|
+
return joined;
|
|
1000
|
+
}
|
|
862
1001
|
}
|
|
863
1002
|
observeCatalogAccess(id, state) {
|
|
864
1003
|
this.catalogAccess.set(id, {
|
|
@@ -872,44 +1011,47 @@ export class Registry {
|
|
|
872
1011
|
*/
|
|
873
1012
|
deferCatalogRefresh(id, baseUrl, expectedGeneration, options) {
|
|
874
1013
|
const connector = this.connectors.get(id);
|
|
875
|
-
|
|
1014
|
+
const defer = options.defer;
|
|
1015
|
+
if (!connector || connector.staticTools || !defer)
|
|
876
1016
|
return;
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
const
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
return Promise.reject(new Error(`Deferred catalog refresh of "${id}" was invalidated before it started.`));
|
|
891
|
-
}
|
|
892
|
-
ctx = this.contextFor(id, baseUrl, requestScope, {
|
|
893
|
-
signal,
|
|
894
|
-
timeoutMs: options.refreshTimeoutMs,
|
|
895
|
-
});
|
|
896
|
-
return this.refreshToolsWithContext(id, connector, ctx, true);
|
|
897
|
-
}, {
|
|
1017
|
+
// The refresh this request starts, if none of this generation is live.
|
|
1018
|
+
// Its scope is closed however it ends, deadline included, before the
|
|
1019
|
+
// flight completes.
|
|
1020
|
+
const refresh = (flight) => withDeadlineEffect((signal) => Effect.gen({ self: this }, function* () {
|
|
1021
|
+
const current = this.cache.get(id);
|
|
1022
|
+
if (current && current.exp > Date.now())
|
|
1023
|
+
return current.tools;
|
|
1024
|
+
if (expectedGeneration !== this.catalogGeneration(id) ||
|
|
1025
|
+
this.invalidated.has(id)) {
|
|
1026
|
+
return yield* Effect.fail(new Error(`Deferred catalog refresh of "${id}" was invalidated before it started.`));
|
|
1027
|
+
}
|
|
1028
|
+
const ctx = this.contextFor(id, baseUrl, {}, {
|
|
1029
|
+
signal,
|
|
898
1030
|
timeoutMs: options.refreshTimeoutMs,
|
|
899
|
-
timeoutError: new Error(`deferred catalog refresh of "${id}" timed out after ${options.refreshTimeoutMs}ms`),
|
|
900
|
-
}), async () => {
|
|
901
|
-
if (ctx)
|
|
902
|
-
await closeConnectorScope(connector, ctx, options.defer);
|
|
903
1031
|
});
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
this.opts.logger.warn(`[connecta] connector "${id}" deferred catalog refresh failed: ${msg(err)}`);
|
|
1032
|
+
yield* closeScopeOnExit(connector, ctx, defer);
|
|
1033
|
+
return yield* Effect.tryPromise({
|
|
1034
|
+
try: () => this.refreshToolsWithContext(id, connector, ctx, true, flight),
|
|
1035
|
+
catch: (error) => error,
|
|
909
1036
|
});
|
|
910
|
-
}
|
|
1037
|
+
}), {
|
|
1038
|
+
timeoutMs: options.refreshTimeoutMs,
|
|
1039
|
+
timeoutError: new Error(`deferred catalog refresh of "${id}" timed out after ${options.refreshTimeoutMs}ms`),
|
|
1040
|
+
});
|
|
1041
|
+
const logFailure = (err) => {
|
|
1042
|
+
this.opts.logger.warn(`[connecta] connector "${id}" deferred catalog refresh failed: ${msg(err)}`);
|
|
1043
|
+
};
|
|
1044
|
+
const { flight, owner } = this.startCatalogRefresh(id, expectedGeneration, options.refreshTimeoutMs, (flight) => Effect.scoped(refresh(flight)));
|
|
1045
|
+
if (owner) {
|
|
1046
|
+
// Runs past this response, under the runtime's waitUntil below.
|
|
1047
|
+
flight.deferredTail = detach(Effect.catchCause(owner, (cause) => Effect.sync(() => logFailure(Cause.squash(cause)))));
|
|
1048
|
+
}
|
|
1049
|
+
// A stale reader that joins a blocking refresh logs its failure once for
|
|
1050
|
+
// every reader after it, and waits through an edge of its own, no longer
|
|
1051
|
+
// than the flight's bound.
|
|
1052
|
+
flight.deferredTail ??= runEdge(this.joinCatalogRefresh(id, flight)).then(() => { }, logFailure);
|
|
911
1053
|
try {
|
|
912
|
-
|
|
1054
|
+
defer(flight.deferredTail);
|
|
913
1055
|
}
|
|
914
1056
|
catch (err) {
|
|
915
1057
|
this.opts.logger.warn(`[connecta] connector "${id}" deferred catalog refresh could not attach to the runtime: ${msg(err)}`);
|
|
@@ -937,7 +1079,7 @@ export class Registry {
|
|
|
937
1079
|
const generation = this.catalogGeneration(id);
|
|
938
1080
|
let persisted = null;
|
|
939
1081
|
try {
|
|
940
|
-
persisted = await this.readCatalog(id,
|
|
1082
|
+
persisted = await runOnPartition(this.readCatalog(id, now), this.opts);
|
|
941
1083
|
}
|
|
942
1084
|
catch (err) {
|
|
943
1085
|
this.opts.logger.warn(`[connecta] connector "${id}" catalog read failed: ${msg(err)}`);
|
|
@@ -1023,9 +1165,15 @@ export class Registry {
|
|
|
1023
1165
|
* WeakMap neither roots the request scope nor lets its connector context
|
|
1024
1166
|
* escape into another request; settled entries are also removed eagerly.
|
|
1025
1167
|
*
|
|
1168
|
+
* The read owns its signal, and each caller waits under its own: one that
|
|
1169
|
+
* is cancelled while others wait leaves with its own failure, and the read
|
|
1170
|
+
* is cancelled only when every caller has gone (src/runtime/shared-read.ts).
|
|
1171
|
+
*
|
|
1026
1172
|
* The deployment-wide flight below this layer coalesces the actual live
|
|
1027
|
-
* refresh across requests. Its
|
|
1028
|
-
* later callers join only its result, never its
|
|
1173
|
+
* refresh across requests. Its owner's context governs the listing, and its
|
|
1174
|
+
* owner's deadline bounds it; later callers join only its result, never its
|
|
1175
|
+
* request scope, and a failure that was the owner's alone sends them to a
|
|
1176
|
+
* fresh attempt rather than to them.
|
|
1029
1177
|
*/
|
|
1030
1178
|
async getTools(id, baseUrl, requestScope, callOptions = {}, readOptions) {
|
|
1031
1179
|
const connector = this.connectors.get(id);
|
|
@@ -1041,20 +1189,23 @@ export class Registry {
|
|
|
1041
1189
|
loads = new Map();
|
|
1042
1190
|
this.requestCatalogLoads.set(requestScope, loads);
|
|
1043
1191
|
}
|
|
1044
|
-
const
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1192
|
+
const requestLoads = loads;
|
|
1193
|
+
let load = requestLoads.get(id);
|
|
1194
|
+
if (!load) {
|
|
1195
|
+
const started = new SharedRead((signal) => this.loadTools(id, baseUrl, requestScope, { ...callOptions, signal }, readOptions),
|
|
1196
|
+
// Settled or cancelled, the read leaves the map before any caller
|
|
1197
|
+
// resumes, so the next one asks the caches afresh.
|
|
1198
|
+
() => {
|
|
1199
|
+
if (requestLoads.get(id) === started)
|
|
1200
|
+
requestLoads.delete(id);
|
|
1201
|
+
if (requestLoads.size === 0) {
|
|
1202
|
+
this.requestCatalogLoads.delete(requestScope);
|
|
1203
|
+
}
|
|
1204
|
+
});
|
|
1205
|
+
requestLoads.set(id, started);
|
|
1206
|
+
load = started;
|
|
1057
1207
|
}
|
|
1208
|
+
return runEdge(load.join(callOptions.signal));
|
|
1058
1209
|
}
|
|
1059
1210
|
async credentialDriftFor(id) {
|
|
1060
1211
|
const credential = this.connectors.get(id)?.credential;
|
|
@@ -1122,14 +1273,7 @@ export class Registry {
|
|
|
1122
1273
|
this.invalidated.add(id);
|
|
1123
1274
|
}
|
|
1124
1275
|
deleteStoredCatalog(id) {
|
|
1125
|
-
return this.enqueueCatalogMutation(id,
|
|
1126
|
-
try {
|
|
1127
|
-
await this.deleteCatalog(id);
|
|
1128
|
-
}
|
|
1129
|
-
catch (err) {
|
|
1130
|
-
this.opts.logger.warn(`[connecta] connector "${id}" catalog invalidation failed: ${msg(err)}`);
|
|
1131
|
-
}
|
|
1132
|
-
});
|
|
1276
|
+
return this.enqueueCatalogMutation(id, warnOnFailure(this.deleteCatalog(id), `connector "${id}" catalog invalidation failed`));
|
|
1133
1277
|
}
|
|
1134
1278
|
/** Drop a connector's cached tool list (e.g. after auth completes). */
|
|
1135
1279
|
invalidate(id) {
|
|
@@ -1186,15 +1330,17 @@ class ScopedRegistryView {
|
|
|
1186
1330
|
throw new Error(`Unknown connector "${args[0]}"`);
|
|
1187
1331
|
}
|
|
1188
1332
|
const tools = await registry.getTools(...args);
|
|
1189
|
-
const
|
|
1333
|
+
const guarded = this.scope.guardedToolAccess?.get(args[0]);
|
|
1334
|
+
const granted = this.scope.toolAccess?.get(args[0]) ?? guarded;
|
|
1190
1335
|
if (!granted)
|
|
1191
1336
|
return tools;
|
|
1192
1337
|
// Every consumer — search, describe, call_tool, and a program's
|
|
1193
1338
|
// connecta.call — resolves through this list, so an ungranted tool is
|
|
1194
1339
|
// indistinguishable from one the connector never had.
|
|
1195
|
-
const visible = tools.filter((tool) => granted.has(tool.name)
|
|
1340
|
+
const visible = tools.filter((tool) => granted.has(tool.name) &&
|
|
1341
|
+
(!guarded?.has(tool.name) || isExplicitlyReadOnly(tool)));
|
|
1196
1342
|
if (visible.length < granted.size) {
|
|
1197
|
-
const present = new Set(
|
|
1343
|
+
const present = new Set(tools.map((tool) => tool.name));
|
|
1198
1344
|
for (const name of granted) {
|
|
1199
1345
|
if (!present.has(name))
|
|
1200
1346
|
this.root.noteAbsentGrant(args[0], name);
|
|
@@ -1206,7 +1352,7 @@ class ScopedRegistryView {
|
|
|
1206
1352
|
const registry = this.registryFor(args[0]);
|
|
1207
1353
|
if (!registry)
|
|
1208
1354
|
throw new Error(`Unknown connector "${args[0]}"`);
|
|
1209
|
-
return registry.contextFor(...args);
|
|
1355
|
+
return attachCaller(registry.contextFor(...args), this.scope.caller);
|
|
1210
1356
|
}
|
|
1211
1357
|
admitCall(...args) {
|
|
1212
1358
|
const registry = this.registryFor(args[0]);
|