@zackbart/connecta 0.24.4 → 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.
Files changed (107) hide show
  1. package/AGENTS.md +40 -10
  2. package/CHANGELOG.md +253 -0
  3. package/dist/advertised-schema.js +53 -0
  4. package/dist/auth/downstream-oauth.js +490 -106
  5. package/dist/call-admission.js +139 -113
  6. package/dist/catalog-service.js +141 -105
  7. package/dist/catalog.js +258 -33
  8. package/dist/concurrency.js +24 -23
  9. package/dist/connector-scope.js +6 -37
  10. package/dist/connectors/api.d.ts +2 -2
  11. package/dist/connectors/remote-mcp.d.ts +6 -5
  12. package/dist/connectors/remote-mcp.js +317 -241
  13. package/dist/credential-contract.d.ts +9 -0
  14. package/dist/credential-rules.js +2 -2
  15. package/dist/credentials.d.ts +6 -1
  16. package/dist/credentials.js +45 -3
  17. package/dist/execute.js +363 -257
  18. package/dist/executor-admission.js +67 -50
  19. package/dist/executors/quickjs-runtime.js +4 -2
  20. package/dist/executors/quickjs.js +251 -210
  21. package/dist/fix-prompt.js +37 -0
  22. package/dist/index.d.ts +10 -1
  23. package/dist/index.js +51 -20
  24. package/dist/invocation.js +324 -285
  25. package/dist/meta-tools.js +206 -92
  26. package/dist/oauth-callback-outcome.js +94 -0
  27. package/dist/oauth-sealing.js +29 -0
  28. package/dist/operator-ui/fix-prompts.js +87 -0
  29. package/dist/operator-ui/generated.js +2 -2
  30. package/dist/operator-ui/model.d.ts +46 -1
  31. package/dist/operator-ui/setup-commands.js +53 -0
  32. package/dist/operator-ui/view.js +38 -2
  33. package/dist/program-source.js +205 -0
  34. package/dist/providers/notion.js +2 -2
  35. package/dist/providers/revenuecat.js +32 -5
  36. package/dist/providers/stripe.d.ts +3 -2
  37. package/dist/providers/vercel.js +1 -1
  38. package/dist/registry.d.ts +1 -1
  39. package/dist/registry.js +305 -260
  40. package/dist/routes/activity.js +120 -137
  41. package/dist/routes/credentials.js +174 -163
  42. package/dist/routes/mcp.js +129 -70
  43. package/dist/routes/oauth-management.js +60 -71
  44. package/dist/routes/oauth.js +44 -16
  45. package/dist/routes/operator.js +82 -0
  46. package/dist/routes/shared.d.ts +2 -0
  47. package/dist/routes/ui-data.js +210 -0
  48. package/dist/routes/ui.js +64 -32
  49. package/dist/runtime/admission.js +43 -0
  50. package/dist/runtime/call-admission.js +67 -0
  51. package/dist/runtime/connector-scope.js +72 -0
  52. package/dist/runtime/run.js +174 -0
  53. package/dist/runtime/services.js +130 -0
  54. package/dist/runtime/storage.js +66 -0
  55. package/dist/server.js +111 -89
  56. package/dist/skills.js +1 -1
  57. package/dist/storage/file.js +39 -2
  58. package/dist/storage/memory.js +28 -16
  59. package/dist/timeout.js +17 -47
  60. package/dist/types.d.ts +27 -7
  61. package/dist/ui.d.ts +16 -2
  62. package/dist/ui.js +50 -166
  63. package/dist/url-safety.js +157 -0
  64. package/dist/version.d.ts +1 -1
  65. package/dist/version.js +1 -1
  66. package/documentation/architecture.md +184 -10
  67. package/documentation/auth.md +80 -0
  68. package/documentation/code-mode.md +69 -24
  69. package/documentation/meta-tools.md +164 -27
  70. package/ethos.md +44 -47
  71. package/examples/worker/README.md +51 -5
  72. package/examples/worker/src/d1-storage.ts +116 -0
  73. package/examples/worker/src/index.ts +6 -6
  74. package/examples/worker/wrangler.jsonc +16 -4
  75. package/package.json +11 -3
  76. package/templates/node/package.json +1 -1
  77. package/dist/auth/downstream-oauth.d.ts +0 -200
  78. package/dist/catalog-fingerprint.d.ts +0 -19
  79. package/dist/catalog-limits.d.ts +0 -9
  80. package/dist/catalog-service.d.ts +0 -263
  81. package/dist/catalog.d.ts +0 -87
  82. package/dist/concurrency.d.ts +0 -3
  83. package/dist/connectors/guarded-fetch.d.ts +0 -96
  84. package/dist/credential-rules.d.ts +0 -85
  85. package/dist/execute.d.ts +0 -154
  86. package/dist/executor-result.d.ts +0 -10
  87. package/dist/executors/quickjs-child.d.ts +0 -1
  88. package/dist/executors/quickjs-protocol.d.ts +0 -67
  89. package/dist/favicon.d.ts +0 -1
  90. package/dist/identity.d.ts +0 -4
  91. package/dist/invocation.d.ts +0 -74
  92. package/dist/mcp-result.d.ts +0 -8
  93. package/dist/meta-tools.d.ts +0 -129
  94. package/dist/operator-ui/generated.d.ts +0 -2
  95. package/dist/operator-ui/view.d.ts +0 -188
  96. package/dist/result-shapes.d.ts +0 -13
  97. package/dist/retired-toolkits.d.ts +0 -2
  98. package/dist/routes/activity.d.ts +0 -2
  99. package/dist/routes/credentials.d.ts +0 -2
  100. package/dist/routes/mcp.d.ts +0 -10
  101. package/dist/routes/oauth-management.d.ts +0 -2
  102. package/dist/routes/oauth.d.ts +0 -2
  103. package/dist/routes/ui.d.ts +0 -2
  104. package/dist/server.d.ts +0 -9
  105. package/dist/skills.d.ts +0 -44
  106. package/dist/timeout.d.ts +0 -21
  107. package/dist/tool-safety.d.ts +0 -9
