@zackbart/connecta 0.38.0 → 0.40.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 (180) hide show
  1. package/CHANGELOG.md +91 -0
  2. package/README.md +3 -2
  3. package/bin/connecta.mjs +4 -8
  4. package/bin/version.mjs +1 -1
  5. package/dist/access-tokens.js +6 -4
  6. package/dist/agent-routes.js +231 -0
  7. package/dist/auth/downstream-oauth/consent.js +519 -0
  8. package/dist/auth/downstream-oauth/grant-store.js +439 -0
  9. package/dist/auth/downstream-oauth/refresh-coordinator.js +562 -0
  10. package/dist/auth/downstream-oauth/refresh-records.js +447 -0
  11. package/dist/auth/downstream-oauth/sdk-hooks.js +242 -0
  12. package/dist/auth/downstream-oauth.js +66 -2382
  13. package/dist/auth/native.js +37 -0
  14. package/dist/auth/oauth-grant.js +13 -0
  15. package/dist/auth/static-oauth.js +153 -131
  16. package/dist/auth-elicitation.js +7 -1
  17. package/dist/authorization-handoff.js +11 -5
  18. package/dist/catalog-cache.js +219 -59
  19. package/dist/catalog-policy.js +18 -0
  20. package/dist/catalog-service.js +284 -47
  21. package/dist/catalog.js +18 -2
  22. package/dist/config.d.ts +16 -11
  23. package/dist/config.js +9 -6
  24. package/dist/connector-caller.d.ts +1 -1
  25. package/dist/connector-health.js +59 -0
  26. package/dist/connector-status.js +117 -0
  27. package/dist/connectors/api-connector.d.ts +7 -2
  28. package/dist/connectors/api-connector.js +7 -4
  29. package/dist/connectors/negotiation-cache.js +22 -4
  30. package/dist/connectors/remote-mcp/call.js +164 -0
  31. package/dist/connectors/remote-mcp/catalog.js +278 -0
  32. package/dist/connectors/remote-mcp/config.js +263 -0
  33. package/dist/connectors/remote-mcp/connection.js +682 -0
  34. package/dist/connectors/remote-mcp/errors.js +488 -0
  35. package/dist/connectors/remote-mcp/oauth.js +473 -0
  36. package/dist/connectors/remote-mcp/resources.js +473 -0
  37. package/dist/connectors/remote-mcp/runtime.js +1 -0
  38. package/dist/connectors/remote-mcp/transport.js +555 -0
  39. package/dist/connectors/remote-mcp.js +62 -2863
  40. package/dist/credential-contract.d.ts +23 -0
  41. package/dist/credential-reads.js +17 -0
  42. package/dist/credential-verification.js +53 -0
  43. package/dist/credentials.d.ts +7 -0
  44. package/dist/credentials.js +60 -25
  45. package/dist/describe-config.d.ts +12 -2
  46. package/dist/describe-config.js +2 -0
  47. package/dist/discovery-health.js +199 -0
  48. package/dist/downstream-input.js +2 -0
  49. package/dist/errors.d.ts +55 -17
  50. package/dist/errors.js +95 -41
  51. package/dist/execute.js +152 -114
  52. package/dist/executor-result.js +3 -1
  53. package/dist/guest-functions.js +83 -0
  54. package/dist/guest-runtime.js +60 -19
  55. package/dist/guest-types.d.ts +19 -5
  56. package/dist/http/argument-problems.js +35 -0
  57. package/dist/http/errors.js +5 -4
  58. package/dist/http/fields.js +192 -13
  59. package/dist/http/hatch-response.js +73 -0
  60. package/dist/http/hatch.js +88 -23
  61. package/dist/http/oauth.js +3 -2
  62. package/dist/http/provider.js +32 -4
  63. package/dist/http/safety.js +8 -0
  64. package/dist/http/tools.js +44 -23
  65. package/dist/index.d.ts +1 -1
  66. package/dist/index.js +9 -2
  67. package/dist/invocation.js +26 -8
  68. package/dist/membership.d.ts +13 -5
  69. package/dist/membership.js +34 -2
  70. package/dist/meta-tools.js +54 -31
  71. package/dist/native-recovery.js +4 -16
  72. package/dist/native-tools.js +5 -2
  73. package/dist/oauth-sealing.js +10 -0
  74. package/dist/operation-vocabulary.js +135 -13
  75. package/dist/operator-record.d.ts +2 -2
  76. package/dist/operator-record.js +1 -0
  77. package/dist/operator-ui/contract.d.ts +10 -0
  78. package/dist/operator-ui/generated.js +2 -2
  79. package/dist/operator-ui/model.d.ts +22 -1
  80. package/dist/provider-call-facts.js +6 -2
  81. package/dist/providers/_shared/rest/operation-index.js +48 -50
  82. package/dist/providers/_shared/rest/value-safety.js +80 -0
  83. package/dist/providers/breeze/api.js +7 -1
  84. package/dist/providers/breeze/skill.generated.js +1 -1
  85. package/dist/providers/breeze/tools.js +4 -3
  86. package/dist/providers/ccb/api.js +2 -1
  87. package/dist/providers/ccb/index.js +5 -0
  88. package/dist/providers/ccb/skill.generated.js +1 -1
  89. package/dist/providers/cloudflare/api.js +8 -1
  90. package/dist/providers/cloudflare/skill.generated.js +1 -1
  91. package/dist/providers/docs/lib/schemas.js +24 -22
  92. package/dist/providers/docs/safety.js +2 -0
  93. package/dist/providers/docs/skill.generated.js +1 -1
  94. package/dist/providers/docs/tools.js +2 -4
  95. package/dist/providers/drive/api.js +1 -0
  96. package/dist/providers/drive/lib/contracts.js +8 -5
  97. package/dist/providers/drive/skill.generated.js +1 -1
  98. package/dist/providers/drive/tools.js +11 -16
  99. package/dist/providers/forms/api.js +1 -0
  100. package/dist/providers/forms/skill.generated.js +1 -1
  101. package/dist/providers/forms/tools.js +10 -7
  102. package/dist/providers/github/api.js +1 -0
  103. package/dist/providers/github/lib/discovery.js +1 -1
  104. package/dist/providers/github/skill.generated.js +1 -1
  105. package/dist/providers/github/tools.js +61 -17
  106. package/dist/providers/gmail/lib/schemas.js +60 -50
  107. package/dist/providers/gmail/skill.generated.js +1 -1
  108. package/dist/providers/gmail/tools.js +7 -5
  109. package/dist/providers/granola/index.js +2 -0
  110. package/dist/providers/granola/skill.generated.js +1 -1
  111. package/dist/providers/overflow/api.js +7 -1
  112. package/dist/providers/overflow/skill.generated.js +1 -1
  113. package/dist/providers/planning-center/api.js +3 -1
  114. package/dist/providers/planning-center/lib/constants.d.ts +1 -1
  115. package/dist/providers/planning-center/skill.generated.js +1 -1
  116. package/dist/providers/quickbooks/safety.js +80 -39
  117. package/dist/providers/quickbooks/skill.generated.js +1 -1
  118. package/dist/providers/sheets/api.js +1 -0
  119. package/dist/providers/sheets/skill.generated.js +1 -1
  120. package/dist/providers/sheets/tools.js +13 -14
  121. package/dist/providers/slides/skill.generated.js +1 -1
  122. package/dist/providers/slides/tools.js +15 -13
  123. package/dist/providers/stripe/skill.generated.js +1 -1
  124. package/dist/providers/tithely/api.js +3 -1
  125. package/dist/providers/tithely/skill.generated.js +1 -1
  126. package/dist/providers/vercel/api.js +1 -1
  127. package/dist/providers/vercel/skill.generated.js +1 -1
  128. package/dist/providers/vercel/tools.js +4 -3
  129. package/dist/registry.d.ts +10 -4
  130. package/dist/registry.js +41 -13
  131. package/dist/routes/credentials.js +129 -60
  132. package/dist/routes/dispatch.js +5 -1
  133. package/dist/routes/mcp.js +30 -14
  134. package/dist/routes/operator.js +2 -2
  135. package/dist/routes/shared.d.ts +6 -1
  136. package/dist/routes/shared.js +7 -1
  137. package/dist/routes/ui-config.js +2 -2
  138. package/dist/routes/ui-data.js +126 -20
  139. package/dist/routes/ui.js +16 -0
  140. package/dist/runtime/call.js +7 -2
  141. package/dist/runtime/native-storage.js +12 -0
  142. package/dist/runtime/request-reads.js +207 -0
  143. package/dist/runtime/request-scope.js +16 -0
  144. package/dist/runtime/storage.js +12 -0
  145. package/dist/skills.js +37 -20
  146. package/dist/storage/keys.js +53 -3
  147. package/dist/storage/memory.js +8 -2
  148. package/dist/storage/sql-native.js +53 -1
  149. package/dist/storage/sql.js +12 -2
  150. package/dist/storage-digest.js +32 -0
  151. package/dist/timeout.js +8 -0
  152. package/dist/types.d.ts +33 -0
  153. package/dist/usage-guide.js +16 -35
  154. package/dist/validate.d.ts +3 -0
  155. package/dist/validate.js +220 -158
  156. package/dist/version.d.ts +1 -1
  157. package/dist/version.js +1 -1
  158. package/dist/write-outcome.js +0 -3
  159. package/documentation/README.md +13 -13
  160. package/documentation/architecture.md +385 -20
  161. package/documentation/auth.md +233 -64
  162. package/documentation/code-mode.md +122 -39
  163. package/documentation/deploying.md +6 -3
  164. package/documentation/integrating.md +16 -2
  165. package/documentation/meta-tools.md +240 -49
  166. package/documentation/migration-0.33.md +5 -1
  167. package/documentation/migration-0.34.md +3 -1
  168. package/documentation/operator-tests.md +1 -1
  169. package/documentation/operator-ui.md +105 -12
  170. package/documentation/pool-membership-upgrade.md +44 -0
  171. package/documentation/providers.md +105 -0
  172. package/examples/worker/AGENTS.md +10 -10
  173. package/examples/worker/README.md +4 -3
  174. package/examples/worker/src/connecta.config.ts +1 -0
  175. package/guides/connecting-agents.md +2 -1
  176. package/guides/how-it-works.md +4 -2
  177. package/package.json +1 -1
  178. package/templates/node/AGENTS.md +13 -12
  179. package/templates/node/package.json +1 -1
  180. package/templates/node/src/connecta.config.ts +1 -0
