@zackbart/connecta 0.24.3 → 0.25.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 +58 -30
- package/CHANGELOG.md +317 -1
- package/README.md +5 -6
- package/dist/advertised-schema.js +53 -0
- package/dist/auth/downstream-oauth.js +490 -106
- package/dist/branding.d.ts +31 -2
- package/dist/branding.js +116 -8
- package/dist/call-admission.js +139 -113
- package/dist/catalog-service.js +141 -105
- package/dist/catalog.js +258 -33
- package/dist/concurrency.js +24 -23
- package/dist/connector-scope.js +6 -37
- package/dist/connectors/api.d.ts +3 -3
- package/dist/connectors/api.js +10 -2
- package/dist/connectors/guarded-fetch.js +34 -4
- package/dist/connectors/remote-mcp.d.ts +6 -5
- package/dist/connectors/remote-mcp.js +325 -245
- 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 +11 -3
- package/dist/errors.js +2 -1
- package/dist/execute.js +363 -257
- package/dist/executor-admission.js +67 -50
- package/dist/executors/quickjs-runtime.js +4 -2
- package/dist/executors/quickjs.js +251 -210
- package/dist/fix-prompt.js +37 -0
- package/dist/index.d.ts +11 -2
- package/dist/index.js +63 -21
- package/dist/invocation.js +324 -285
- package/dist/meta-tools.js +304 -114
- 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 +46 -1
- package/dist/operator-ui/setup-commands.js +53 -0
- package/dist/operator-ui/view.js +109 -2
- package/dist/program-source.js +205 -0
- package/dist/providers/cloudflare.d.ts +14 -2
- package/dist/providers/cloudflare.js +107 -16
- package/dist/providers/linear.d.ts +26 -4
- package/dist/providers/linear.js +19 -4
- package/dist/providers/mixpanel.d.ts +16 -3
- package/dist/providers/mixpanel.js +13 -2
- package/dist/providers/notion.d.ts +8 -1
- package/dist/providers/notion.js +85 -12
- package/dist/providers/revenuecat.d.ts +30 -4
- package/dist/providers/revenuecat.js +70 -5
- package/dist/providers/stripe.d.ts +10 -3
- package/dist/providers/stripe.js +30 -4
- package/dist/providers/vercel.js +12 -2
- package/dist/registry.d.ts +13 -5
- package/dist/registry.js +317 -258
- package/dist/routes/activity.js +120 -137
- package/dist/routes/credentials.js +174 -163
- package/dist/routes/mcp.js +129 -70
- package/dist/routes/oauth-management.js +60 -71
- package/dist/routes/oauth.js +44 -16
- package/dist/routes/operator.js +82 -0
- package/dist/routes/shared.d.ts +2 -0
- package/dist/routes/ui-data.js +210 -0
- package/dist/routes/ui.js +64 -32
- 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/storage.js +66 -0
- package/dist/server.js +111 -89
- package/dist/skills.js +1 -1
- package/dist/storage/file.js +39 -2
- package/dist/storage/memory.js +28 -16
- package/dist/timeout.js +17 -47
- package/dist/types.d.ts +64 -7
- package/dist/ui.d.ts +16 -2
- package/dist/ui.js +68 -176
- package/dist/url-safety.js +157 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/documentation/architecture.md +368 -182
- package/documentation/auth.md +277 -176
- package/documentation/code-mode.md +479 -329
- package/documentation/meta-tools.md +494 -417
- package/ethos.md +44 -47
- package/examples/worker/AGENTS.md +2 -1
- package/examples/worker/README.md +62 -14
- package/examples/worker/src/d1-storage.ts +116 -0
- package/examples/worker/src/index.ts +18 -21
- package/examples/worker/wrangler.jsonc +16 -4
- package/package.json +11 -4
- package/templates/node/.env.example +3 -3
- package/templates/node/AGENTS.md +5 -4
- package/templates/node/README.md +2 -1
- package/templates/node/package.json +1 -1
- package/templates/node/src/index.ts +23 -22
- 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 -92
- 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 -151
- 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/tool-safety.d.ts +0 -9
- package/documentation/call-admission.md +0 -158
- package/documentation/cloudflare.md +0 -471
- package/documentation/connector-guides.md +0 -176
- package/documentation/connectors.md +0 -431
- package/documentation/linear.md +0 -193
- package/documentation/mixpanel.md +0 -160
- package/documentation/notion.md +0 -308
- package/documentation/operations.md +0 -359
- package/documentation/operator-ui.md +0 -135
- package/documentation/optional-modules-upgrade.md +0 -243
- package/documentation/provider-conventions.md +0 -729
- package/documentation/request-admission.md +0 -204
- package/documentation/revenuecat.md +0 -305
- package/documentation/storage-and-credentials.md +0 -254
- package/documentation/stripe.md +0 -262
- package/documentation/upgrading.md +0 -768
- package/documentation/vercel.md +0 -241
package/dist/registry.js
CHANGED
|
@@ -1,19 +1,22 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { Cause, Clock, Deferred, Effect, Exit, 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 { closeScopeOnExit } from "./runtime/connector-scope.js";
|
|
12
|
+
import { detach, runEdge, withDeadlineEffect } from "./runtime/run.js";
|
|
13
|
+
import { Logger as LoggerService } from "./runtime/services.js";
|
|
14
|
+
import { runOnPartition, storageDelete, storageGet, storageSet, } from "./runtime/storage.js";
|
|
12
15
|
const ID_RE = /^[a-z0-9_-]+$/;
|
|
13
16
|
const DEFAULT_TTL_SECONDS = 300;
|
|
14
17
|
const DEFAULT_STALE_SECONDS = 3600;
|
|
15
18
|
const CATALOG_CHUNK_TTL_GRACE_SECONDS = 300;
|
|
16
|
-
const DEFAULT_MAX_RESULT_BYTES =
|
|
19
|
+
const DEFAULT_MAX_RESULT_BYTES = 24_000;
|
|
17
20
|
const encoder = new TextEncoder();
|
|
18
21
|
/**
|
|
19
22
|
* Split `"<connectorId>.<toolName>"` on the first dot. Connector ids contain
|
|
@@ -67,6 +70,22 @@ export function resolveMaxResultBytes(value, inherited) {
|
|
|
67
70
|
: inherited;
|
|
68
71
|
}
|
|
69
72
|
const CATALOG_CHUNK_IO_CONCURRENCY = 4;
|
|
73
|
+
/**
|
|
74
|
+
* Run `operation` over every chunk index or chunk with the fixed chunk I/O
|
|
75
|
+
* bound, settling each: one failure neither interrupts its siblings nor
|
|
76
|
+
* leaves their storage calls running behind the caller. Results keep input
|
|
77
|
+
* order, so the first failure by index is the one a caller reports.
|
|
78
|
+
*/
|
|
79
|
+
function forEachChunk(items, operation) {
|
|
80
|
+
return Effect.forEach(items, (item, index) => Effect.result(operation(item, index)), { concurrency: CATALOG_CHUNK_IO_CONCURRENCY });
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* A persisted-catalog mutation that logs its storage failure rather than
|
|
84
|
+
* failing, so its turn in the mutation queue always ends.
|
|
85
|
+
*/
|
|
86
|
+
function warnOnFailure(mutation, failed) {
|
|
87
|
+
return Effect.catch(mutation, (err) => LoggerService.use((logger) => Effect.sync(() => logger.warn(`[connecta] ${failed}: ${msg(err)}`))));
|
|
88
|
+
}
|
|
70
89
|
function namespaced(storage, prefix) {
|
|
71
90
|
return {
|
|
72
91
|
get: (k) => storage.get(prefix + k),
|
|
@@ -77,6 +96,11 @@ function namespaced(storage, prefix) {
|
|
|
77
96
|
list: async (keyPrefix) => (await storage.list(prefix + keyPrefix)).map((key) => key.slice(prefix.length)),
|
|
78
97
|
}
|
|
79
98
|
: {}),
|
|
99
|
+
...(storage.compareAndSet
|
|
100
|
+
? {
|
|
101
|
+
compareAndSet: (k, expected, next, o) => storage.compareAndSet(prefix + k, expected, next, o),
|
|
102
|
+
}
|
|
103
|
+
: {}),
|
|
80
104
|
};
|
|
81
105
|
}
|
|
82
106
|
const MAX_PERSONAL_REGISTRIES = 1_024;
|
|
@@ -101,6 +125,8 @@ export class Registry {
|
|
|
101
125
|
invalidated = new Set();
|
|
102
126
|
/** Per-connector epoch preventing a pre-invalidation refresh from publishing. */
|
|
103
127
|
catalogGenerations = new Map();
|
|
128
|
+
// The last queued mutation per connector, as the turn its own request
|
|
129
|
+
// completes once its storage work is done; see enqueueCatalogMutation.
|
|
104
130
|
/** Serialize persisted catalog set/delete operations within this isolate. */
|
|
105
131
|
catalogMutations = new Map();
|
|
106
132
|
/** Same-request cold loads share one promise without retaining the request.
|
|
@@ -181,9 +207,15 @@ export class Registry {
|
|
|
181
207
|
}
|
|
182
208
|
if (this.personalRegistries.size >= MAX_PERSONAL_REGISTRIES) {
|
|
183
209
|
// Eviction must not reset a live rolling budget or orphan queued calls.
|
|
184
|
-
|
|
210
|
+
// Nor may it drop catalog work in flight: a replacement for the same
|
|
211
|
+
// principal starts with no generations and no mutation queue, so a
|
|
212
|
+
// refresh the evicted registry finishes later would persist a listing
|
|
213
|
+
// that a credential change on the replacement had just deleted.
|
|
214
|
+
const idle = [...this.personalRegistries].find(([, candidate]) => [...candidate.callAdmission.values()].every(admission => admission.isIdle()) &&
|
|
215
|
+
candidate.catalogRefreshes.size === 0 &&
|
|
216
|
+
candidate.catalogMutations.size === 0);
|
|
185
217
|
if (!idle) {
|
|
186
|
-
throw new Error("Personal connector capacity is exhausted; retry after calls
|
|
218
|
+
throw new Error("Personal connector capacity is exhausted; retry after calls, rolling budgets, and catalog refreshes drain.");
|
|
187
219
|
}
|
|
188
220
|
idle[1].closeCallAdmission();
|
|
189
221
|
this.personalRegistries.delete(idle[0]);
|
|
@@ -346,7 +378,7 @@ export class Registry {
|
|
|
346
378
|
getAll: readValues,
|
|
347
379
|
};
|
|
348
380
|
}
|
|
349
|
-
|
|
381
|
+
const context = {
|
|
350
382
|
storage: namespaced(this.opts.storage, `conn:${id}:`),
|
|
351
383
|
logger: this.opts.logger,
|
|
352
384
|
baseUrl,
|
|
@@ -354,6 +386,11 @@ export class Registry {
|
|
|
354
386
|
requestScope,
|
|
355
387
|
...callOptions,
|
|
356
388
|
};
|
|
389
|
+
// Downstream OAuth state is sealed under the vault key, bound to this
|
|
390
|
+
// connector and owner. The sealer rides beside the context, not on it.
|
|
391
|
+
return this.opts.credentialVault
|
|
392
|
+
? attachOAuthSealer(context, vaultOAuthSealer(this.opts.credentialVault, id, this.opts.credentialOwner, this.opts.logger))
|
|
393
|
+
: context;
|
|
357
394
|
}
|
|
358
395
|
admitCall(id, input) {
|
|
359
396
|
const admission = this.callAdmission.get(id);
|
|
@@ -446,48 +483,65 @@ export class Registry {
|
|
|
446
483
|
for (const registry of this.personalRegistries.values())
|
|
447
484
|
registry.closeCallAdmission();
|
|
448
485
|
}
|
|
449
|
-
/**
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
486
|
+
/**
|
|
487
|
+
* Reserve capacity and write one ASCII paging envelope in this runtime.
|
|
488
|
+
*
|
|
489
|
+
* `chunks[0]` lands on `<prefix><key>` and `chunks[n]` on `<prefix><key>#<n>`
|
|
490
|
+
* — the layout get_result reads back, so a page fetches only the chunks it
|
|
491
|
+
* covers instead of the whole stored result (issue #540). Chunking is a
|
|
492
|
+
* read-cost decision, not a capacity one: however many keys an envelope
|
|
493
|
+
* occupies, it is one stash entry charged its total ASCII length.
|
|
494
|
+
*/
|
|
495
|
+
stashResult(key, chunks, ttlSeconds, prefix = "results:") {
|
|
496
|
+
// The fiber runs synchronously up to its first storage call. With nothing
|
|
497
|
+
// expired to reclaim, that is the first chunk write, after the
|
|
498
|
+
// reservation: a concurrent stash already sees this one's charge.
|
|
499
|
+
return runOnPartition(Effect.gen({ self: this }, function* () {
|
|
500
|
+
const maxBytes = this.opts.results?.maxStashBytes ?? 8 * 1024 * 1024;
|
|
501
|
+
const maxEntries = this.opts.results?.maxStashEntries ?? 64;
|
|
502
|
+
// The paging envelope is ASCII, so its string length is its stored byte count.
|
|
503
|
+
const bytes = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
|
|
504
|
+
if (bytes > maxBytes || maxEntries === 0)
|
|
505
|
+
return false;
|
|
506
|
+
const now = yield* Clock.currentTimeMillis;
|
|
507
|
+
for (const [oldKey, entry] of this.resultStash) {
|
|
508
|
+
if (entry.busy || entry.expiresAt > now)
|
|
509
|
+
continue;
|
|
510
|
+
entry.busy = true;
|
|
511
|
+
// TTL alone cannot reclaim a lazy backend. Keep the charge until
|
|
512
|
+
// deletion succeeds, including writes which persisted before throwing;
|
|
513
|
+
// a failed delete fails this stash, and the entry waits for the next.
|
|
514
|
+
yield* Effect.gen({ self: this }, function* () {
|
|
515
|
+
for (const staleKey of entry.keys)
|
|
516
|
+
yield* storageDelete(staleKey);
|
|
517
|
+
this.resultStash.delete(oldKey);
|
|
518
|
+
this.resultStashBytes -= entry.bytes;
|
|
519
|
+
}).pipe(Effect.ensuring(Effect.sync(() => { entry.busy = false; })));
|
|
468
520
|
}
|
|
469
|
-
|
|
521
|
+
if (this.resultStash.size >= maxEntries || this.resultStashBytes + bytes > maxBytes)
|
|
522
|
+
return false;
|
|
523
|
+
const fullKey = prefix + key;
|
|
524
|
+
const keys = chunks.map((_, index) => index === 0 ? fullKey : `${fullKey}#${index}`);
|
|
525
|
+
const entry = { keys, bytes, expiresAt: Infinity, busy: true };
|
|
526
|
+
this.resultStash.set(fullKey, entry);
|
|
527
|
+
this.resultStashBytes += bytes;
|
|
528
|
+
// Trailing chunks first: the header chunk is what makes an id readable, so
|
|
529
|
+
// a write that fails midway leaves no envelope pointing at absent chunks.
|
|
530
|
+
yield* Effect.gen(function* () {
|
|
531
|
+
for (let index = keys.length - 1; index >= 0; index--) {
|
|
532
|
+
yield* storageSet(keys[index], chunks[index], { ttlSeconds });
|
|
533
|
+
}
|
|
534
|
+
}).pipe(
|
|
535
|
+
// A failed write expires at once, still charged, so the next stash
|
|
536
|
+
// reclaims every key it may have persisted before failing.
|
|
537
|
+
Effect.onExit((exit) => Clock.currentTimeMillis.pipe(Effect.map((writtenAt) => {
|
|
538
|
+
entry.expiresAt = Exit.isSuccess(exit)
|
|
539
|
+
? writtenAt + ttlSeconds * 1000
|
|
540
|
+
: 0;
|
|
470
541
|
entry.busy = false;
|
|
471
|
-
}
|
|
472
|
-
}
|
|
473
|
-
if (this.resultStash.size >= maxEntries || this.resultStashBytes + bytes > maxBytes)
|
|
474
|
-
return false;
|
|
475
|
-
const fullKey = prefix + key;
|
|
476
|
-
const entry = { bytes, expiresAt: Infinity, busy: true };
|
|
477
|
-
this.resultStash.set(fullKey, entry);
|
|
478
|
-
this.resultStashBytes += bytes;
|
|
479
|
-
try {
|
|
480
|
-
await this.opts.storage.set(fullKey, value, { ttlSeconds });
|
|
481
|
-
entry.expiresAt = Date.now() + ttlSeconds * 1000;
|
|
542
|
+
}))));
|
|
482
543
|
return true;
|
|
483
|
-
}
|
|
484
|
-
catch (error) {
|
|
485
|
-
entry.expiresAt = 0;
|
|
486
|
-
throw error;
|
|
487
|
-
}
|
|
488
|
-
finally {
|
|
489
|
-
entry.busy = false;
|
|
490
|
-
}
|
|
544
|
+
}), this.opts);
|
|
491
545
|
}
|
|
492
546
|
/**
|
|
493
547
|
* Storage namespaced to the meta-tool result store (`results:` prefix), kept
|
|
@@ -582,135 +636,145 @@ export class Registry {
|
|
|
582
636
|
}
|
|
583
637
|
return chunks;
|
|
584
638
|
}
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
}
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
}
|
|
595
|
-
const manifest = this.validCatalogManifest(parsed);
|
|
596
|
-
if (!manifest) {
|
|
597
|
-
this.opts.logger.warn(`[connecta] connector "${id}" catalog manifest is invalid; ignoring persisted catalog.`);
|
|
598
|
-
return null;
|
|
599
|
-
}
|
|
600
|
-
if (manifest.staleUntil <= now)
|
|
601
|
-
return null;
|
|
602
|
-
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)));
|
|
603
|
-
const chunks = [];
|
|
604
|
-
let chunkBytes = 0;
|
|
605
|
-
for (const [index, result] of chunkReads.entries()) {
|
|
606
|
-
if (result.status === "rejected")
|
|
607
|
-
throw result.reason;
|
|
608
|
-
const chunk = result.value;
|
|
609
|
-
if (chunk === null) {
|
|
610
|
-
this.opts.logger.warn(`[connecta] connector "${id}" catalog chunk ${index + 1}/${manifest.chunkCount} is missing; ignoring persisted catalog.`);
|
|
639
|
+
// The persisted catalog's one reader: the manifest at `catalog:<id>`, then
|
|
640
|
+
// the chunks it names. A storage failure fails the effect, and the caller
|
|
641
|
+
// logs it; a manifest or chunk that is invalid, missing, torn, or does not
|
|
642
|
+
// match its fingerprint is logged here and read as no catalog, because a
|
|
643
|
+
// catalog is complete or it is nothing.
|
|
644
|
+
readCatalog(id, now) {
|
|
645
|
+
return Effect.gen({ self: this }, function* () {
|
|
646
|
+
const raw = yield* storageGet(this.catalogKey(id));
|
|
647
|
+
if (!raw)
|
|
611
648
|
return null;
|
|
649
|
+
let parsed;
|
|
650
|
+
try {
|
|
651
|
+
parsed = JSON.parse(raw);
|
|
612
652
|
}
|
|
613
|
-
|
|
614
|
-
chunkBytes += byteLength;
|
|
615
|
-
if (byteLength > MAX_CATALOG_CHUNK_BYTES ||
|
|
616
|
-
chunkBytes > manifest.byteCount) {
|
|
617
|
-
this.opts.logger.warn(`[connecta] connector "${id}" catalog chunk bounds do not match its manifest; ignoring persisted catalog.`);
|
|
653
|
+
catch {
|
|
618
654
|
return null;
|
|
619
655
|
}
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
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
|
-
|
|
656
|
+
const logger = yield* LoggerService;
|
|
657
|
+
const manifest = this.validCatalogManifest(parsed);
|
|
658
|
+
if (!manifest) {
|
|
659
|
+
logger.warn(`[connecta] connector "${id}" catalog manifest is invalid; ignoring persisted catalog.`);
|
|
660
|
+
return null;
|
|
661
|
+
}
|
|
662
|
+
if (manifest.staleUntil <= now)
|
|
663
|
+
return null;
|
|
664
|
+
const chunkReads = yield* forEachChunk(Array.from({ length: manifest.chunkCount }, (_, index) => index), (index) => storageGet(this.catalogChunkKey(id, manifest.revision, index)));
|
|
665
|
+
const chunks = [];
|
|
666
|
+
let chunkBytes = 0;
|
|
667
|
+
for (const [index, read] of chunkReads.entries()) {
|
|
668
|
+
if (Result.isFailure(read))
|
|
669
|
+
return yield* Effect.fail(read.failure);
|
|
670
|
+
const chunk = read.success;
|
|
671
|
+
if (chunk === null) {
|
|
672
|
+
logger.warn(`[connecta] connector "${id}" catalog chunk ${index + 1}/${manifest.chunkCount} is missing; ignoring persisted catalog.`);
|
|
673
|
+
return null;
|
|
674
|
+
}
|
|
675
|
+
const byteLength = encoder.encode(chunk).byteLength;
|
|
676
|
+
chunkBytes += byteLength;
|
|
677
|
+
if (byteLength > MAX_CATALOG_CHUNK_BYTES ||
|
|
678
|
+
chunkBytes > manifest.byteCount) {
|
|
679
|
+
logger.warn(`[connecta] connector "${id}" catalog chunk bounds do not match its manifest; ignoring persisted catalog.`);
|
|
680
|
+
return null;
|
|
681
|
+
}
|
|
682
|
+
chunks.push(chunk);
|
|
683
|
+
}
|
|
684
|
+
const serializedTools = chunks.join("");
|
|
685
|
+
const stored = yield* Effect.tryPromise({
|
|
686
|
+
try: () => fingerprintSerializedCatalog(serializedTools),
|
|
687
|
+
catch: (error) => error,
|
|
688
|
+
});
|
|
689
|
+
if (stored.byteLength !== manifest.byteCount ||
|
|
690
|
+
stored.fingerprint !== manifest.revision) {
|
|
691
|
+
logger.warn(`[connecta] connector "${id}" catalog fingerprint mismatch; ignoring persisted catalog.`);
|
|
692
|
+
return null;
|
|
693
|
+
}
|
|
694
|
+
let tools;
|
|
695
|
+
try {
|
|
696
|
+
tools = JSON.parse(serializedTools);
|
|
697
|
+
}
|
|
698
|
+
catch {
|
|
699
|
+
logger.warn(`[connecta] connector "${id}" catalog chunks are torn; ignoring persisted catalog.`);
|
|
700
|
+
return null;
|
|
701
|
+
}
|
|
702
|
+
if (!Array.isArray(tools) || !this.validCatalogTools(tools)) {
|
|
703
|
+
logger.warn(`[connecta] connector "${id}" catalog chunks contain invalid tools; ignoring persisted catalog.`);
|
|
704
|
+
return null;
|
|
705
|
+
}
|
|
706
|
+
if (tools.length !== manifest.toolCount) {
|
|
707
|
+
logger.warn(`[connecta] connector "${id}" catalog tool count does not match its manifest; ignoring persisted catalog.`);
|
|
708
|
+
return null;
|
|
709
|
+
}
|
|
710
|
+
return {
|
|
711
|
+
tools,
|
|
712
|
+
fingerprint: stored.fingerprint,
|
|
713
|
+
fetchedAt: manifest.fetchedAt,
|
|
714
|
+
expiresAt: manifest.expiresAt,
|
|
715
|
+
staleUntil: manifest.staleUntil,
|
|
716
|
+
};
|
|
681
717
|
});
|
|
682
718
|
}
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
}
|
|
701
|
-
let firstError;
|
|
702
|
-
for (let index = 0; index < manifest.chunkCount; index++) {
|
|
703
|
-
try {
|
|
704
|
-
await this.opts.storage.delete(this.catalogChunkKey(id, manifest.revision, index));
|
|
719
|
+
// Persist one complete catalog: every chunk, then the manifest. A snapshot
|
|
720
|
+
// only reaches here after the tool and byte ceilings accepted it whole.
|
|
721
|
+
storeCatalog(id, snapshot) {
|
|
722
|
+
return Effect.gen({ self: this }, function* () {
|
|
723
|
+
if (!this.persistToolCatalog)
|
|
724
|
+
return;
|
|
725
|
+
const fetchedAt = yield* Clock.currentTimeMillis;
|
|
726
|
+
const expiresAt = fetchedAt + this.ttlMs;
|
|
727
|
+
const staleUntil = expiresAt + this.staleMs;
|
|
728
|
+
const ttlSeconds = Math.max(60, Math.ceil((this.ttlMs + this.staleMs) / 1000));
|
|
729
|
+
const chunks = this.splitCatalogChunks(snapshot);
|
|
730
|
+
const chunkWrites = yield* forEachChunk(chunks, (chunk, index) => storageSet(this.catalogChunkKey(id, snapshot.fingerprint, index), chunk, {
|
|
731
|
+
ttlSeconds: ttlSeconds + CATALOG_CHUNK_TTL_GRACE_SECONDS,
|
|
732
|
+
}));
|
|
733
|
+
for (const write of chunkWrites) {
|
|
734
|
+
if (Result.isFailure(write))
|
|
735
|
+
return yield* Effect.fail(write.failure);
|
|
705
736
|
}
|
|
706
|
-
|
|
707
|
-
|
|
737
|
+
// The manifest is the only publication point. A failed/partial chunk write
|
|
738
|
+
// therefore leaves the previous manifest authoritative (or no catalog);
|
|
739
|
+
// unreachable chunks carry a bounded TTL and require no prefix scan.
|
|
740
|
+
const manifest = {
|
|
741
|
+
version: 2,
|
|
742
|
+
revision: snapshot.fingerprint,
|
|
743
|
+
toolCount: snapshot.tools.length,
|
|
744
|
+
byteCount: snapshot.serializedBytes.byteLength,
|
|
745
|
+
chunkCount: chunks.length,
|
|
746
|
+
fetchedAt,
|
|
747
|
+
expiresAt,
|
|
748
|
+
staleUntil,
|
|
749
|
+
};
|
|
750
|
+
yield* storageSet(this.catalogKey(id), JSON.stringify(manifest), {
|
|
751
|
+
ttlSeconds,
|
|
752
|
+
});
|
|
753
|
+
});
|
|
754
|
+
}
|
|
755
|
+
// Remove the persisted catalog: the manifest always, then the chunks it
|
|
756
|
+
// names. Every delete is attempted; the effect fails with the manifest
|
|
757
|
+
// read's error first, then the first chunk delete's.
|
|
758
|
+
deleteCatalog(id) {
|
|
759
|
+
return Effect.gen({ self: this }, function* () {
|
|
760
|
+
const read = yield* Effect.result(storageGet(this.catalogKey(id)));
|
|
761
|
+
const manifest = Result.isSuccess(read)
|
|
762
|
+
? this.parseCatalogManifest(read.success)
|
|
763
|
+
: null;
|
|
764
|
+
// The root is authoritative, so attempt its deletion even when the
|
|
765
|
+
// best-effort read needed for physical chunk cleanup failed.
|
|
766
|
+
yield* storageDelete(this.catalogKey(id));
|
|
767
|
+
let firstError;
|
|
768
|
+
for (let index = 0; manifest && index < manifest.chunkCount; index++) {
|
|
769
|
+
const deleted = yield* Effect.result(storageDelete(this.catalogChunkKey(id, manifest.revision, index)));
|
|
770
|
+
if (Result.isFailure(deleted))
|
|
771
|
+
firstError ??= deleted;
|
|
708
772
|
}
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
773
|
+
if (Result.isFailure(read))
|
|
774
|
+
return yield* Effect.fail(read.failure);
|
|
775
|
+
if (firstError)
|
|
776
|
+
return yield* Effect.fail(firstError.failure);
|
|
777
|
+
});
|
|
714
778
|
}
|
|
715
779
|
catalogGeneration(id) {
|
|
716
780
|
return this.catalogGenerations.get(id) ?? 0;
|
|
@@ -723,18 +787,29 @@ export class Registry {
|
|
|
723
787
|
* old refresh can finish its storage.set after a credential change deletes
|
|
724
788
|
* the catalog and resurrect the pre-change listing.
|
|
725
789
|
*/
|
|
726
|
-
enqueueCatalogMutation(id, operation) {
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
790
|
+
async enqueueCatalogMutation(id, operation) {
|
|
791
|
+
// Mutations run in arrival order, each taking its turn from the one
|
|
792
|
+
// queued before it. That one may belong to another request, and
|
|
793
|
+
// completing a Deferred resumes its waiters inside the completing call,
|
|
794
|
+
// so the wait is an edge of its own and this request's storage work
|
|
795
|
+
// starts only after it. (Effect's Semaphore would do neither: it resumes
|
|
796
|
+
// a waiter from the releasing fiber, and a newcomer can take the permit
|
|
797
|
+
// before a waiter that queued earlier.) An operation logs its own storage
|
|
798
|
+
// failure, so a turn always ends.
|
|
799
|
+
const previous = this.catalogMutations.get(id);
|
|
800
|
+
const turn = Deferred.makeUnsafe();
|
|
801
|
+
this.catalogMutations.set(id, turn);
|
|
802
|
+
try {
|
|
803
|
+
if (previous)
|
|
804
|
+
await runEdge(Deferred.await(previous));
|
|
805
|
+
await runOnPartition(operation, this.opts);
|
|
806
|
+
}
|
|
807
|
+
finally {
|
|
808
|
+
if (this.catalogMutations.get(id) === turn) {
|
|
733
809
|
this.catalogMutations.delete(id);
|
|
734
810
|
}
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
return next;
|
|
811
|
+
Deferred.doneUnsafe(turn, Exit.void);
|
|
812
|
+
}
|
|
738
813
|
}
|
|
739
814
|
async refreshToolsWithContext(id, connector, ctx, skipPublicationWhenAborted = false) {
|
|
740
815
|
const generation = this.catalogGeneration(id);
|
|
@@ -787,16 +862,12 @@ export class Registry {
|
|
|
787
862
|
});
|
|
788
863
|
this.invalidated.delete(id);
|
|
789
864
|
if (shouldPersist) {
|
|
790
|
-
await this.enqueueCatalogMutation(id,
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
}
|
|
796
|
-
catch (err) {
|
|
797
|
-
this.opts.logger.warn(`[connecta] connector "${id}" catalog persistence failed: ${msg(err)}`);
|
|
798
|
-
}
|
|
799
|
-
});
|
|
865
|
+
await this.enqueueCatalogMutation(id,
|
|
866
|
+
// The generation is read when the turn comes, not when it is queued:
|
|
867
|
+
// an invalidation queued meanwhile has already deleted the catalog.
|
|
868
|
+
warnOnFailure(Effect.suspend(() => generation === this.catalogGeneration(id)
|
|
869
|
+
? this.storeCatalog(id, snapshot)
|
|
870
|
+
: Effect.void), `connector "${id}" catalog persistence failed`));
|
|
800
871
|
}
|
|
801
872
|
return tools;
|
|
802
873
|
}
|
|
@@ -805,36 +876,23 @@ export class Registry {
|
|
|
805
876
|
* The first caller owns the scope and deadline; every later caller joins the
|
|
806
877
|
* result without gaining access to that context.
|
|
807
878
|
*/
|
|
808
|
-
startCatalogRefresh(id, generation,
|
|
879
|
+
startCatalogRefresh(id, generation, work) {
|
|
809
880
|
const existing = this.catalogRefreshes.get(id);
|
|
810
881
|
if (existing?.generation === generation)
|
|
811
|
-
return existing;
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
let flight;
|
|
819
|
-
const promise = work
|
|
820
|
-
.finally(async () => {
|
|
821
|
-
if (finish)
|
|
822
|
-
await finish();
|
|
823
|
-
})
|
|
824
|
-
.finally(() => {
|
|
882
|
+
return { flight: existing };
|
|
883
|
+
const flight = {
|
|
884
|
+
generation,
|
|
885
|
+
outcome: Deferred.makeUnsafe(),
|
|
886
|
+
};
|
|
887
|
+
this.catalogRefreshes.set(id, flight);
|
|
888
|
+
const owner = Effect.onExit(work, (exit) => Effect.sync(() => {
|
|
825
889
|
if (this.catalogRefreshes.get(id) === flight) {
|
|
826
890
|
this.catalogRefreshes.delete(id);
|
|
827
891
|
}
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
Promise.resolve(start()).then(resolveWork, rejectWork);
|
|
833
|
-
}
|
|
834
|
-
catch (err) {
|
|
835
|
-
rejectWork(err);
|
|
836
|
-
}
|
|
837
|
-
return flight;
|
|
892
|
+
// Last, because joined fibers resume inside this call.
|
|
893
|
+
Deferred.doneUnsafe(flight.outcome, exit);
|
|
894
|
+
}));
|
|
895
|
+
return { flight, owner };
|
|
838
896
|
}
|
|
839
897
|
/** Force a live listTools refresh and replace both catalog cache layers. */
|
|
840
898
|
async refreshTools(id, baseUrl, requestScope, callOptions = {}) {
|
|
@@ -843,8 +901,14 @@ export class Registry {
|
|
|
843
901
|
throw new Error(`Unknown connector "${id}"`);
|
|
844
902
|
if (connector.staticTools)
|
|
845
903
|
return connector.staticTools;
|
|
846
|
-
const
|
|
847
|
-
|
|
904
|
+
const { flight, owner } = this.startCatalogRefresh(id, this.catalogGeneration(id), Effect.tryPromise({
|
|
905
|
+
try: () => this.refreshToolsWithContext(id, connector, this.contextFor(id, baseUrl, requestScope, callOptions)),
|
|
906
|
+
catch: (error) => error,
|
|
907
|
+
}));
|
|
908
|
+
// The owner lists in its own request. A joiner waits through an edge of
|
|
909
|
+
// its own, and whatever it does with the tools happens after that, back in
|
|
910
|
+
// its request (see "Effect inside" in documentation/architecture.md).
|
|
911
|
+
return runEdge(owner ?? Deferred.await(flight.outcome));
|
|
848
912
|
}
|
|
849
913
|
observeCatalogAccess(id, state) {
|
|
850
914
|
this.catalogAccess.set(id, {
|
|
@@ -858,44 +922,46 @@ export class Registry {
|
|
|
858
922
|
*/
|
|
859
923
|
deferCatalogRefresh(id, baseUrl, expectedGeneration, options) {
|
|
860
924
|
const connector = this.connectors.get(id);
|
|
861
|
-
|
|
925
|
+
const defer = options.defer;
|
|
926
|
+
if (!connector || connector.staticTools || !defer)
|
|
862
927
|
return;
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
const
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
return Promise.reject(new Error(`Deferred catalog refresh of "${id}" was invalidated before it started.`));
|
|
877
|
-
}
|
|
878
|
-
ctx = this.contextFor(id, baseUrl, requestScope, {
|
|
879
|
-
signal,
|
|
880
|
-
timeoutMs: options.refreshTimeoutMs,
|
|
881
|
-
});
|
|
882
|
-
return this.refreshToolsWithContext(id, connector, ctx, true);
|
|
883
|
-
}, {
|
|
928
|
+
// The refresh this request starts, if none of this generation is live.
|
|
929
|
+
// Its scope is closed however it ends, deadline included, before the
|
|
930
|
+
// flight completes.
|
|
931
|
+
const refresh = withDeadlineEffect((signal) => Effect.gen({ self: this }, function* () {
|
|
932
|
+
const current = this.cache.get(id);
|
|
933
|
+
if (current && current.exp > Date.now())
|
|
934
|
+
return current.tools;
|
|
935
|
+
if (expectedGeneration !== this.catalogGeneration(id) ||
|
|
936
|
+
this.invalidated.has(id)) {
|
|
937
|
+
return yield* Effect.fail(new Error(`Deferred catalog refresh of "${id}" was invalidated before it started.`));
|
|
938
|
+
}
|
|
939
|
+
const ctx = this.contextFor(id, baseUrl, {}, {
|
|
940
|
+
signal,
|
|
884
941
|
timeoutMs: options.refreshTimeoutMs,
|
|
885
|
-
timeoutError: new Error(`deferred catalog refresh of "${id}" timed out after ${options.refreshTimeoutMs}ms`),
|
|
886
|
-
}), async () => {
|
|
887
|
-
if (ctx)
|
|
888
|
-
await closeConnectorScope(connector, ctx, options.defer);
|
|
889
942
|
});
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
this.opts.logger.warn(`[connecta] connector "${id}" deferred catalog refresh failed: ${msg(err)}`);
|
|
943
|
+
yield* closeScopeOnExit(connector, ctx, defer);
|
|
944
|
+
return yield* Effect.tryPromise({
|
|
945
|
+
try: () => this.refreshToolsWithContext(id, connector, ctx, true),
|
|
946
|
+
catch: (error) => error,
|
|
895
947
|
});
|
|
896
|
-
}
|
|
948
|
+
}), {
|
|
949
|
+
timeoutMs: options.refreshTimeoutMs,
|
|
950
|
+
timeoutError: new Error(`deferred catalog refresh of "${id}" timed out after ${options.refreshTimeoutMs}ms`),
|
|
951
|
+
});
|
|
952
|
+
const logFailure = (err) => {
|
|
953
|
+
this.opts.logger.warn(`[connecta] connector "${id}" deferred catalog refresh failed: ${msg(err)}`);
|
|
954
|
+
};
|
|
955
|
+
const { flight, owner } = this.startCatalogRefresh(id, expectedGeneration, Effect.scoped(refresh));
|
|
956
|
+
if (owner) {
|
|
957
|
+
// Runs past this response, under the runtime's waitUntil below.
|
|
958
|
+
flight.deferredTail = detach(Effect.catchCause(owner, (cause) => Effect.sync(() => logFailure(Cause.squash(cause)))));
|
|
959
|
+
}
|
|
960
|
+
// A stale reader that joins a blocking refresh logs its failure once for
|
|
961
|
+
// every reader after it, and waits through an edge of its own.
|
|
962
|
+
flight.deferredTail ??= runEdge(Deferred.await(flight.outcome)).then(() => { }, logFailure);
|
|
897
963
|
try {
|
|
898
|
-
|
|
964
|
+
defer(flight.deferredTail);
|
|
899
965
|
}
|
|
900
966
|
catch (err) {
|
|
901
967
|
this.opts.logger.warn(`[connecta] connector "${id}" deferred catalog refresh could not attach to the runtime: ${msg(err)}`);
|
|
@@ -923,7 +989,7 @@ export class Registry {
|
|
|
923
989
|
const generation = this.catalogGeneration(id);
|
|
924
990
|
let persisted = null;
|
|
925
991
|
try {
|
|
926
|
-
persisted = await this.readCatalog(id,
|
|
992
|
+
persisted = await runOnPartition(this.readCatalog(id, now), this.opts);
|
|
927
993
|
}
|
|
928
994
|
catch (err) {
|
|
929
995
|
this.opts.logger.warn(`[connecta] connector "${id}" catalog read failed: ${msg(err)}`);
|
|
@@ -1108,14 +1174,7 @@ export class Registry {
|
|
|
1108
1174
|
this.invalidated.add(id);
|
|
1109
1175
|
}
|
|
1110
1176
|
deleteStoredCatalog(id) {
|
|
1111
|
-
return this.enqueueCatalogMutation(id,
|
|
1112
|
-
try {
|
|
1113
|
-
await this.deleteCatalog(id);
|
|
1114
|
-
}
|
|
1115
|
-
catch (err) {
|
|
1116
|
-
this.opts.logger.warn(`[connecta] connector "${id}" catalog invalidation failed: ${msg(err)}`);
|
|
1117
|
-
}
|
|
1118
|
-
});
|
|
1177
|
+
return this.enqueueCatalogMutation(id, warnOnFailure(this.deleteCatalog(id), `connector "${id}" catalog invalidation failed`));
|
|
1119
1178
|
}
|
|
1120
1179
|
/** Drop a connector's cached tool list (e.g. after auth completes). */
|
|
1121
1180
|
invalidate(id) {
|
|
@@ -1201,8 +1260,8 @@ class ScopedRegistryView {
|
|
|
1201
1260
|
}
|
|
1202
1261
|
return registry.admitCall(...args);
|
|
1203
1262
|
}
|
|
1204
|
-
stashResult(key,
|
|
1205
|
-
return this.root.stashResult(key,
|
|
1263
|
+
stashResult(key, chunks, ttlSeconds) {
|
|
1264
|
+
return this.root.stashResult(key, chunks, ttlSeconds, this.scope.subjectKey ? `subject:${this.scope.subjectKey}:` : "results:");
|
|
1206
1265
|
}
|
|
1207
1266
|
resultsStorage() {
|
|
1208
1267
|
return this.scope.subjectKey
|