@zackbart/connecta 0.25.0 → 0.26.1
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 +6 -5
- package/CHANGELOG.md +387 -0
- package/README.md +32 -15
- package/SECURITY.md +6 -5
- package/bin/connecta.mjs +5 -1
- package/dist/activity.d.ts +10 -3
- package/dist/activity.js +10 -1
- package/dist/artifacts/connector.d.ts +55 -0
- package/dist/artifacts/connector.js +724 -0
- package/dist/artifacts/document.js +80 -0
- package/dist/artifacts/guide.js +140 -0
- package/dist/artifacts/html-scan.js +329 -0
- package/dist/artifacts/json.js +68 -0
- package/dist/artifacts/kv-store.d.ts +20 -0
- package/dist/artifacts/kv-store.js +140 -0
- package/dist/artifacts/markdown.js +478 -0
- package/dist/artifacts/module.d.ts +37 -0
- package/dist/artifacts/module.js +60 -0
- package/dist/artifacts/operations.d.ts +195 -0
- package/dist/artifacts/operations.js +944 -0
- package/dist/artifacts/refresh.d.ts +53 -0
- package/dist/artifacts/refresh.js +235 -0
- package/dist/artifacts/routes.js +217 -0
- package/dist/artifacts/types.d.ts +214 -0
- package/dist/artifacts/types.js +22 -0
- package/dist/artifacts/validate.d.ts +62 -0
- package/dist/artifacts/validate.js +653 -0
- package/dist/artifacts.d.ts +6 -0
- package/dist/artifacts.js +9 -0
- package/dist/auth/downstream-oauth.js +397 -48
- package/dist/auth/oauth-start-reset.js +20 -0
- package/dist/branding.d.ts +87 -0
- package/dist/branding.js +170 -1
- package/dist/catalog-service.js +39 -29
- package/dist/catalog.js +42 -8
- package/dist/connector-access.d.ts +18 -4
- package/dist/connector-access.js +65 -20
- package/dist/connector-caller.d.ts +8 -0
- package/dist/connector-caller.js +19 -0
- package/dist/connectors/remote-mcp.js +48 -9
- package/dist/errors.d.ts +16 -1
- package/dist/errors.js +28 -0
- package/dist/execute.js +431 -75
- package/dist/executor-admission.d.ts +11 -1
- package/dist/executor-admission.js +79 -14
- package/dist/executors/quickjs.js +4 -2
- package/dist/index.d.ts +62 -6
- package/dist/index.js +230 -6
- package/dist/invocation.js +56 -10
- package/dist/meta-tools.js +7 -2
- package/dist/module-contracts.d.ts +21 -1
- package/dist/oauth-callback-outcome.js +43 -18
- package/dist/operator-ui/generated.js +2 -2
- package/dist/operator-ui/model.d.ts +62 -5
- package/dist/operator-ui/view.js +372 -28
- package/dist/page-styles.js +7 -0
- package/dist/program-source.js +116 -0
- package/dist/registry.d.ts +42 -6
- package/dist/registry.js +164 -60
- package/dist/resumable.js +1291 -0
- package/dist/routes/activity.js +6 -69
- package/dist/routes/actor-labels.js +114 -0
- package/dist/routes/credentials.js +27 -5
- package/dist/routes/mcp.js +120 -59
- package/dist/routes/oauth-management.js +87 -20
- package/dist/routes/oauth.js +54 -87
- package/dist/routes/operator.js +1 -0
- package/dist/routes/shared.d.ts +23 -1
- package/dist/routes/shared.js +20 -2
- package/dist/routes/ui-data.js +2 -1
- package/dist/routes/ui.js +19 -4
- package/dist/run-journal.js +347 -0
- package/dist/runtime/shared-read.js +106 -0
- package/dist/server.js +30 -2
- package/dist/skills.js +30 -11
- package/dist/timeout.js +26 -0
- package/dist/tool-safety.d.ts +35 -0
- package/dist/tool-safety.js +31 -0
- package/dist/types.d.ts +23 -0
- package/dist/ui.d.ts +17 -5
- package/dist/ui.js +51 -68
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/documentation/architecture.md +214 -27
- package/documentation/auth.md +186 -2
- package/documentation/code-mode.md +236 -63
- package/documentation/meta-tools.md +55 -29
- package/ethos.md +12 -10
- package/examples/worker/AGENTS.md +6 -0
- package/examples/worker/README.md +174 -10
- package/examples/worker/src/d1-activity-row.ts +14 -2
- package/examples/worker/src/d1-activity.ts +4 -2
- package/examples/worker/src/index.ts +36 -1
- package/examples/worker/src/r2-artifact-blobs.ts +29 -0
- package/examples/worker/wrangler.jsonc +5 -1
- package/package.json +6 -2
- package/templates/node/AGENTS.md +9 -2
- package/templates/node/README.md +31 -3
- package/templates/node/package.json +1 -1
- package/templates/node/src/index.ts +27 -0
package/dist/program-source.js
CHANGED
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
// QuickJS normalizer runs it too for callers that drive that executor directly.
|
|
4
4
|
/** A whole-program markdown fence, as QuickJS's normalizer recognizes one. */
|
|
5
5
|
const FENCE = /^```[\w-]*\s*\n([\s\S]*?)\n?```$/;
|
|
6
|
+
/** Intake accepts one JavaScript fence, optionally surrounded by prose. */
|
|
7
|
+
const PROGRAM_FENCE = /^```(?:js|javascript)?[ \t]*\r?\n([\s\S]*?)\r?\n```[ \t]*$/gm;
|
|
6
8
|
/** Leading whitespace and comments, which may precede the arrow expression. */
|
|
7
9
|
const LEADING_TRIVIA = /^(?:\s+|\/\/[^\n]*|\/\*[\s\S]*?\*\/)+/;
|
|
8
10
|
/** One character of an identifier, keyword, or number; `\\` for escapes. */
|
|
@@ -44,6 +46,120 @@ export function withoutProgramTerminator(code) {
|
|
|
44
46
|
const stripped = stripTerminators(source);
|
|
45
47
|
return stripped === source ? code : stripped;
|
|
46
48
|
}
|
|
49
|
+
/**
|
|
50
|
+
* Recover wrappers that models put around a single program. This runs in the
|
|
51
|
+
* host, so QuickJS and Dynamic Workers receive the same source. It does not
|
|
52
|
+
* turn arbitrary statements into a program: an arrow remains the contract.
|
|
53
|
+
*/
|
|
54
|
+
export function normalizeProgramSource(code) {
|
|
55
|
+
let source = code.trim();
|
|
56
|
+
const head = source.replace(LEADING_TRIVIA, "");
|
|
57
|
+
// A program may itself contain fenced Markdown in a comment or string. Once
|
|
58
|
+
// the source starts as a program, a fence inside it is data, never a wrapper.
|
|
59
|
+
if (!/^(?:async\b|\(|export\s+default\s+async\b)/.test(head)) {
|
|
60
|
+
const fences = [...source.matchAll(PROGRAM_FENCE)];
|
|
61
|
+
if (fences.length === 1) {
|
|
62
|
+
const fence = fences[0];
|
|
63
|
+
const outside = source.slice(0, fence.index) + source.slice(fence.index + fence[0].length);
|
|
64
|
+
const body = fence[1].trim();
|
|
65
|
+
const fencedHead = body.replace(LEADING_TRIVIA, "");
|
|
66
|
+
if (!outside.includes("```") && /^(?:async\b|\(|export\s+default\s+async\b)/.test(fencedHead)) {
|
|
67
|
+
source = body;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
source = source.replace(/^export\s+default\s+(?=async\s*(?:\(|[A-Za-z_$]))/, "");
|
|
72
|
+
// The declaration must be the whole source. A scanner finds its own closing
|
|
73
|
+
// brace rather than mistaking a brace in a string or a second statement for
|
|
74
|
+
// the end of the function.
|
|
75
|
+
const named = /^async\s+function\s+([A-Za-z_$][\w$]*)\s*\(([^()]*)\)\s*\{/.exec(source);
|
|
76
|
+
if (named) {
|
|
77
|
+
const end = functionBodyEnd(source, named[0].length - 1);
|
|
78
|
+
if (end !== undefined && /^(?:\s|;|\/\/[^\n]*|\/\*[\s\S]*?\*\/)*$/.test(source.slice(end + 1))) {
|
|
79
|
+
// Invoke a named function expression inside the arrow. Its own name and
|
|
80
|
+
// `arguments` binding then work exactly as in the original declaration.
|
|
81
|
+
source = `async (...args) => (async function ${named[1]}(${named[2]}) {${source.slice(named[0].length, end)}})(...args)${source.slice(end + 1)}`;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return withoutProgramTerminator(source);
|
|
85
|
+
}
|
|
86
|
+
/** Find the matching closing brace of a named function's body. */
|
|
87
|
+
function functionBodyEnd(source, open) {
|
|
88
|
+
let depth = 0;
|
|
89
|
+
let previous = "";
|
|
90
|
+
for (let i = open; i < source.length; i++) {
|
|
91
|
+
const c = source[i];
|
|
92
|
+
if (/\s/.test(c))
|
|
93
|
+
continue;
|
|
94
|
+
if (c === "/" && source[i + 1] === "/") {
|
|
95
|
+
const end = source.indexOf("\n", i + 2);
|
|
96
|
+
i = end === -1 ? source.length : end;
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
if (c === "/" && source[i + 1] === "*") {
|
|
100
|
+
const end = source.indexOf("*/", i + 2);
|
|
101
|
+
if (end === -1)
|
|
102
|
+
return undefined;
|
|
103
|
+
i = end + 1;
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
if (c === "'" || c === '"' || c === "`") {
|
|
107
|
+
let end = i + 1;
|
|
108
|
+
for (; end < source.length && source[end] !== c; end++) {
|
|
109
|
+
if (source[end] === "\\")
|
|
110
|
+
end++;
|
|
111
|
+
else if (source[end] === "\n" && c !== "`")
|
|
112
|
+
return undefined;
|
|
113
|
+
}
|
|
114
|
+
if (end >= source.length)
|
|
115
|
+
return undefined;
|
|
116
|
+
i = end;
|
|
117
|
+
previous = "0";
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
if (c === "/" && startsRegex(previous)) {
|
|
121
|
+
let end = i + 1;
|
|
122
|
+
let inClass = false;
|
|
123
|
+
for (; end < source.length; end++) {
|
|
124
|
+
const r = source[end];
|
|
125
|
+
if (r === "\n")
|
|
126
|
+
return undefined;
|
|
127
|
+
if (r === "\\")
|
|
128
|
+
end++;
|
|
129
|
+
else if (r === "[")
|
|
130
|
+
inClass = true;
|
|
131
|
+
else if (r === "]")
|
|
132
|
+
inClass = false;
|
|
133
|
+
else if (r === "/" && !inClass)
|
|
134
|
+
break;
|
|
135
|
+
}
|
|
136
|
+
if (end >= source.length)
|
|
137
|
+
return undefined;
|
|
138
|
+
i = end;
|
|
139
|
+
previous = "0";
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
if (WORD.test(c)) {
|
|
143
|
+
let end = i + 1;
|
|
144
|
+
while (end < source.length && WORD.test(source[end]))
|
|
145
|
+
end++;
|
|
146
|
+
previous = source.slice(i, end);
|
|
147
|
+
i = end - 1;
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
if (c === "{")
|
|
151
|
+
depth++;
|
|
152
|
+
else if (c === "}") {
|
|
153
|
+
depth--;
|
|
154
|
+
if (depth === 0)
|
|
155
|
+
return i;
|
|
156
|
+
if (depth < 0)
|
|
157
|
+
return undefined;
|
|
158
|
+
}
|
|
159
|
+
previous = c;
|
|
160
|
+
}
|
|
161
|
+
return undefined;
|
|
162
|
+
}
|
|
47
163
|
function stripTerminators(source) {
|
|
48
164
|
const head = source.replace(LEADING_TRIVIA, "");
|
|
49
165
|
if (!/^async\b/.test(head) && !head.startsWith("("))
|
package/dist/registry.d.ts
CHANGED
|
@@ -3,6 +3,7 @@ import type { CatalogDriftReport, Connector, ConnectorContext, ConnectorStatus,
|
|
|
3
3
|
import { type DeferredWork } from "./connector-scope.js";
|
|
4
4
|
import { type CatalogDriftActivityContext } from "./activity.js";
|
|
5
5
|
import { type CallAdmissionPermit, type ConnectorCallAdmissionSnapshot } from "./call-admission.js";
|
|
6
|
+
import { type ConnectorCaller } from "./connector-caller.js";
|
|
6
7
|
/**
|
|
7
8
|
* Split `"<connectorId>.<toolName>"` on the first dot. Connector ids contain
|
|
8
9
|
* no dots, so a downstream tool name may. Exported because an address that
|
|
@@ -129,8 +130,11 @@ export type ToolAccess = ReadonlyMap<string, ReadonlySet<string>>;
|
|
|
129
130
|
export interface RegistryScope {
|
|
130
131
|
connectorIds: "all" | readonly string[];
|
|
131
132
|
toolAccess?: ToolAccess;
|
|
133
|
+
guardedToolAccess?: ToolAccess;
|
|
132
134
|
subjectKey?: string;
|
|
133
135
|
principalKey?: string;
|
|
136
|
+
/** The admitted caller, readable only by built-in connectors; see connector-caller.ts. */
|
|
137
|
+
caller?: ConnectorCaller;
|
|
134
138
|
}
|
|
135
139
|
/**
|
|
136
140
|
* Holds the connector set, resolves addresses, and caches per-connector tool
|
|
@@ -147,7 +151,7 @@ export declare class Registry implements RegistryView {
|
|
|
147
151
|
private readonly catalogGenerations;
|
|
148
152
|
/** Serialize persisted catalog set/delete operations within this isolate. */
|
|
149
153
|
private readonly catalogMutations;
|
|
150
|
-
/** Same-request cold loads share one
|
|
154
|
+
/** Same-request cold loads share one read without retaining the request.
|
|
151
155
|
* See documentation/architecture.md#the-two-lifetimes. */
|
|
152
156
|
private readonly requestCatalogLoads;
|
|
153
157
|
/** One live refresh per connector across agent and operator requests. */
|
|
@@ -283,11 +287,37 @@ export declare class Registry implements RegistryView {
|
|
|
283
287
|
private enqueueCatalogMutation;
|
|
284
288
|
private refreshToolsWithContext;
|
|
285
289
|
/**
|
|
286
|
-
*
|
|
287
|
-
*
|
|
288
|
-
*
|
|
290
|
+
* Whether a refresh may still enter the shared cache layers: its generation
|
|
291
|
+
* is current, and the flight it runs in has been neither abandoned nor
|
|
292
|
+
* outlived its bound. Asked at each publication point, since listing and
|
|
293
|
+
* snapshotting both yield, and a late result must never overwrite the one a
|
|
294
|
+
* fresh attempt published in the meantime.
|
|
295
|
+
*/
|
|
296
|
+
private mayPublish;
|
|
297
|
+
/**
|
|
298
|
+
* Publish one shared refresh before starting its connector work. The first
|
|
299
|
+
* caller owns the scope and signal, and the flight lives `boundMs` from now
|
|
300
|
+
* at most; every later caller joins the result without gaining access to
|
|
301
|
+
* that context. A flight found past its bound is abandoned here, and this
|
|
302
|
+
* caller starts the fresh attempt.
|
|
289
303
|
*/
|
|
290
304
|
private startCatalogRefresh;
|
|
305
|
+
/**
|
|
306
|
+
* Give up on a flight that outlived its bound. Later readers start a fresh
|
|
307
|
+
* attempt, its joiners are told to, and nothing its owner lists from now on
|
|
308
|
+
* reaches a cache. The owner's work is left alone: it belongs to another
|
|
309
|
+
* request, which nothing here may reach into, and it can no longer publish.
|
|
310
|
+
*/
|
|
311
|
+
private abandonCatalogRefresh;
|
|
312
|
+
/**
|
|
313
|
+
* Wait on another caller's flight, no longer than its bound. Succeeds with
|
|
314
|
+
* its tools, or with `undefined` when it will not answer for this caller —
|
|
315
|
+
* past its bound, when this waiter abandons it, or failed for its owner
|
|
316
|
+
* alone — so the caller makes a fresh attempt. The timer is this waiter's
|
|
317
|
+
* own: on workerd it is what keeps a request that waits on another alive,
|
|
318
|
+
* and bounded.
|
|
319
|
+
*/
|
|
320
|
+
private joinCatalogRefresh;
|
|
291
321
|
/** Force a live listTools refresh and replace both catalog cache layers. */
|
|
292
322
|
private refreshTools;
|
|
293
323
|
private observeCatalogAccess;
|
|
@@ -303,9 +333,15 @@ export declare class Registry implements RegistryView {
|
|
|
303
333
|
* WeakMap neither roots the request scope nor lets its connector context
|
|
304
334
|
* escape into another request; settled entries are also removed eagerly.
|
|
305
335
|
*
|
|
336
|
+
* The read owns its signal, and each caller waits under its own: one that
|
|
337
|
+
* is cancelled while others wait leaves with its own failure, and the read
|
|
338
|
+
* is cancelled only when every caller has gone (src/runtime/shared-read.ts).
|
|
339
|
+
*
|
|
306
340
|
* The deployment-wide flight below this layer coalesces the actual live
|
|
307
|
-
* refresh across requests. Its
|
|
308
|
-
* later callers join only its result, never its
|
|
341
|
+
* refresh across requests. Its owner's context governs the listing, and its
|
|
342
|
+
* owner's deadline bounds it; later callers join only its result, never its
|
|
343
|
+
* request scope, and a failure that was the owner's alone sends them to a
|
|
344
|
+
* fresh attempt rather than to them.
|
|
309
345
|
*/
|
|
310
346
|
getTools(id: string, baseUrl: string, requestScope?: object, callOptions?: ConnectorOperationOptions, readOptions?: CatalogReadOptions): Promise<ToolDef[]>;
|
|
311
347
|
credentialDriftFor(id: string): Promise<string | undefined>;
|
package/dist/registry.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Cause, Clock, Deferred, Effect, Exit, Result } from "effect";
|
|
1
|
+
import { Cause, Clock, Deferred, Duration, Effect, Exit, Option, Result, } from "effect";
|
|
2
2
|
import { storedCredentialShape, } from "./credential-rules.js";
|
|
3
3
|
import { ConnectorCallError, msg } from "./errors.js";
|
|
4
4
|
import { ConnectorCallAdmissionController, aggregateCallAdmissionSnapshots, } from "./call-admission.js";
|
|
@@ -8,10 +8,14 @@ import { MAX_CATALOG_CHUNK_BYTES, MAX_CATALOG_TOOLS, MAX_SERIALIZED_CATALOG_BYTE
|
|
|
8
8
|
import { ObservedOutputSchemas } from "./result-shapes.js";
|
|
9
9
|
import { GUIDE_SUMMARY_LENGTH, normalizeGuideSummary, } from "./skills.js";
|
|
10
10
|
import { attachOAuthSealer, vaultOAuthSealer } from "./oauth-sealing.js";
|
|
11
|
+
import { attachCaller } from "./connector-caller.js";
|
|
11
12
|
import { closeScopeOnExit } from "./runtime/connector-scope.js";
|
|
12
13
|
import { detach, runEdge, withDeadlineEffect } from "./runtime/run.js";
|
|
14
|
+
import { SharedRead } from "./runtime/shared-read.js";
|
|
13
15
|
import { Logger as LoggerService } from "./runtime/services.js";
|
|
14
16
|
import { runOnPartition, storageDelete, storageGet, storageSet, } from "./runtime/storage.js";
|
|
17
|
+
import { DEFAULT_PROBE_TIMEOUT_MS, normalizeTimeoutMs } from "./timeout.js";
|
|
18
|
+
import { isExplicitlyReadOnly } from "./tool-safety.js";
|
|
15
19
|
const ID_RE = /^[a-z0-9_-]+$/;
|
|
16
20
|
const DEFAULT_TTL_SECONDS = 300;
|
|
17
21
|
const DEFAULT_STALE_SECONDS = 3600;
|
|
@@ -69,6 +73,17 @@ export function resolveMaxResultBytes(value, inherited) {
|
|
|
69
73
|
? value
|
|
70
74
|
: inherited;
|
|
71
75
|
}
|
|
76
|
+
/**
|
|
77
|
+
* What a joiner hears from a flight that will not answer for it: one that
|
|
78
|
+
* passed its bound, or failed for a reason that was its owner's alone. It
|
|
79
|
+
* never reaches a caller; a joiner that hears it makes a fresh attempt.
|
|
80
|
+
*/
|
|
81
|
+
class AbandonedCatalogRefresh extends Error {
|
|
82
|
+
constructor(id) {
|
|
83
|
+
super(`The catalog refresh of "${id}" was abandoned.`);
|
|
84
|
+
this.name = "AbandonedCatalogRefresh";
|
|
85
|
+
}
|
|
86
|
+
}
|
|
72
87
|
const CATALOG_CHUNK_IO_CONCURRENCY = 4;
|
|
73
88
|
/**
|
|
74
89
|
* Run `operation` over every chunk index or chunk with the fixed chunk I/O
|
|
@@ -129,7 +144,7 @@ export class Registry {
|
|
|
129
144
|
// completes once its storage work is done; see enqueueCatalogMutation.
|
|
130
145
|
/** Serialize persisted catalog set/delete operations within this isolate. */
|
|
131
146
|
catalogMutations = new Map();
|
|
132
|
-
/** Same-request cold loads share one
|
|
147
|
+
/** Same-request cold loads share one read without retaining the request.
|
|
133
148
|
* See documentation/architecture.md#the-two-lifetimes. */
|
|
134
149
|
requestCatalogLoads = new WeakMap();
|
|
135
150
|
/** One live refresh per connector across agent and operator requests. */
|
|
@@ -752,9 +767,10 @@ export class Registry {
|
|
|
752
767
|
});
|
|
753
768
|
});
|
|
754
769
|
}
|
|
755
|
-
// Remove the persisted catalog: the manifest
|
|
756
|
-
//
|
|
757
|
-
// read's error first, then the
|
|
770
|
+
// Remove the persisted catalog: the manifest and every chunk it names, all
|
|
771
|
+
// at once under the chunk I/O bound. Every delete is attempted; the effect
|
|
772
|
+
// fails with the manifest read's error first, then the manifest delete's,
|
|
773
|
+
// then the first chunk delete's by index.
|
|
758
774
|
deleteCatalog(id) {
|
|
759
775
|
return Effect.gen({ self: this }, function* () {
|
|
760
776
|
const read = yield* Effect.result(storageGet(this.catalogKey(id)));
|
|
@@ -762,18 +778,20 @@ export class Registry {
|
|
|
762
778
|
? this.parseCatalogManifest(read.success)
|
|
763
779
|
: null;
|
|
764
780
|
// The root is authoritative, so attempt its deletion even when the
|
|
765
|
-
// best-effort read needed for physical chunk cleanup failed.
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
781
|
+
// best-effort read needed for physical chunk cleanup failed. It need
|
|
782
|
+
// not land first: a reader that finds the root but not a chunk has an
|
|
783
|
+
// incomplete catalog, which is never served, and an eventually
|
|
784
|
+
// consistent store could show it that way whatever the order.
|
|
785
|
+
const keys = [
|
|
786
|
+
this.catalogKey(id),
|
|
787
|
+
...Array.from({ length: manifest?.chunkCount ?? 0 }, (_, index) => this.catalogChunkKey(id, manifest.revision, index)),
|
|
788
|
+
];
|
|
789
|
+
const deleted = yield* forEachChunk(keys, (key) => storageDelete(key));
|
|
773
790
|
if (Result.isFailure(read))
|
|
774
791
|
return yield* Effect.fail(read.failure);
|
|
775
|
-
|
|
776
|
-
|
|
792
|
+
const failed = deleted.find(Result.isFailure);
|
|
793
|
+
if (failed)
|
|
794
|
+
return yield* Effect.fail(failed.failure);
|
|
777
795
|
});
|
|
778
796
|
}
|
|
779
797
|
catalogGeneration(id) {
|
|
@@ -811,7 +829,7 @@ export class Registry {
|
|
|
811
829
|
Deferred.doneUnsafe(turn, Exit.void);
|
|
812
830
|
}
|
|
813
831
|
}
|
|
814
|
-
async refreshToolsWithContext(id, connector, ctx, skipPublicationWhenAborted = false) {
|
|
832
|
+
async refreshToolsWithContext(id, connector, ctx, skipPublicationWhenAborted = false, flight) {
|
|
815
833
|
const generation = this.catalogGeneration(id);
|
|
816
834
|
const tools = await connector.listTools(ctx);
|
|
817
835
|
// The listing a maintained proxy just served is also the only catalog
|
|
@@ -826,9 +844,10 @@ export class Registry {
|
|
|
826
844
|
if (skipPublicationWhenAborted && ctx.signal?.aborted)
|
|
827
845
|
return tools;
|
|
828
846
|
// The caller that began this refresh may still use its result, but a
|
|
829
|
-
// credential/OAuth change that landed while listTools was in flight
|
|
830
|
-
// the listing must not enter
|
|
831
|
-
|
|
847
|
+
// credential/OAuth change that landed while listTools was in flight, or a
|
|
848
|
+
// flight abandoned while it listed, means the listing must not enter
|
|
849
|
+
// either shared cache layer.
|
|
850
|
+
if (!this.mayPublish(id, generation, flight))
|
|
832
851
|
return tools;
|
|
833
852
|
if (tools.length > MAX_CATALOG_TOOLS) {
|
|
834
853
|
const message = `Connector "${id}" returned ${tools.length} tools, over the ` +
|
|
@@ -838,7 +857,7 @@ export class Registry {
|
|
|
838
857
|
}
|
|
839
858
|
const previous = this.cache.get(id);
|
|
840
859
|
const snapshot = await snapshotCatalog(tools);
|
|
841
|
-
if (
|
|
860
|
+
if (!this.mayPublish(id, generation, flight) ||
|
|
842
861
|
(skipPublicationWhenAborted && ctx.signal?.aborted)) {
|
|
843
862
|
return tools;
|
|
844
863
|
}
|
|
@@ -872,28 +891,88 @@ export class Registry {
|
|
|
872
891
|
return tools;
|
|
873
892
|
}
|
|
874
893
|
/**
|
|
875
|
-
*
|
|
876
|
-
*
|
|
877
|
-
*
|
|
894
|
+
* Whether a refresh may still enter the shared cache layers: its generation
|
|
895
|
+
* is current, and the flight it runs in has been neither abandoned nor
|
|
896
|
+
* outlived its bound. Asked at each publication point, since listing and
|
|
897
|
+
* snapshotting both yield, and a late result must never overwrite the one a
|
|
898
|
+
* fresh attempt published in the meantime.
|
|
899
|
+
*/
|
|
900
|
+
mayPublish(id, generation, flight) {
|
|
901
|
+
return (generation === this.catalogGeneration(id) &&
|
|
902
|
+
(!flight || (!flight.abandoned && Date.now() < flight.abandonAt)));
|
|
903
|
+
}
|
|
904
|
+
/**
|
|
905
|
+
* Publish one shared refresh before starting its connector work. The first
|
|
906
|
+
* caller owns the scope and signal, and the flight lives `boundMs` from now
|
|
907
|
+
* at most; every later caller joins the result without gaining access to
|
|
908
|
+
* that context. A flight found past its bound is abandoned here, and this
|
|
909
|
+
* caller starts the fresh attempt.
|
|
878
910
|
*/
|
|
879
|
-
startCatalogRefresh(id, generation, work) {
|
|
911
|
+
startCatalogRefresh(id, generation, boundMs, work, ownerLeft = () => false) {
|
|
880
912
|
const existing = this.catalogRefreshes.get(id);
|
|
881
|
-
if (existing?.generation === generation)
|
|
882
|
-
|
|
913
|
+
if (existing?.generation === generation) {
|
|
914
|
+
if (Date.now() < existing.abandonAt)
|
|
915
|
+
return { flight: existing };
|
|
916
|
+
this.abandonCatalogRefresh(id, existing);
|
|
917
|
+
}
|
|
883
918
|
const flight = {
|
|
884
919
|
generation,
|
|
920
|
+
abandonAt: Date.now() + boundMs,
|
|
921
|
+
abandoned: false,
|
|
885
922
|
outcome: Deferred.makeUnsafe(),
|
|
886
923
|
};
|
|
887
924
|
this.catalogRefreshes.set(id, flight);
|
|
888
|
-
const owner = Effect.onExit(work, (exit) => Effect.sync(() => {
|
|
925
|
+
const owner = Effect.onExit(work(flight), (exit) => Effect.sync(() => {
|
|
889
926
|
if (this.catalogRefreshes.get(id) === flight) {
|
|
890
927
|
this.catalogRefreshes.delete(id);
|
|
891
928
|
}
|
|
929
|
+
// A failure that was the owner's alone is no answer for anyone
|
|
930
|
+
// else: its own cancellation (its signal, read here in its own
|
|
931
|
+
// request), or anything that arrives once the flight is past its
|
|
932
|
+
// bound. Joiners make a fresh attempt under their own deadlines.
|
|
933
|
+
const ownersAlone = Exit.isFailure(exit) &&
|
|
934
|
+
(flight.abandoned ||
|
|
935
|
+
Date.now() >= flight.abandonAt ||
|
|
936
|
+
ownerLeft());
|
|
892
937
|
// Last, because joined fibers resume inside this call.
|
|
893
|
-
Deferred.doneUnsafe(flight.outcome, exit);
|
|
938
|
+
Deferred.doneUnsafe(flight.outcome, ownersAlone ? Exit.fail(new AbandonedCatalogRefresh(id)) : exit);
|
|
894
939
|
}));
|
|
895
940
|
return { flight, owner };
|
|
896
941
|
}
|
|
942
|
+
/**
|
|
943
|
+
* Give up on a flight that outlived its bound. Later readers start a fresh
|
|
944
|
+
* attempt, its joiners are told to, and nothing its owner lists from now on
|
|
945
|
+
* reaches a cache. The owner's work is left alone: it belongs to another
|
|
946
|
+
* request, which nothing here may reach into, and it can no longer publish.
|
|
947
|
+
*/
|
|
948
|
+
abandonCatalogRefresh(id, flight) {
|
|
949
|
+
if (flight.abandoned)
|
|
950
|
+
return;
|
|
951
|
+
flight.abandoned = true;
|
|
952
|
+
if (this.catalogRefreshes.get(id) === flight) {
|
|
953
|
+
this.catalogRefreshes.delete(id);
|
|
954
|
+
}
|
|
955
|
+
this.opts.logger.warn(`[connecta] connector "${id}" catalog refresh outlived its bound; abandoning it for a fresh attempt.`);
|
|
956
|
+
Deferred.doneUnsafe(flight.outcome, Exit.fail(new AbandonedCatalogRefresh(id)));
|
|
957
|
+
}
|
|
958
|
+
/**
|
|
959
|
+
* Wait on another caller's flight, no longer than its bound. Succeeds with
|
|
960
|
+
* its tools, or with `undefined` when it will not answer for this caller —
|
|
961
|
+
* past its bound, when this waiter abandons it, or failed for its owner
|
|
962
|
+
* alone — so the caller makes a fresh attempt. The timer is this waiter's
|
|
963
|
+
* own: on workerd it is what keeps a request that waits on another alive,
|
|
964
|
+
* and bounded.
|
|
965
|
+
*/
|
|
966
|
+
joinCatalogRefresh(id, flight) {
|
|
967
|
+
return Effect.suspend(() => Deferred.await(flight.outcome).pipe(Effect.timeoutOption(Duration.millis(Math.max(0, flight.abandonAt - Date.now()))), Effect.flatMap((joined) => Option.isSome(joined)
|
|
968
|
+
? Effect.succeed(joined.value)
|
|
969
|
+
: Effect.sync(() => {
|
|
970
|
+
this.abandonCatalogRefresh(id, flight);
|
|
971
|
+
return undefined;
|
|
972
|
+
})), Effect.catch((error) => error instanceof AbandonedCatalogRefresh
|
|
973
|
+
? Effect.succeed(undefined)
|
|
974
|
+
: Effect.fail(error))));
|
|
975
|
+
}
|
|
897
976
|
/** Force a live listTools refresh and replace both catalog cache layers. */
|
|
898
977
|
async refreshTools(id, baseUrl, requestScope, callOptions = {}) {
|
|
899
978
|
const connector = this.connectors.get(id);
|
|
@@ -901,14 +980,27 @@ export class Registry {
|
|
|
901
980
|
throw new Error(`Unknown connector "${id}"`);
|
|
902
981
|
if (connector.staticTools)
|
|
903
982
|
return connector.staticTools;
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
983
|
+
// A flight lives as long as its owner will wait for it. Past that, the
|
|
984
|
+
// owner may have answered and gone, and on workerd its I/O went with it.
|
|
985
|
+
const boundMs = normalizeTimeoutMs(callOptions.timeoutMs) ?? DEFAULT_PROBE_TIMEOUT_MS;
|
|
986
|
+
for (;;) {
|
|
987
|
+
const { flight, owner } = this.startCatalogRefresh(id, this.catalogGeneration(id), boundMs, (flight) => Effect.tryPromise({
|
|
988
|
+
try: () => this.refreshToolsWithContext(id, connector, this.contextFor(id, baseUrl, requestScope, callOptions), false, flight),
|
|
989
|
+
catch: (error) => error,
|
|
990
|
+
}), () => callOptions.signal?.aborted === true);
|
|
991
|
+
// The owner lists in its own request. A joiner waits through an edge of
|
|
992
|
+
// its own, under its own signal, and whatever it does with the tools
|
|
993
|
+
// happens after that, back in its request (see "Effect inside" in
|
|
994
|
+
// documentation/architecture.md). A flight that will not answer for it
|
|
995
|
+
// sends it round again, to join a fresh attempt or to own one.
|
|
996
|
+
if (owner)
|
|
997
|
+
return runEdge(owner);
|
|
998
|
+
const joined = await runEdge(this.joinCatalogRefresh(id, flight), {
|
|
999
|
+
signal: callOptions.signal,
|
|
1000
|
+
});
|
|
1001
|
+
if (joined)
|
|
1002
|
+
return joined;
|
|
1003
|
+
}
|
|
912
1004
|
}
|
|
913
1005
|
observeCatalogAccess(id, state) {
|
|
914
1006
|
this.catalogAccess.set(id, {
|
|
@@ -928,7 +1020,7 @@ export class Registry {
|
|
|
928
1020
|
// The refresh this request starts, if none of this generation is live.
|
|
929
1021
|
// Its scope is closed however it ends, deadline included, before the
|
|
930
1022
|
// flight completes.
|
|
931
|
-
const refresh = withDeadlineEffect((signal) => Effect.gen({ self: this }, function* () {
|
|
1023
|
+
const refresh = (flight) => withDeadlineEffect((signal) => Effect.gen({ self: this }, function* () {
|
|
932
1024
|
const current = this.cache.get(id);
|
|
933
1025
|
if (current && current.exp > Date.now())
|
|
934
1026
|
return current.tools;
|
|
@@ -942,7 +1034,7 @@ export class Registry {
|
|
|
942
1034
|
});
|
|
943
1035
|
yield* closeScopeOnExit(connector, ctx, defer);
|
|
944
1036
|
return yield* Effect.tryPromise({
|
|
945
|
-
try: () => this.refreshToolsWithContext(id, connector, ctx, true),
|
|
1037
|
+
try: () => this.refreshToolsWithContext(id, connector, ctx, true, flight),
|
|
946
1038
|
catch: (error) => error,
|
|
947
1039
|
});
|
|
948
1040
|
}), {
|
|
@@ -952,14 +1044,15 @@ export class Registry {
|
|
|
952
1044
|
const logFailure = (err) => {
|
|
953
1045
|
this.opts.logger.warn(`[connecta] connector "${id}" deferred catalog refresh failed: ${msg(err)}`);
|
|
954
1046
|
};
|
|
955
|
-
const { flight, owner } = this.startCatalogRefresh(id, expectedGeneration, Effect.scoped(refresh));
|
|
1047
|
+
const { flight, owner } = this.startCatalogRefresh(id, expectedGeneration, options.refreshTimeoutMs, (flight) => Effect.scoped(refresh(flight)));
|
|
956
1048
|
if (owner) {
|
|
957
1049
|
// Runs past this response, under the runtime's waitUntil below.
|
|
958
1050
|
flight.deferredTail = detach(Effect.catchCause(owner, (cause) => Effect.sync(() => logFailure(Cause.squash(cause)))));
|
|
959
1051
|
}
|
|
960
1052
|
// 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
|
-
|
|
1053
|
+
// every reader after it, and waits through an edge of its own, no longer
|
|
1054
|
+
// than the flight's bound.
|
|
1055
|
+
flight.deferredTail ??= runEdge(this.joinCatalogRefresh(id, flight)).then(() => { }, logFailure);
|
|
963
1056
|
try {
|
|
964
1057
|
defer(flight.deferredTail);
|
|
965
1058
|
}
|
|
@@ -1075,9 +1168,15 @@ export class Registry {
|
|
|
1075
1168
|
* WeakMap neither roots the request scope nor lets its connector context
|
|
1076
1169
|
* escape into another request; settled entries are also removed eagerly.
|
|
1077
1170
|
*
|
|
1171
|
+
* The read owns its signal, and each caller waits under its own: one that
|
|
1172
|
+
* is cancelled while others wait leaves with its own failure, and the read
|
|
1173
|
+
* is cancelled only when every caller has gone (src/runtime/shared-read.ts).
|
|
1174
|
+
*
|
|
1078
1175
|
* The deployment-wide flight below this layer coalesces the actual live
|
|
1079
|
-
* refresh across requests. Its
|
|
1080
|
-
* later callers join only its result, never its
|
|
1176
|
+
* refresh across requests. Its owner's context governs the listing, and its
|
|
1177
|
+
* owner's deadline bounds it; later callers join only its result, never its
|
|
1178
|
+
* request scope, and a failure that was the owner's alone sends them to a
|
|
1179
|
+
* fresh attempt rather than to them.
|
|
1081
1180
|
*/
|
|
1082
1181
|
async getTools(id, baseUrl, requestScope, callOptions = {}, readOptions) {
|
|
1083
1182
|
const connector = this.connectors.get(id);
|
|
@@ -1093,20 +1192,23 @@ export class Registry {
|
|
|
1093
1192
|
loads = new Map();
|
|
1094
1193
|
this.requestCatalogLoads.set(requestScope, loads);
|
|
1095
1194
|
}
|
|
1096
|
-
const
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1195
|
+
const requestLoads = loads;
|
|
1196
|
+
let load = requestLoads.get(id);
|
|
1197
|
+
if (!load) {
|
|
1198
|
+
const started = new SharedRead((signal) => this.loadTools(id, baseUrl, requestScope, { ...callOptions, signal }, readOptions),
|
|
1199
|
+
// Settled or cancelled, the read leaves the map before any caller
|
|
1200
|
+
// resumes, so the next one asks the caches afresh.
|
|
1201
|
+
() => {
|
|
1202
|
+
if (requestLoads.get(id) === started)
|
|
1203
|
+
requestLoads.delete(id);
|
|
1204
|
+
if (requestLoads.size === 0) {
|
|
1205
|
+
this.requestCatalogLoads.delete(requestScope);
|
|
1206
|
+
}
|
|
1207
|
+
});
|
|
1208
|
+
requestLoads.set(id, started);
|
|
1209
|
+
load = started;
|
|
1109
1210
|
}
|
|
1211
|
+
return runEdge(load.join(callOptions.signal));
|
|
1110
1212
|
}
|
|
1111
1213
|
async credentialDriftFor(id) {
|
|
1112
1214
|
const credential = this.connectors.get(id)?.credential;
|
|
@@ -1231,15 +1333,17 @@ class ScopedRegistryView {
|
|
|
1231
1333
|
throw new Error(`Unknown connector "${args[0]}"`);
|
|
1232
1334
|
}
|
|
1233
1335
|
const tools = await registry.getTools(...args);
|
|
1234
|
-
const
|
|
1336
|
+
const guarded = this.scope.guardedToolAccess?.get(args[0]);
|
|
1337
|
+
const granted = this.scope.toolAccess?.get(args[0]) ?? guarded;
|
|
1235
1338
|
if (!granted)
|
|
1236
1339
|
return tools;
|
|
1237
1340
|
// Every consumer — search, describe, call_tool, and a program's
|
|
1238
1341
|
// connecta.call — resolves through this list, so an ungranted tool is
|
|
1239
1342
|
// indistinguishable from one the connector never had.
|
|
1240
|
-
const visible = tools.filter((tool) => granted.has(tool.name)
|
|
1343
|
+
const visible = tools.filter((tool) => granted.has(tool.name) &&
|
|
1344
|
+
(!guarded?.has(tool.name) || isExplicitlyReadOnly(tool)));
|
|
1241
1345
|
if (visible.length < granted.size) {
|
|
1242
|
-
const present = new Set(
|
|
1346
|
+
const present = new Set(tools.map((tool) => tool.name));
|
|
1243
1347
|
for (const name of granted) {
|
|
1244
1348
|
if (!present.has(name))
|
|
1245
1349
|
this.root.noteAbsentGrant(args[0], name);
|
|
@@ -1251,7 +1355,7 @@ class ScopedRegistryView {
|
|
|
1251
1355
|
const registry = this.registryFor(args[0]);
|
|
1252
1356
|
if (!registry)
|
|
1253
1357
|
throw new Error(`Unknown connector "${args[0]}"`);
|
|
1254
|
-
return registry.contextFor(...args);
|
|
1358
|
+
return attachCaller(registry.contextFor(...args), this.scope.caller);
|
|
1255
1359
|
}
|
|
1256
1360
|
admitCall(...args) {
|
|
1257
1361
|
const registry = this.registryFor(args[0]);
|