@@ -9,14 +9,19 @@ import { describedTools } from "../described.js";
9
9
  import { Effect } from "effect";
10
10
  import { bindActivityRequest } from "../activity-request.js";
11
11
  import { resolveDiscoveryConcurrency } from "../concurrency.js";
12
+ import { credentialReads } from "../credential-reads.js";
12
13
  import { credentialTestRule, describeUndeclaredCredentialFields, storedCredentialShape } from "../credential-rules.js";
14
+ import { readVerification } from "../credential-verification.js";
13
15
  import { failureRecord, failureStatus, logFailure, recordedToolName, statusFailure } from "../operator-record.js";
14
16
  import { closeScope } from "../runtime/connector-scope.js";
17
+ import { requestReads } from "../runtime/request-scope.js";
15
18
  import { withDeadlineEffect } from "../runtime/run.js";
16
19
  import { uiProblemFor } from "../ui.js";
17
20
  import { CONNECTA_VERSION } from "../version.js";
21
+ import { prepareStatusSlot, readStatusObservation, storeStatusObservation, } from "../connector-status.js";
18
22
  const DETAIL_TIMEOUT_MS = 30_000;
19
23
  const DETAIL_TIMED_OUT = "Connection details timed out. Retry this connection.";
24
+ const OBSERVE_TIMED_OUT = "Connector did not answer before the operator page's deadline.";
20
25
  function attempt(operation) {
21
26
  return Effect.tryPromise({ try: operation, catch: (error) => error });
22
27
  }