package/dist/registry.js CHANGED
@@ -1,19 +1,22 @@
1
- import { closeConnectorScope, } from "./connector-scope.js";
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 { withDeadline } from "./timeout.js";
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 = 50_000;
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
- const idle = [...this.personalRegistries].find(([, candidate]) => [...candidate.callAdmission.values()].every(admission => admission.isIdle()));
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 and rolling budgets drain.");
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
- return {
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);
@@ -455,53 +492,56 @@ export class Registry {
455
492
  * read-cost decision, not a capacity one: however many keys an envelope
456
493
  * occupies, it is one stash entry charged its total ASCII length.
457
494
  */
458
- async stashResult(key, chunks, ttlSeconds, prefix = "results:") {
459
- const maxBytes = this.opts.results?.maxStashBytes ?? 8 * 1024 * 1024;
460
- const maxEntries = this.opts.results?.maxStashEntries ?? 64;
461
- // The paging envelope is ASCII, so its string length is its stored byte count.
462
- const bytes = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
463
- if (bytes > maxBytes || maxEntries === 0)
464
- return false;
465
- const now = Date.now();
466
- for (const [oldKey, entry] of this.resultStash) {
467
- if (entry.busy || entry.expiresAt > now)
468
- continue;
469
- entry.busy = true;
470
- try {
471
- // TTL alone cannot reclaim a lazy backend. Keep the charge until deletion
472
- // succeeds, including writes which persisted before throwing.
473
- for (const staleKey of entry.keys)
474
- await this.opts.storage.delete(staleKey);
475
- this.resultStash.delete(oldKey);
476
- this.resultStashBytes -= entry.bytes;
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; })));
477
520
  }