@@ -38,6 +43,35 @@ export function uiData(registry, baseUrl, options) {
38
43
  const logger = registry.contextFor(id, baseUrl, requestScope).logger;
39
44
  logFailure(logger, "operator status", failureRecord({ connector: id, state }, failure), state === "auth_required" ? "info" : "warn");
40
45
  };
46
+ // A stored row: the observation's state and problem, and no tools. They
47
+ // stay in the catalog cache (INV-8), and the browser's live read, which
48
+ // follows in its own request, shows them. Reading them here could connect,
49
+ // and the row's scope would wait for that listing to end.
50
+ // The credential's stored verification is local, so it is read fresh here too.
51
+ const observeStored = (c, stored, drift, requestScope) => drift
52
+ ? Effect.succeed({ status: { state: "auth_required", message: drift }, tools: [], catalogFailed: false })
53
+ : Effect.map(verificationFor(c, requestScope), (verification) => ({
54
+ status: { state: stored.state },
55
+ tools: [],
56
+ catalogFailed: stored.problem === "catalog_failed",
57
+ ...(stored.problem === "connector_timeout" ? { timedOut: true } : {}),
58
+ verification,
59
+ }));
60
+ /**
61
+ * The stored last verification of an operator-managed credential
62
+ * (src/credential-verification.ts). Read only, never probed (INV-10). A
63
+ * record counts only for the save it observed, whose revision the card's
64
+ * memoized vault read already holds; a vault without revisions has none
65
+ * to match.
66
+ */
67
+ const verificationFor = (c, requestScope) => c.credential && vault
68
+ ? attempt(async () => {
69
+ const metadata = await credentialReads(vault, requestReads(requestScope)).metadata(c.id, c.authScope === "personal" ? owner : undefined);
70
+ return metadata?.revision
71
+ ? readVerification(registry.contextFor(c.id, baseUrl, requestScope).storage, metadata.revision)
72
+ : undefined;
73
+ }).pipe(Effect.orElseSucceed(() => undefined))
74
+ : Effect.succeed(undefined);
41
75
  // Status first, then the catalog only when status is ok. A failed status
42
76
  // read is an error status, not a failed row: the row still reports the
43
77
  // connector's credential.
@@ -54,8 +88,9 @@ export function uiData(registry, baseUrl, options) {
54
88
  signal,
55
89
  ...(options.defer ? { defer: options.defer } : {}),
56
90
  }));
91
+ const verification = yield* verificationFor(c, requestScope);
57
92
  if (status.state !== "ok" || signal.aborted) {
58
- return { status, tools: [], catalogFailed: false };
93
+ return { status, tools: [], catalogFailed: false, verification };
59
94
  }
60
95
  // A catalog is configuration the operator chose to load, and its
61
96
  // descriptions are the agent's too, so the page shows them as text.
@@ -71,10 +106,10 @@ export function uiData(registry, baseUrl, options) {
71
106
  name: recordedToolName(t),
72
107
  address: `${c.id}.${recordedToolName(t)}`,
73
108
  classification: t.classification === "read" ? "read" : "write",
74
- }))).pipe(Effect.map((tools) => ({ status, tools, catalogFailed: false })),
109
+ }))).pipe(Effect.map((tools) => ({ status, tools, catalogFailed: false, verification })),
75
110
  // The registry owns the failed catalog observation; the page only
76
111
  // needs to know there was one.
77
- Effect.catch(() => Effect.succeed({ status, tools: [], catalogFailed: !signal.aborted })),
112
+ Effect.catch(() => Effect.succeed({ status, tools: [], catalogFailed: !signal.aborted, verification })),
78
113
  // Loading the catalog may replace an earlier clean observation.
79
114
  // Read this view's current bounded facts without another status probe.
80
115
  Effect.map((observed) => ({
@@ -87,9 +122,23 @@ export function uiData(registry, baseUrl, options) {
87
122
  catalogFailed: false,
88
123
  })));
89
124
  };
125
+ // The observation under its own deadline. Its abort reaches the status
126
+ // and catalog reads through `inner`, which then settle as an error or a
127
+ // failed catalog; either way the reason on `inner` says the deadline
128
+ // ended them, so the row is a timeout rather than a failure.
129
+ const observeWithin = (c, drift, signal, requestScope, timeoutMs) => {
130
+ const timeoutError = new Error(OBSERVE_TIMED_OUT);
131
+ const timedOut = () => ({
132
+ status: failureStatus(c.id, timeoutError),
133
+ tools: [],
134
+ catalogFailed: false,
135
+ timedOut: true,
136
+ });
137
+ return withDeadlineEffect((inner) => observe(c, drift, inner, requestScope).pipe(Effect.map((observed) => (inner.reason === timeoutError ? timedOut() : observed))), { timeoutMs, signal, timeoutError }).pipe(Effect.catch((error) => (error === timeoutError ? Effect.succeed(timedOut()) : Effect.fail(error))));
138
+ };
90
139
  // The credential card, for someone who may manage this connector's auth.