478
- finally {
479
- entry.busy = false;
480
- }
481
- }
482
- if (this.resultStash.size >= maxEntries || this.resultStashBytes + bytes > maxBytes)
483
- return false;
484
- const fullKey = prefix + key;
485
- const keys = chunks.map((_, index) => index === 0 ? fullKey : `${fullKey}#${index}`);
486
- const entry = { keys, bytes, expiresAt: Infinity, busy: true };
487
- this.resultStash.set(fullKey, entry);
488
- this.resultStashBytes += bytes;
489
- try {
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;
490
528
  // Trailing chunks first: the header chunk is what makes an id readable, so
491
529
  // a write that fails midway leaves no envelope pointing at absent chunks.
492
- for (let index = keys.length - 1; index >= 0; index--) {
493
- await this.opts.storage.set(keys[index], chunks[index], { ttlSeconds });
494
- }
495
- entry.expiresAt = Date.now() + ttlSeconds * 1000;
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;
541
+ entry.busy = false;
542
+ }))));
496
543
  return true;
497
- }
498
- catch (error) {
499
- entry.expiresAt = 0;
500
- throw error;
501
- }
502
- finally {
503
- entry.busy = false;
504
- }
544
+ }), this.opts);
505
545
  }
506
546
  /**
507
547
  * Storage namespaced to the meta-tool result store (`results:` prefix), kept
@@ -596,135 +636,145 @@ export class Registry {
596
636
  }
597
637
  return chunks;
598
638
  }
599
- async readCatalog(id, raw, now) {
600
- if (!raw)
601
- return null;
602
- let parsed;
603
- try {
604
- parsed = JSON.parse(raw);
605
- }
606
- catch {
607
- return null;
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.`);
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)
625
648
  return null;
649
+ let parsed;
650
+ try {
651
+ parsed = JSON.parse(raw);
626
652
  }
627
- const byteLength = encoder.encode(chunk).byteLength;
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.`);
653
+ catch {
632
654
  return null;
633
655
  }
634
- chunks.push(chunk);
635
- }
636
- const serializedTools = chunks.join("");
637
- const stored = await fingerprintSerializedCatalog(serializedTools);
638
- if (stored.byteLength !== manifest.byteCount ||
639
- stored.fingerprint !== manifest.revision) {
640
- this.opts.logger.warn(`[connecta] connector "${id}" catalog fingerprint mismatch; ignoring persisted catalog.`);
641
- return null;
642
- }
643
- let tools;
644
- try {
645
- tools = JSON.parse(serializedTools);
646
- }
647
- catch {
648
- this.opts.logger.warn(`[connecta] connector "${id}" catalog chunks are torn; ignoring persisted catalog.`);
649
- return null;
650
- }
651
- if (!Array.isArray(tools) || !this.validCatalogTools(tools)) {
652
- this.opts.logger.warn(`[connecta] connector "${id}" catalog chunks contain invalid tools; ignoring persisted catalog.`);
653
- return null;
654
- }
655
- if (tools.length !== manifest.toolCount) {
656
- this.opts.logger.warn(`[connecta] connector "${id}" catalog tool count does not match its manifest; ignoring persisted catalog.`);
657
- return null;
658
- }
659
- return {
660
- tools,
661
- fingerprint: stored.fingerprint,
662
- fetchedAt: manifest.fetchedAt,
663
- expiresAt: manifest.expiresAt,
664
- staleUntil: manifest.staleUntil,
665
- };
666
- }
667
- async storeCatalog(id, snapshot) {
668
- if (!this.persistToolCatalog)
669
- return;
670
- const fetchedAt = Date.now();
671
- const expiresAt = fetchedAt + this.ttlMs;
672
- const staleUntil = expiresAt + this.staleMs;
673
- const ttlSeconds = Math.max(60, Math.ceil((this.ttlMs + this.staleMs) / 1000));
674
- const chunks = this.splitCatalogChunks(snapshot);
675
- const chunkWrites = await mapSettledWithConcurrency(chunks, CATALOG_CHUNK_IO_CONCURRENCY, (chunk, index) => this.opts.storage.set(this.catalogChunkKey(id, snapshot.fingerprint, index), chunk, { ttlSeconds: ttlSeconds + CATALOG_CHUNK_TTL_GRACE_SECONDS }));
676
- for (const result of chunkWrites) {
677
- if (result.status === "rejected")
678
- throw result.reason;
679
- }
680
- // The manifest is the only publication point. A failed/partial chunk write
681
- // therefore leaves the previous manifest authoritative (or no catalog);
682
- // unreachable chunks carry a bounded TTL and require no prefix scan.
683
- const manifest = {
684
- version: 2,
685
- revision: snapshot.fingerprint,
686
- toolCount: snapshot.tools.length,
687
- byteCount: snapshot.serializedBytes.byteLength,
688
- chunkCount: chunks.length,
689
- fetchedAt,
690
- expiresAt,
691
- staleUntil,
692
- };
693
- await this.opts.storage.set(this.catalogKey(id), JSON.stringify(manifest), {
694
- ttlSeconds,
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
+ };
695
717
  });
696
718
  }
697
- async deleteCatalog(id) {
698
- let raw = null;
699
- let readError;
700
- try {
701
- raw = await this.opts.storage.get(this.catalogKey(id));
702
- }
703
- catch (err) {
704
- readError = err;
705
- }
706
- const manifest = this.parseCatalogManifest(raw);
707
- // The root is authoritative, so attempt its deletion even when the
708
- // best-effort read needed for physical chunk cleanup failed.
709
- await this.opts.storage.delete(this.catalogKey(id));
710
- if (!manifest) {
711
- if (readError)
712
- throw readError;
713
- return;
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));
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);
719
736
  }
720
- catch (err) {
721
- firstError ??= err;
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;
722
772
  }
723
- }
724
- if (readError)
725
- throw readError;
726
- if (firstError)
727
- throw firstError;
773
+ if (Result.isFailure(read))
774
+ return yield* Effect.fail(read.failure);
775
+ if (firstError)
776
+ return yield* Effect.fail(firstError.failure);
777
+ });
728
778
  }
729
779
  catalogGeneration(id) {
730
780
  return this.catalogGenerations.get(id) ?? 0;
@@ -737,18 +787,29 @@ export class Registry {
737
787
  * old refresh can finish its storage.set after a credential change deletes
738
788
  * the catalog and resurrect the pre-change listing.
739
789
  */