91
140
  // Never fails: a vault that cannot be read is the card's own error.
92
- const credentialFor = (c) => {
141
+ const credentialFor = (c, requestScope) => {
93
142
  const mayManageAuth = options.mayManage?.(c.id) ?? (c.authScope === "personal" ? Boolean(owner) : options.oauthManagement);
94
143
  const declared = c.credential;
95
144
  if (!declared || !vault || !mayManageAuth)
@@ -121,7 +170,9 @@ export function uiData(registry, baseUrl, options) {
121
170
  ...(declared.placeholder ? { placeholder: declared.placeholder } : {}),
122
171
  };
123
172
  return attempt(async () => {
124
- const metadata = await vault.metadata(c.id, c.authScope === "personal" ? owner : undefined);
173
+ // Memoized per request: the card only displays what the drift and
174
+ // status reads of this same detail already read.
175
+ const metadata = await credentialReads(vault, requestReads(requestScope)).metadata(c.id, c.authScope === "personal" ? owner : undefined);
125
176
  const fields = credentialFields(metadata);
126
177
  const shape = storedCredentialShape(declared, metadata?.fields ?? null);
127
178
  return {
@@ -152,18 +203,52 @@ export function uiData(registry, baseUrl, options) {
152
203
  });
153
204
  }));
154
205
  };