740
- enqueueCatalogMutation(id, operation) {
741
- const previous = this.catalogMutations.get(id) ?? Promise.resolve();
742
- const next = previous.catch(() => { }).then(operation);
743
- this.catalogMutations.set(id, next);
744
- void next
745
- .finally(() => {
746
- if (this.catalogMutations.get(id) === next) {
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) {
747
809
  this.catalogMutations.delete(id);
748
810
  }
749
- })
750
- .catch(() => { });
751
- return next;
811
+ Deferred.doneUnsafe(turn, Exit.void);
812
+ }
752
813
  }
753
814
  async refreshToolsWithContext(id, connector, ctx, skipPublicationWhenAborted = false) {
754
815
  const generation = this.catalogGeneration(id);
@@ -801,16 +862,12 @@ export class Registry {
801
862
  });
802
863
  this.invalidated.delete(id);
803
864
  if (shouldPersist) {
804
- await this.enqueueCatalogMutation(id, async () => {
805
- if (generation !== this.catalogGeneration(id))
806
- return;
807
- try {
808
- await this.storeCatalog(id, snapshot);
809
- }
810
- catch (err) {
811
- this.opts.logger.warn(`[connecta] connector "${id}" catalog persistence failed: ${msg(err)}`);
812
- }
813
- });
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`));
814
871
  }
815
872
  return tools;
816
873
  }
@@ -819,36 +876,23 @@ export class Registry {
819
876
  * The first caller owns the scope and deadline; every later caller joins the
820
877
  * result without gaining access to that context.
821
878
  */
822
- startCatalogRefresh(id, generation, start, finish) {
879
+ startCatalogRefresh(id, generation, work) {
823
880
  const existing = this.catalogRefreshes.get(id);
824
881
  if (existing?.generation === generation)
825
- return existing;
826
- let resolveWork;
827
- let rejectWork;
828
- const work = new Promise((resolve, reject) => {
829
- resolveWork = resolve;
830
- rejectWork = reject;
831
- });
832
- let flight;
833
- const promise = work
834
- .finally(async () => {
835
- if (finish)
836
- await finish();
837
- })
838
- .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(() => {
839
889
  if (this.catalogRefreshes.get(id) === flight) {
840
890
  this.catalogRefreshes.delete(id);
841
891
  }
842
- });
843
- flight = { generation, promise };
844
- this.catalogRefreshes.set(id, flight);
845
- try {
846
- Promise.resolve(start()).then(resolveWork, rejectWork);
847
- }
848
- catch (err) {
849
- rejectWork(err);
850
- }
851
- return flight;
892
+ // Last, because joined fibers resume inside this call.
893
+ Deferred.doneUnsafe(flight.outcome, exit);
894
+ }));
895
+ return { flight, owner };
852
896
  }
853
897
  /** Force a live listTools refresh and replace both catalog cache layers. */
854
898
  async refreshTools(id, baseUrl, requestScope, callOptions = {}) {
@@ -857,8 +901,14 @@ export class Registry {
857
901
  throw new Error(`Unknown connector "${id}"`);
858
902
  if (connector.staticTools)
859
903
  return connector.staticTools;
860
- const ctx = this.contextFor(id, baseUrl, requestScope, callOptions);
861
- return this.startCatalogRefresh(id, this.catalogGeneration(id), () => this.refreshToolsWithContext(id, connector, ctx)).promise;
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));
862
912
  }
863
913
  observeCatalogAccess(id, state) {
864
914
  this.catalogAccess.set(id, {
@@ -872,44 +922,46 @@ export class Registry {
872
922
  */
873
923
  deferCatalogRefresh(id, baseUrl, expectedGeneration, options) {
874
924
  const connector = this.connectors.get(id);
875
- if (!connector || connector.staticTools || !options.defer)
925
+ const defer = options.defer;
926
+ if (!connector || connector.staticTools || !defer)
876
927
  return;
877
- let flight = this.catalogRefreshes.get(id);
878
- if (flight?.generation !== expectedGeneration)
879
- flight = undefined;
880
- if (!flight) {
881
- const requestScope = {};
882
- let ctx;
883
- flight = this.startCatalogRefresh(id, expectedGeneration, () => withDeadline((signal) => {
884
- const current = this.cache.get(id);
885
- if (current && current.exp > Date.now()) {
886
- return Promise.resolve(current.tools);
887
- }
888
- if (expectedGeneration !== this.catalogGeneration(id) ||
889
- this.invalidated.has(id)) {
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
- }, {
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,
898
941
  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
942
  });
904
- }
905
- if (!flight.deferredTail) {
906
- // Attach the rejection handler before handing the tail to waitUntil.
907
- flight.deferredTail = flight.promise.then(() => { }, (err) => {
908
- 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,
909
947
  });
910
- }
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);
911
963
  try {
912
- options.defer(flight.deferredTail);
964
+ defer(flight.deferredTail);
913
965
  }
914
966
  catch (err) {
915
967
  this.opts.logger.warn(`[connecta] connector "${id}" deferred catalog refresh could not attach to the runtime: ${msg(err)}`);
@@ -937,7 +989,7 @@ export class Registry {
937
989
  const generation = this.catalogGeneration(id);
938
990
  let persisted = null;
939
991
  try {
940
- persisted = await this.readCatalog(id, await this.opts.storage.get(this.catalogKey(id)), now);
992
+ persisted = await runOnPartition(this.readCatalog(id, now), this.opts);
941
993
  }
942
994
  catch (err) {
943
995
  this.opts.logger.warn(`[connecta] connector "${id}" catalog read failed: ${msg(err)}`);
@@ -1122,14 +1174,7 @@ export class Registry {
1122
1174
  this.invalidated.add(id);
1123
1175
  }
1124
1176
  deleteStoredCatalog(id) {
1125
- return this.enqueueCatalogMutation(id, async () => {
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
- });
1177
+ return this.enqueueCatalogMutation(id, warnOnFailure(this.deleteCatalog(id), `connector "${id}" catalog invalidation failed`));
1133
1178
  }
1134
1179
  /** Drop a connector's cached tool list (e.g. after auth completes). */
1135
1180
  invalidate(id) {