155
- const detail = (c, signal, requestScope) => Effect.gen(function* () {
156
- const drift = yield* attempt(() => registry.credentialDriftFor(c.id));
206
+ // Where this row records what it observed. A live read prepares only the
207
+ // generation; a stored read looks the observation up and, finding none,
208
+ // records the live read it falls back to.
209
+ const statusLookup = (c, requestScope) => options.statusObservation === "stored"
210
+ ? readStatusObservation(registry, c.id, baseUrl, requestScope)
211
+ : options.statusObservation === "record"
212
+ ? Effect.map(prepareStatusSlot(registry, c.id, baseUrl, requestScope), (slot) => (slot ? { slot } : {}))
213
+ : Effect.succeed({});
214
+ const detail = (c, signal, requestScope, recording) => Effect.gen(function* () {
215
+ // The status slot is read before the probe starts, so an invalidation
216
+ // during it leaves the observation under the generation it replaced.
217
+ const [drift, stored] = yield* Effect.all([attempt(() => registry.credentialDriftFor(c.id, requestScope)), statusLookup(c, requestScope)], { concurrency: 2 });
218
+ const observation = stored.observation;
219
+ const statusSlot = observation ? undefined : stored.slot;
220
+ // The row's own deadline may end it below; it records from here.
221
+ if (statusSlot)
222
+ recording.slot = statusSlot;
157
223
  // One deadline covers the whole row. The status and catalog reads once
158
224
  // had a second one of the same length inside it, which could only fire
159
225
  // after this one had already ended the row.
160
- const { status, tools, catalogFailed } = yield* observe(c, drift, signal, requestScope);
161
- const credential = yield* credentialFor(c);
226
+ // The operator page's shorter observation deadline, when it has one,
227
+ // bounds only the downstream: the credential card below is local
228
+ // storage and still renders, so a slow vendor's slot stays fixable.
229
+ const { status, tools, catalogFailed, timedOut, verification } = observation
230
+ ? yield* observeStored(c, observation, drift, requestScope)
231
+ : options.observeTimeoutMs === undefined
232
+ ? yield* observe(c, drift, signal, requestScope)
233
+ : yield* observeWithin(c, drift, signal, requestScope, options.observeTimeoutMs);
234
+ const credential = yield* credentialFor(c, requestScope);
162
235
  logStatus(c.id, status.state, statusFailure(status), requestScope);
163
- const problem = uiProblemFor(c, status.state, {
164
- credentialDrift: Boolean(drift),
165
- catalogFailed,
166
- });
236
+ const problem = timedOut
237
+ ? "connector_timeout"
238
+ : uiProblemFor(c, status.state, {
239
+ credentialDrift: Boolean(drift),
240
+ catalogFailed,
241
+ });
242
+ const toolCount = observation ? observation.toolCount : tools.length;
243
+ // Started only while the caller is still here, then awaited to its
244
+ // end even if the row's deadline or the caller ends it meanwhile.
245
+ if (statusSlot && !signal.aborted && !options.signal?.aborted)
246
+ yield* Effect.uninterruptible(storeStatusObservation(statusSlot, {
247
+ state: status.state,
248
+ ...(problem ? { problem } : {}),
249
+ toolCount,
250
+ observedAt: Date.now(),
251
+ }));
167
252
  return {
168
253
  id: c.id,
169
254
  authScope: c.authScope ?? "shared",
@@ -171,7 +256,7 @@ export function uiData(registry, baseUrl, options) {
171
256
  ...(c.description !== undefined ? { description: c.description } : {}),
172
257
  status: status.state,
173
258
  ...(problem ? { problem } : {}),
174
- toolCount: tools.length,
259
+ toolCount,
175
260
  tools: tools.map((tool) => ({
176
261
  name: tool.name,
177
262
  address: tool.address,
@@ -186,7 +271,9 @@ export function uiData(registry, baseUrl, options) {
186
271
  ...(status.registrationPath ? { auth: { registrationPath: status.registrationPath } } : {}),
187
272
  catalogAgeMs: registry.catalogAgeMs(c.id),
188
273
  lastCall: null,
274
+ ...(observation ? { observedAgeMs: Math.max(0, Date.now() - observation.observedAt) } : {}),
189
275
  },
276
+ ...(observation ? { observedAgeMs: Math.max(0, Date.now() - observation.observedAt) } : {}),
190
277
  // Counts only, and only when a refresh in this runtime produced them.
191
278
  // `Registry.withCatalogObservations` rebuilt the report through
192
279
  ...(status.catalogAccess ? { catalogAccess: status.catalogAccess } : {}),
@@ -196,43 +283,62 @@ export function uiData(registry, baseUrl, options) {
196
283
  : {}),
197
284
  oauth: Boolean(c.startAuth && c.disconnectAuth),
198
285
  ...(status.registrationPath ? { registrationPath: status.registrationPath } : {}),
286
+ ...(verification ? { verification } : {}),
199
287
  ...(credential ? { credential } : {}),
200
288
  };
201
289
  });
202
- const unavailable = (c, error, requestScope) => Effect.sync(() => {
290
+ const unavailable = (c, error, requestScope, timeoutError) => Effect.sync(() => {
203
291
  logStatus(c.id, "failed", error, requestScope);
292
+ // The row's own deadline is a timeout too, whichever ran out first.
293
+ const problem = error === timeoutError ? "connector_timeout" : "connector_unavailable";
204
294
  return {
205
295
  id: c.id,
206
296
  ...(c.title ? { title: c.title } : {}),
207
297
  authScope: c.authScope ?? "shared",
208
298
  status: "error",
209
- problem: "connector_unavailable",
299
+ problem,
210
300
  oauth: Boolean(c.startAuth && c.disconnectAuth),
211
301
  toolCount: 0,
212
302
  tools: [],
213
303
  overlay: {
214
304
  id: c.id,
215
305
  status: "error",
216
- problem: "connector_unavailable",
306
+ problem,
217
307
  tools: [],
218
308
  catalogAgeMs: registry.catalogAgeMs(c.id),
219
309
  lastCall: null,
220
310
  },
221
311
  };
222
312
  });
313
+ // The row's own deadline ended the read: record that timeout before the
314
+ // response, so the next stored read parks this row too. A caller who has
315
+ // already left records nothing. Once started, the write is awaited to its
316
+ // end, never raced or detached: storage writes cannot be withdrawn once
317
+ // sent, so the request settles only after it does, bounded by the storage
318
+ // layer like every other awaited write (INV-7).
319
+ const recordRowTimeout = (slot) => !slot || options.signal?.aborted
320
+ ? Effect.void
321
+ : Effect.uninterruptible(storeStatusObservation(slot, {
322
+ state: "error",
323
+ problem: "connector_timeout",
324
+ toolCount: 0,
325
+ observedAt: Date.now(),
326
+ }));
223
327
  const row = (c) => Effect.suspend(() => {
224
328
  // A row ends independently. Revoking its probe must not revoke a live
225
329
  // sibling that happens to belong to the same operator payload.
226
330
  const requestScope = {};
227
331
  if (options.activityContext)
228
332
  bindActivityRequest(requestScope, options.activityContext);
229
- return withDeadlineEffect((signal) => detail(c, signal, requestScope), {
333
+ const timeoutError = new Error(DETAIL_TIMED_OUT);
334
+ const recording = {};
335
+ return withDeadlineEffect((signal) => detail(c, signal, requestScope, recording), {
230
336
  timeoutMs: options.timeoutMs ?? DETAIL_TIMEOUT_MS,
231
337
  ...(options.signal ? { signal: options.signal } : {}),
232
- timeoutError: new Error(DETAIL_TIMED_OUT),
338
+ timeoutError,
233
339
  }).pipe(
234
340
  // A hook that throws where it should reject fails its own row too.
235
- Effect.catchDefect((defect) => Effect.fail(defect)), Effect.catch((error) => unavailable(c, error, requestScope)), Effect.ensuring(Effect.suspend(() => closeScope(c, registry.contextFor(c.id, baseUrl, requestScope), options.defer))));
341
+ Effect.catchDefect((defect) => Effect.fail(defect)), Effect.catch((error) => Effect.andThen(recordRowTimeout(error === timeoutError ? recording.slot : undefined), unavailable(c, error, requestScope, timeoutError))), Effect.ensuring(Effect.suspend(() => closeScope(c, registry.contextFor(c.id, baseUrl, requestScope), options.defer))));
236
342
  });
237
343
  return Effect.forEach(registry.listConnectors(), row, {
238
344
  concurrency: resolveDiscoveryConcurrency(options.discoveryConcurrency),
package/dist/routes/ui.js CHANGED
@@ -134,6 +134,16 @@ function operatorView({ opts }, authz, registry) {
134
134
  : "requires_operator";
135
135
  return { visible, mayManage, permissions, activityEnabled, credentialManagement };
136
136
  }
137
+ /**
138
+ * How long a detail read waits on the downstream before its row reports
139
+ * `connector_timeout`. A cold remote MCP handshake with an OAuth refresh is
140
+ * about five sequential round trips, so 8 s allows ~1.5 s each; anything
141
+ * slower is shown as slow or unreachable rather than holding one of the
142
+ * page's few detail slots for the full discovery deadline. A deployment that
143
+ * set a shorter `discovery.probeTimeoutMs` keeps that one deadline, which also
144
+ * ends in `connector_timeout`.
145
+ */
146
+ const DETAIL_OBSERVE_TIMEOUT_MS = 8000;
137
147
  /** One connector's probed row, the page's second request per card. */
138
148
  function connectorDetail(context, id) {
139
149
  const { request, baseUrl, opts, defer } = context;
@@ -158,7 +168,13 @@ function connectorDetail(context, id) {
158
168
  discoveryConcurrency: 1,
159
169
  personalCredentialOwner: authz.principalKey,
160
170
  mayManage,
171
+ // A live read records its observation; `?observation=stored` answers
172
+ // from the current one instead and leaves the live read to the browser.
173
+ statusObservation: context.url.searchParams.get("observation") === "stored" ? "stored" : "record",
161
174
  timeoutMs: opts.config.discovery.probeTimeoutMs,
175
+ ...(opts.config.discovery.probeTimeoutMs > DETAIL_OBSERVE_TIMEOUT_MS
176
+ ? { observeTimeoutMs: DETAIL_OBSERVE_TIMEOUT_MS }
177
+ : {}),
162
178
  signal: request.signal,
163
179
  });
164
180
  const detail = data.connectors[0];
@@ -1,12 +1,13 @@
1
1
  // Operation and call services. Only the Promise view bridge is keyed on a
2
2
  // context object; authority lives in Context values, shared by derived views.
3
+ import { Routes, routesOf } from "../agent-routes.js";
3
4
  import { Context, Effect } from "effect";
4
5
  import { ConnectorCallError } from "../errors.js";
5
6
  import { dispatchProviderRequest, } from "../provider-call.js";
6
7
  import { SentSecrets, sentSecretsForRequest, registerCredentialValues } from "../sent-secrets.js";
7
8
  import { recordRecovery } from "../call-recovery.js";
8
9
  import { fromSignal, runEdge } from "./run.js";
9
- import { RequestScope, requestOwner as requestOf } from "./request-scope.js";
10
+ import { RequestScope, requestOwner as requestOf, requestReads } from "./request-scope.js";
10
11
  /** Who admitted the operation; absent for a probe or a callback no request admitted. */
11
12
  export class Caller extends Context.Service()("connecta/call/Caller") {
12
13
  }
@@ -197,7 +198,7 @@ function openCall(inputs) {
197
198
  throw ended();
198
199
  (inputs.ledger ?? detachedLedger).dispatch(request, headers, method);
199
200
  },
200
- }), Context.add(CallMemo, new Map()), Context.add(ProviderLedger, inputs.providerLedger), Context.add(LogicalLedger, inputs.operation ? serviceFor(inputs.operation, LogicalLedger) : undefined));
201
+ }), Context.add(CallMemo, new Map()), Context.add(ProviderLedger, inputs.providerLedger), Context.add(LogicalLedger, inputs.operation ? serviceFor(inputs.operation, LogicalLedger) : undefined), Context.add(Routes, routesOf(inputs.operation)));
201
202
  const lease = {
202
203
  get services() {
203
204
  return Context.add(Context.add(services, ActiveCall, lease), Operation, inputs.operation);
@@ -338,6 +339,10 @@ export function chargeView(view) {
338
339
  const budget = recordedService(view, Budget);
339
340
  return budget ? runEdge(budget.charge) : Promise.reject(ended());
340
341
  }
342
+ /** The read memo of the request this context belongs to (request-reads.ts). */
343
+ export function viewReads(view) {
344
+ return requestReads(views.get(view)?.request ?? view.requestScope ?? view);
345
+ }
341
346
  /** Admission is sticky until request close; ending a call never makes its copies standalone. */
342
347
  export function callScopeKnown(view) {
343
348
  const scope = requestOf(views.get(view)?.request ?? view.requestScope ?? view);
@@ -23,8 +23,20 @@ function current(receiver, key, own, args, native) {
23
23
  export function registerNativeStorage(edge, native) {
24
24
  const own = { get: edge.get, set: edge.set, delete: edge.delete, list: edge.list, compareAndSet: edge.compareAndSet };
25
25
  storageMethods.set(edge, own);
26
+ const getMany = native.getMany;
26
27
  storage.set(edge, {
28
+ accessToken: native.accessToken
29
+ ? (hash) => typed(() => (getNativeStorage(edge) ? native.accessToken(hash) : Effect.succeed(undefined)))
30
+ : undefined,
27
31
  get: (key) => current(edge, "get", own.get, [key], () => native.get(key)),
32
+ // A replaced `get` answers each key itself, in order, as before.
33
+ ...(getMany
34
+ ? {
35
+ getMany: (keys) => typed(() => Reflect.get(edge, "get") === own.get
36
+ ? getMany(keys)
37
+ : Effect.forEach(keys, (key) => current(edge, "get", own.get, [key], () => native.get(key)))),
38
+ }
39
+ : {}),
28
40
  set: (key, value, options) => current(edge, "set", own.set, [key, value, options], () => native.set(key, value, options)),
29
41
  delete: (key) => current(edge, "delete", own.delete, [key], () => native.delete(key)),
30
42
  list: (prefix) => current(edge, "list", own.list, [prefix], () => native.list(prefix)),
@@ -0,0 +1,207 @@
1
+ // Request-scoped memo of storage reads (INV-7).
2
+ //
3
+ // One request often reads the same key many times: a catalog listing reads its
4
+ // generation at each step, and status, drift and detail views each open the
5
+ // same credential. A call site that has argued staleness
6
+ // within one request is safe reads through `RequestReads.read`; every other
7
+ // read stays a plain storage read. The memo belongs to one request owner,
8
+ // holds plain strings only, and is emptied when the request closes. Nothing in
9
+ // it crosses requests: each request owner has its own. Entries are keyed by
10
+ // the physical key in the underlying store, through any namespaced views, so
11
+ // one principal's partition never answers another's and two views of the same
12
+ // partition share one entry.
13
+ //
14
+ // Freshness. Every write through a store connecta can observe — the SQL
15
+ // drivers, `memoryStorage()`, and namespaced views over them — tells each open
16
+ // memo over that store, in this isolate, that its key is changing: before the
17
+ // write is submitted and again when it settles. The memo drops that key, and
18
+ // a read of it already in flight is not recorded, since it may or may not
19
+ // include the write. So a write by this request, or by any other request in
20
+ // this isolate, is never hidden by a memoized value. A store whose writes
21
+ // connecta cannot observe (a custom adapter, or a built-in one whose methods
22
+ // were replaced) is never memoized. Writes in another isolate or process, and
23
+ // expiry within the request, are invisible here: a memoized key can lag them
24
+ // for the rest of the request. So no read that gates authority, sends a
25
+ // credential or decides a write may be memoized; a call site that memoizes
26
+ // must say why that staleness is safe.
27
+ //
28
+ // A `fresh` read bypasses the lookup and records what it saw, so a memoized
29
+ // reader never returns a value older than this request's latest fresh read of
30
+ // the same key. Only the latest read of a key this request started records:
31
+ // an earlier read that settles later, possibly from before another isolate's
32
+ // write, never replaces a newer observation.
33
+ //
34
+ // The registry of open memos holds weak references, so a request owner that is
35
+ // never closed cannot pin its memo. A runtime without `WeakRef` and
36
+ // `FinalizationRegistry` (workerd before compatibility date 2025-05-05 without
37
+ // `enable_weak_ref`) memoizes nothing: every read goes to storage.
38
+ import { Effect } from "effect";
39
+ const roots = new WeakMap();
40
+ const observed = new WeakMap();
41
+ /**
42
+ * Open memos holding or reading entries of each store. Weak, so a request
43
+ * owner that is never closed still lets its memo be collected with it.
44
+ */
45
+ const open = new WeakMap();
46
+ function eachOpen(store, visit) {
47
+ const memos = open.get(store);
48
+ if (!memos)
49
+ return;
50
+ for (const ref of memos) {
51
+ const memo = ref.deref();
52
+ if (memo)
53
+ visit(memo);
54
+ else
55
+ memos.delete(ref);
56
+ }
57
+ }
58
+ /** A write to `key` of `store` is about to be submitted, or has settled. */
59
+ export function noteStorageWrite(store, key) {
60
+ eachOpen(store, (memo) => memo.changed(store, key));
61
+ }
62
+ /** Wrap one write so open memos see it begin and settle, however it ends. */
63
+ export function observedWrite(store, key, write) {
64
+ return Effect.suspend(() => {
65
+ noteStorageWrite(store, key);
66
+ return write;
67
+ }).pipe(Effect.ensuring(Effect.sync(() => noteStorageWrite(store, key))));
68
+ }
69
+ /**
70
+ * Declare that every write through `storage` reaches `noteStorageWrite` for
71
+ * `root`, as long as its methods stay the ones it has now; or that `storage`
72
+ * stores `key` at `prefix + key` in another store, observed only while that
73
+ * one is.
74
+ */
75
+ export function observeStorageWrites(storage, layer) {
76
+ if ("root" in layer)
77
+ roots.set(layer.root, storage);
78
+ observed.set(storage, {
79
+ own: {
80
+ get: storage.get,
81
+ set: storage.set,
82
+ delete: storage.delete,
83
+ list: storage.list,
84
+ compareAndSet: storage.compareAndSet,
85
+ },
86
+ layer,
87
+ });
88
+ return storage;
89
+ }
90
+ /** The underlying store and key a read of `key` reaches, when every layer is observed. */
91
+ function physical(storage, key) {
92
+ const entry = observed.get(storage);
93
+ if (!entry)
94
+ return undefined;
95
+ const { own, layer } = entry;
96
+ if (own.get !== storage.get ||
97
+ own.set !== storage.set ||
98
+ own.delete !== storage.delete ||
99
+ own.list !== storage.list ||
100
+ own.compareAndSet !== storage.compareAndSet)
101
+ return undefined;
102
+ return "root" in layer ? { store: layer.root, key } : physical(layer.storage, layer.prefix + key);
103
+ }
104
+ const weakReferences = () => typeof WeakRef === "function" && typeof FinalizationRegistry === "function";
105
+ export class RequestReads {
106
+ closed = false;
107
+ stores = new Map();
108
+ ref = weakReferences() ? new WeakRef(this) : undefined;
109
+ entriesFor(store) {
110
+ let entries = this.stores.get(store);
111
+ if (!entries) {
112
+ entries = { values: new Map(), versions: new Map(), started: new Map() };
113
+ this.stores.set(store, entries);
114
+ eachOpen(store, () => { }); // prune collected memos
115
+ let memos = open.get(store);
116
+ if (!memos)
117
+ open.set(store, (memos = new Set()));
118
+ memos.add(this.ref);
119
+ }
120
+ return entries;
121
+ }
122
+ /** A write notice: forget `key` and refuse any read of it in flight. */
123
+ changed(store, key) {
124
+ const entries = this.stores.get(store);
125
+ if (!entries)
126
+ return;
127
+ entries.values.delete(key);
128
+ entries.versions.set(key, (entries.versions.get(key) ?? 0) + 1);
129
+ }
130
+ /** A memoized answer, or how to record the value a storage read returns. */
131
+ lookup(storage, key, fresh) {
132
+ const address = this.closed || !this.ref ? undefined : physical(storage, key);
133
+ if (!address)
134
+ return undefined;
135
+ const entries = this.entriesFor(address.store);
136
+ if (!fresh && entries.values.has(address.key))
137
+ return { hit: true, value: entries.values.get(address.key) };
138
+ const before = entries.versions.get(address.key) ?? 0;
139
+ const read = (entries.started.get(address.key) ?? 0) + 1;
140
+ entries.started.set(address.key, read);
141
+ return {
142
+ hit: false,
143
+ record: (value) => {
144
+ if (!this.closed &&
145
+ entries.started.get(address.key) === read &&
146
+ (entries.versions.get(address.key) ?? 0) === before)
147
+ entries.values.set(address.key, value);
148
+ },
149
+ };
150
+ }
151
+ /**
152
+ * `load` reads `key` from `storage`. Without `fresh`, a value this request
153
+ * already read, with no observed write to that key since, answers instead.
154
+ */
155
+ read(storage, key, load, fresh = false) {
156
+ return Effect.suspend(() => {
157
+ const found = this.lookup(storage, key, fresh);
158
+ if (!found)
159
+ return load;
160
+ if (found.hit)
161
+ return Effect.succeed(found.value);
162
+ return Effect.tap(load, (value) => Effect.sync(() => found.record(value)));
163
+ });
164
+ }
165
+ /** Prefetch only keys whose callers already permit request-local staleness. */
166
+ prefetch(requests, load) {
167
+ return Effect.suspend(() => {
168
+ const pending = requests.map(({ storage, key }) => ({
169
+ address: physical(storage, key),
170
+ found: this.lookup(storage, key, false),
171
+ }));
172
+ const root = pending[0]?.address?.store;
173
+ const storage = root && roots.get(root);
174
+ if (!storage || pending.some(({ address, found }) => !found || address?.store !== root))
175
+ return Effect.void;
176
+ const misses = pending.filter(({ found }) => found && !found.hit);
177
+ if (!misses.length)
178
+ return Effect.void;
179
+ return Effect.map(load(storage, misses.map(({ address }) => address.key)), (values) => {
180
+ misses.forEach(({ found }, i) => {
181
+ if (found && !found.hit)
182
+ found.record(values[i] ?? null);
183
+ });
184
+ });
185
+ });
186
+ }
187
+ /** `read` for a Promise-shaped caller, without another Effect edge. */
188
+ async readPromise(storage, key, load, fresh = false) {
189
+ const found = this.lookup(storage, key, fresh);
190
+ if (!found)
191
+ return load();
192
+ if (found.hit)
193
+ return found.value;
194
+ const value = await load();
195
+ found.record(value);
196
+ return value;
197
+ }
198
+ close() {
199
+ if (this.closed)
200
+ return;
201
+ this.closed = true;
202
+ for (const store of this.stores.keys())
203
+ if (this.ref)
204
+ open.get(store)?.delete(this.ref);
205
+ this.stores.clear();
206
+ }
207
+ }
@@ -1,6 +1,7 @@
1
1
  // One owner per existing upstream request identity. Facts survive revocation;
2
2
  // retaining them never reconstitutes a call capability or a live operation.
3
3
  import { Context, Exit, Scope } from "effect";
4
+ import { RequestReads } from "./request-reads.js";
4
5
  import { detach } from "./run.js";
5
6
  export class RequestOwner {
6
7
  identity;
@@ -18,6 +19,7 @@ export class RequestOwner {
18
19
  secrets: undefined,
19
20
  invocations: { entered: [], authFailures: new Map(), retryReads: new Map() },
20
21
  handoffs: undefined,
22
+ reads: undefined,
21
23
  };
22
24
  constructor(identity) {
23
25
  this.identity = identity;
@@ -51,6 +53,7 @@ export class RequestOwner {
51
53
  }
52
54
  this.calls.clear();
53
55
  this.entries.clear();
56
+ this.retained.reads?.close();
54
57
  attempt(() => this.controller.abort(new Error("The request ended.")));
55
58
  for (const finalizer of this.finalizers.splice(0).reverse())
56
59
  attempt(finalizer);
@@ -61,6 +64,19 @@ export class RequestOwner {
61
64
  throw new AggregateError(failures, `Closing the request: ${failures.length} cleanup step(s) failed.`);
62
65
  }
63
66
  }
67
+ /** This request's read memo; a closed request's memo answers nothing. */
68
+ export function requestReads(identity) {
69
+ return ownerReads(requestOwner(identity));
70
+ }
71
+ export function ownerReads(owner) {
72
+ const retained = owner.retained;
73
+ if (!retained.reads) {
74
+ retained.reads = new RequestReads();
75
+ if (!owner.open)
76
+ retained.reads.close();
77
+ }
78
+ return retained.reads;
79
+ }
64
80
  export class RequestScope extends Context.Service()("connecta/request/Scope") {
65
81
  }
66
82
  const requests = new WeakMap();
@@ -32,6 +32,18 @@ export function storageGet(key) {
32
32
  catch: (error) => error,
33
33
  }));
34
34
  }
35
+ /**
36
+ * Read several keys, in one round trip when the store can; otherwise one
37
+ * `get` after another. Values come back in `keys` order.
38
+ */
39
+ export function storageGetMany(keys) {
40
+ return Storage.use((storage) => {
41
+ const native = getNativeStorage(storage);
42
+ if (native?.getMany)
43
+ return native.getMany(keys);
44
+ return Effect.forEach(keys, (key) => storageGet(key).pipe(Effect.provideService(Storage, storage)));
45
+ });
46
+ }
35
47
  /** Write one key, with the adapter's optional TTL. */
36
48
  export function storageSet(key, value, options) {
37
49
  return Storage.use((storage) => getNativeStorage(storage)?.set(key, value, options) ??