@zackbart/connecta 0.24.3 → 0.25.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +58 -30
- package/CHANGELOG.md +317 -1
- package/README.md +5 -6
- package/dist/advertised-schema.js +53 -0
- package/dist/auth/downstream-oauth.js +490 -106
- package/dist/branding.d.ts +31 -2
- package/dist/branding.js +116 -8
- package/dist/call-admission.js +139 -113
- package/dist/catalog-service.js +141 -105
- package/dist/catalog.js +258 -33
- package/dist/concurrency.js +24 -23
- package/dist/connector-scope.js +6 -37
- package/dist/connectors/api.d.ts +3 -3
- package/dist/connectors/api.js +10 -2
- package/dist/connectors/guarded-fetch.js +34 -4
- package/dist/connectors/remote-mcp.d.ts +6 -5
- package/dist/connectors/remote-mcp.js +325 -245
- package/dist/credential-contract.d.ts +9 -0
- package/dist/credential-rules.js +2 -2
- package/dist/credentials.d.ts +6 -1
- package/dist/credentials.js +45 -3
- package/dist/errors.d.ts +11 -3
- package/dist/errors.js +2 -1
- package/dist/execute.js +363 -257
- package/dist/executor-admission.js +67 -50
- package/dist/executors/quickjs-runtime.js +4 -2
- package/dist/executors/quickjs.js +251 -210
- package/dist/fix-prompt.js +37 -0
- package/dist/index.d.ts +11 -2
- package/dist/index.js +63 -21
- package/dist/invocation.js +324 -285
- package/dist/meta-tools.js +304 -114
- package/dist/oauth-callback-outcome.js +94 -0
- package/dist/oauth-sealing.js +29 -0
- package/dist/operator-ui/fix-prompts.js +87 -0
- package/dist/operator-ui/generated.js +2 -2
- package/dist/operator-ui/model.d.ts +46 -1
- package/dist/operator-ui/setup-commands.js +53 -0
- package/dist/operator-ui/view.js +109 -2
- package/dist/program-source.js +205 -0
- package/dist/providers/cloudflare.d.ts +14 -2
- package/dist/providers/cloudflare.js +107 -16
- package/dist/providers/linear.d.ts +26 -4
- package/dist/providers/linear.js +19 -4
- package/dist/providers/mixpanel.d.ts +16 -3
- package/dist/providers/mixpanel.js +13 -2
- package/dist/providers/notion.d.ts +8 -1
- package/dist/providers/notion.js +85 -12
- package/dist/providers/revenuecat.d.ts +30 -4
- package/dist/providers/revenuecat.js +70 -5
- package/dist/providers/stripe.d.ts +10 -3
- package/dist/providers/stripe.js +30 -4
- package/dist/providers/vercel.js +12 -2
- package/dist/registry.d.ts +13 -5
- package/dist/registry.js +317 -258
- package/dist/routes/activity.js +120 -137
- package/dist/routes/credentials.js +174 -163
- package/dist/routes/mcp.js +129 -70
- package/dist/routes/oauth-management.js +60 -71
- package/dist/routes/oauth.js +44 -16
- package/dist/routes/operator.js +82 -0
- package/dist/routes/shared.d.ts +2 -0
- package/dist/routes/ui-data.js +210 -0
- package/dist/routes/ui.js +64 -32
- package/dist/runtime/admission.js +43 -0
- package/dist/runtime/call-admission.js +67 -0
- package/dist/runtime/connector-scope.js +72 -0
- package/dist/runtime/run.js +174 -0
- package/dist/runtime/services.js +130 -0
- package/dist/runtime/storage.js +66 -0
- package/dist/server.js +111 -89
- package/dist/skills.js +1 -1
- package/dist/storage/file.js +39 -2
- package/dist/storage/memory.js +28 -16
- package/dist/timeout.js +17 -47
- package/dist/types.d.ts +64 -7
- package/dist/ui.d.ts +16 -2
- package/dist/ui.js +68 -176
- package/dist/url-safety.js +157 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/documentation/architecture.md +368 -182
- package/documentation/auth.md +277 -176
- package/documentation/code-mode.md +479 -329
- package/documentation/meta-tools.md +494 -417
- package/ethos.md +44 -47
- package/examples/worker/AGENTS.md +2 -1
- package/examples/worker/README.md +62 -14
- package/examples/worker/src/d1-storage.ts +116 -0
- package/examples/worker/src/index.ts +18 -21
- package/examples/worker/wrangler.jsonc +16 -4
- package/package.json +11 -4
- package/templates/node/.env.example +3 -3
- package/templates/node/AGENTS.md +5 -4
- package/templates/node/README.md +2 -1
- package/templates/node/package.json +1 -1
- package/templates/node/src/index.ts +23 -22
- package/dist/auth/downstream-oauth.d.ts +0 -200
- package/dist/catalog-fingerprint.d.ts +0 -19
- package/dist/catalog-limits.d.ts +0 -9
- package/dist/catalog-service.d.ts +0 -263
- package/dist/catalog.d.ts +0 -87
- package/dist/concurrency.d.ts +0 -3
- package/dist/connectors/guarded-fetch.d.ts +0 -92
- package/dist/credential-rules.d.ts +0 -85
- package/dist/execute.d.ts +0 -154
- package/dist/executor-result.d.ts +0 -10
- package/dist/executors/quickjs-child.d.ts +0 -1
- package/dist/executors/quickjs-protocol.d.ts +0 -67
- package/dist/favicon.d.ts +0 -1
- package/dist/identity.d.ts +0 -4
- package/dist/invocation.d.ts +0 -74
- package/dist/mcp-result.d.ts +0 -8
- package/dist/meta-tools.d.ts +0 -129
- package/dist/operator-ui/generated.d.ts +0 -2
- package/dist/operator-ui/view.d.ts +0 -151
- package/dist/result-shapes.d.ts +0 -13
- package/dist/retired-toolkits.d.ts +0 -2
- package/dist/routes/activity.d.ts +0 -2
- package/dist/routes/credentials.d.ts +0 -2
- package/dist/routes/mcp.d.ts +0 -10
- package/dist/routes/oauth-management.d.ts +0 -2
- package/dist/routes/oauth.d.ts +0 -2
- package/dist/routes/ui.d.ts +0 -2
- package/dist/server.d.ts +0 -9
- package/dist/skills.d.ts +0 -44
- package/dist/timeout.d.ts +0 -21
- package/dist/tool-safety.d.ts +0 -9
- package/documentation/call-admission.md +0 -158
- package/documentation/cloudflare.md +0 -471
- package/documentation/connector-guides.md +0 -176
- package/documentation/connectors.md +0 -431
- package/documentation/linear.md +0 -193
- package/documentation/mixpanel.md +0 -160
- package/documentation/notion.md +0 -308
- package/documentation/operations.md +0 -359
- package/documentation/operator-ui.md +0 -135
- package/documentation/optional-modules-upgrade.md +0 -243
- package/documentation/provider-conventions.md +0 -729
- package/documentation/request-admission.md +0 -204
- package/documentation/revenuecat.md +0 -305
- package/documentation/storage-and-credentials.md +0 -254
- package/documentation/stripe.md +0 -262
- package/documentation/upgrading.md +0 -768
- package/documentation/vercel.md +0 -241
package/dist/branding.d.ts
CHANGED
|
@@ -1,6 +1,13 @@
|
|
|
1
|
-
import type { ConnectaBranding, UiAuthConfig } from "./types.js";
|
|
1
|
+
import type { ConnectaBranding, ConnectaTheme, UiAuthConfig } from "./types.js";
|
|
2
2
|
/** Connecta's default monochrome "C" mark. */
|
|
3
3
|
export declare const CONNECTA_FAVICON_SVG = "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 32 32\">\n <style>\n .fg { fill: #000 }\n @media (prefers-color-scheme: dark) { .fg { fill: #fff } }\n </style>\n <path class=\"fg\" d=\"M27 9.4A13 13 0 1 0 27 22.6l-4.4-2.5a8 8 0 1 1 0-8.2z\"/>\n</svg>";
|
|
4
|
+
export interface ResolvedTheme {
|
|
5
|
+
accent?: string;
|
|
6
|
+
radius?: string;
|
|
7
|
+
fontFamily?: string;
|
|
8
|
+
monoFamily?: string;
|
|
9
|
+
colorScheme: "system" | "light" | "dark";
|
|
10
|
+
}
|
|
4
11
|
interface ResolvedBranding {
|
|
5
12
|
productName: string;
|
|
6
13
|
productUrl?: string;
|
|
@@ -12,8 +19,30 @@ interface ResolvedBranding {
|
|
|
12
19
|
/** href for the page's icon link. */
|
|
13
20
|
faviconHref: string;
|
|
14
21
|
themeColor: string;
|
|
22
|
+
/** Only the tokens that survived their gate; the stylesheet owns the rest. */
|
|
23
|
+
theme: ResolvedTheme;
|
|
15
24
|
}
|
|
16
25
|
export declare function resolveBranding(branding?: ConnectaBranding): ResolvedBranding;
|
|
26
|
+
/**
|
|
27
|
+
* Read a theme the way branding URLs are read: gate every field, drop what
|
|
28
|
+
* fails, never throw. This runs during `createConnecta`, so a malformed value
|
|
29
|
+
* has to fall back to the stylesheet default instead of refusing to serve the
|
|
30
|
+
* page. `droppedThemeTokens` names the drops for the startup warning.
|
|
31
|
+
*/
|
|
32
|
+
export declare function resolveTheme(theme?: ConnectaTheme): ResolvedTheme;
|
|
33
|
+
/**
|
|
34
|
+
* Names of the theme tokens the operator set that failed their gate. Same
|
|
35
|
+
* contract as `droppedBrandingUrls`: rendering falls back silently, so this is
|
|
36
|
+
* the only place an operator learns their value never reached the page.
|
|
37
|
+
*/
|
|
38
|
+
export declare function droppedThemeTokens(theme?: ConnectaTheme): string[];
|
|
39
|
+
/**
|
|
40
|
+
* The resolved theme as a `:root` block, or "" when a deployment configured
|
|
41
|
+
* nothing. It is emitted after the stylesheet so it overrides the defaults.
|
|
42
|
+
* There is no escaping here: every value has already passed a gate above, and
|
|
43
|
+
* anything that would need escaping is dropped rather than rewritten.
|
|
44
|
+
*/
|
|
45
|
+
export declare function themeCss(theme: ResolvedTheme): string;
|
|
17
46
|
/**
|
|
18
47
|
* Names of the branding URLs the operator set that failed their gate and were
|
|
19
48
|
* replaced by a default. Lives beside the gates so the startup warning cannot
|
|
@@ -55,7 +84,7 @@ export declare function isSafeHttpsUrl(url: unknown): boolean;
|
|
|
55
84
|
* `frontendApiUrl` is required, so anything that fails its gate is a drop.
|
|
56
85
|
* `signInUrl` and `signUpUrl` are optional, so only a value the operator
|
|
57
86
|
* *supplied* and the gate then rejected is worth a warning — an unset field
|
|
58
|
-
* took no default away from anyone. `
|
|
87
|
+
* took no default away from anyone. `isSetValue` decides that, the same way
|
|
59
88
|
* and for the same reasons it decides it for the branding URLs: a warning that
|
|
60
89
|
* fires for one and not the other would be reporting on the field rather than
|
|
61
90
|
* on the operator's intent. Rendering is not consulted for this: it drops on
|
package/dist/branding.js
CHANGED
|
@@ -38,17 +38,125 @@ export function resolveBranding(branding) {
|
|
|
38
38
|
? faviconHref
|
|
39
39
|
: DEFAULT_FAVICON_HREF,
|
|
40
40
|
themeColor: trimmedString(branding?.themeColor) ?? "#ffffff",
|
|
41
|
+
theme: resolveTheme(branding?.theme),
|
|
41
42
|
};
|
|
42
43
|
}
|
|
44
|
+
/**
|
|
45
|
+
* Hex colors only: `#rgb`, `#rrggbb`, `#rrggbbaa`. A hex value cannot carry a
|
|
46
|
+
* `url()`, a `var()`, or a closing brace into the `:root` block it is written
|
|
47
|
+
* into, which is the whole reason the gate is this narrow.
|
|
48
|
+
*/
|
|
49
|
+
const HEX_COLOR = /^#(?:[0-9a-f]{3}|[0-9a-f]{4}|[0-9a-f]{6}|[0-9a-f]{8})$/i;
|
|
50
|
+
/** A single non-negative CSS length, or a bare number the caller reads as px. */
|
|
51
|
+
const CSS_LENGTH = /^(?:0|[0-9]{1,3}(?:\.[0-9]{1,3})?)(px|rem|em)?$/;
|
|
52
|
+
/**
|
|
53
|
+
* One family name: bare, or wrapped in matching quotes. The character class
|
|
54
|
+
* excludes everything CSS needs to end a declaration or open a function (`;`,
|
|
55
|
+
* `{`, `}`, `(`, `)`, backslash, `<`, `>`, `@`, `*`, `/`, `:`), and matched
|
|
56
|
+
* quotes mean the value cannot leave an open string that swallows the CSS
|
|
57
|
+
* after it.
|
|
58
|
+
*/
|
|
59
|
+
const FONT_NAME = /^(?:"[a-z0-9 ._-]+"|'[a-z0-9 ._-]+'|[a-z][a-z0-9 ._-]*)$/i;
|
|
60
|
+
/** A font-family list: comma-separated names and nothing else. */
|
|
61
|
+
function isFontStack(value) {
|
|
62
|
+
if (value.length > 200)
|
|
63
|
+
return false;
|
|
64
|
+
const names = value.split(",");
|
|
65
|
+
return names.length <= 12 &&
|
|
66
|
+
names.every((name) => FONT_NAME.test(name.trim()));
|
|
67
|
+
}
|
|
68
|
+
const COLOR_SCHEMES = ["system", "light", "dark"];
|
|
69
|
+
/**
|
|
70
|
+
* Read a theme the way branding URLs are read: gate every field, drop what
|
|
71
|
+
* fails, never throw. This runs during `createConnecta`, so a malformed value
|
|
72
|
+
* has to fall back to the stylesheet default instead of refusing to serve the
|
|
73
|
+
* page. `droppedThemeTokens` names the drops for the startup warning.
|
|
74
|
+
*/
|
|
75
|
+
export function resolveTheme(theme) {
|
|
76
|
+
const accent = trimmedString(theme?.accent);
|
|
77
|
+
const fontFamily = trimmedString(theme?.fontFamily);
|
|
78
|
+
const monoFamily = trimmedString(theme?.monoFamily);
|
|
79
|
+
const scheme = trimmedString(theme?.colorScheme);
|
|
80
|
+
const radius = radiusLength(theme?.radius);
|
|
81
|
+
return {
|
|
82
|
+
...(accent && HEX_COLOR.test(accent) ? { accent } : {}),
|
|
83
|
+
...(radius !== undefined ? { radius } : {}),
|
|
84
|
+
...(fontFamily && isFontStack(fontFamily) ? { fontFamily } : {}),
|
|
85
|
+
...(monoFamily && isFontStack(monoFamily) ? { monoFamily } : {}),
|
|
86
|
+
colorScheme: COLOR_SCHEMES.includes(scheme)
|
|
87
|
+
? scheme
|
|
88
|
+
: "system",
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* `radius` accepts a number as well as a string, since a config file is more
|
|
93
|
+
* likely to say `10` than `"10px"`. A bare number means pixels; a string must
|
|
94
|
+
* carry its own unit or be zero.
|
|
95
|
+
*/
|
|
96
|
+
function radiusLength(radius) {
|
|
97
|
+
if (typeof radius === "number") {
|
|
98
|
+
return Number.isFinite(radius) && radius >= 0 && radius <= 999
|
|
99
|
+
? `${radius}px`
|
|
100
|
+
: undefined;
|
|
101
|
+
}
|
|
102
|
+
const value = trimmedString(radius);
|
|
103
|
+
if (!value || !CSS_LENGTH.test(value))
|
|
104
|
+
return undefined;
|
|
105
|
+
return /[a-z]$/i.test(value) || value === "0" ? value : `${value}px`;
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Names of the theme tokens the operator set that failed their gate. Same
|
|
109
|
+
* contract as `droppedBrandingUrls`: rendering falls back silently, so this is
|
|
110
|
+
* the only place an operator learns their value never reached the page.
|
|
111
|
+
*/
|
|
112
|
+
export function droppedThemeTokens(theme) {
|
|
113
|
+
if (!theme)
|
|
114
|
+
return [];
|
|
115
|
+
const resolved = resolveTheme(theme);
|
|
116
|
+
const dropped = [];
|
|
117
|
+
if (isSetValue(theme.accent) && !resolved.accent)
|
|
118
|
+
dropped.push("accent");
|
|
119
|
+
if (isSetValue(theme.radius) && resolved.radius === undefined) {
|
|
120
|
+
dropped.push("radius");
|
|
121
|
+
}
|
|
122
|
+
if (isSetValue(theme.fontFamily) && !resolved.fontFamily) {
|
|
123
|
+
dropped.push("fontFamily");
|
|
124
|
+
}
|
|
125
|
+
if (isSetValue(theme.monoFamily) && !resolved.monoFamily) {
|
|
126
|
+
dropped.push("monoFamily");
|
|
127
|
+
}
|
|
128
|
+
// Compared against the trimmed value the resolver reads, so `" dark "` is
|
|
129
|
+
// not reported as dropped when it was applied.
|
|
130
|
+
if (isSetValue(theme.colorScheme) &&
|
|
131
|
+
trimmedString(theme.colorScheme) !== resolved.colorScheme) {
|
|
132
|
+
dropped.push("colorScheme");
|
|
133
|
+
}
|
|
134
|
+
return dropped.map((token) => `theme.${token}`);
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* The resolved theme as a `:root` block, or "" when a deployment configured
|
|
138
|
+
* nothing. It is emitted after the stylesheet so it overrides the defaults.
|
|
139
|
+
* There is no escaping here: every value has already passed a gate above, and
|
|
140
|
+
* anything that would need escaping is dropped rather than rewritten.
|
|
141
|
+
*/
|
|
142
|
+
export function themeCss(theme) {
|
|
143
|
+
const declarations = [
|
|
144
|
+
theme.accent ? `--accent:${theme.accent}` : "",
|
|
145
|
+
theme.radius !== undefined ? `--radius:${theme.radius}` : "",
|
|
146
|
+
theme.fontFamily ? `--sans:${theme.fontFamily}` : "",
|
|
147
|
+
theme.monoFamily ? `--mono:${theme.monoFamily}` : "",
|
|
148
|
+
].filter(Boolean);
|
|
149
|
+
return declarations.length ? `:root{${declarations.join(";")}}` : "";
|
|
150
|
+
}
|
|
43
151
|
/**
|
|
44
152
|
* Whether the operator meant to supply a value here — the question every
|
|
45
|
-
* dropped-
|
|
153
|
+
* dropped-value warning asks before naming a field, and one definition so the
|
|
46
154
|
* branding and `uiAuth` warnings cannot answer it differently. A non-string
|
|
47
155
|
* counts as set: the intent was there and is exactly what the warning reports
|
|
48
156
|
* on. A blank or whitespace-only string does not; that is indistinguishable
|
|
49
157
|
* from leaving the field alone, and both take the default silently.
|
|
50
158
|
*/
|
|
51
|
-
function
|
|
159
|
+
function isSetValue(value) {
|
|
52
160
|
return typeof value === "string"
|
|
53
161
|
? trimmedString(value) !== undefined
|
|
54
162
|
: value !== undefined && value !== null;
|
|
@@ -65,13 +173,13 @@ export function droppedBrandingUrls(branding) {
|
|
|
65
173
|
const resolved = resolveBranding(branding);
|
|
66
174
|
const faviconHref = branding.favicon?.href;
|
|
67
175
|
return [
|
|
68
|
-
...(
|
|
176
|
+
...(isSetValue(branding.productUrl) && !resolved.productUrl
|
|
69
177
|
? ["productUrl"]
|
|
70
178
|
: []),
|
|
71
|
-
...(
|
|
179
|
+
...(isSetValue(branding.ownerUrl) && !resolved.ownerUrl
|
|
72
180
|
? ["ownerUrl"]
|
|
73
181
|
: []),
|
|
74
|
-
...(
|
|
182
|
+
...(isSetValue(faviconHref) &&
|
|
75
183
|
trimmedString(faviconHref) !== resolved.faviconHref
|
|
76
184
|
? ["favicon.href"]
|
|
77
185
|
: []),
|
|
@@ -153,7 +261,7 @@ export function isSafeHttpsUrl(url) {
|
|
|
153
261
|
* `frontendApiUrl` is required, so anything that fails its gate is a drop.
|
|
154
262
|
* `signInUrl` and `signUpUrl` are optional, so only a value the operator
|
|
155
263
|
* *supplied* and the gate then rejected is worth a warning — an unset field
|
|
156
|
-
* took no default away from anyone. `
|
|
264
|
+
* took no default away from anyone. `isSetValue` decides that, the same way
|
|
157
265
|
* and for the same reasons it decides it for the branding URLs: a warning that
|
|
158
266
|
* fires for one and not the other would be reporting on the field rather than
|
|
159
267
|
* on the operator's intent. Rendering is not consulted for this: it drops on
|
|
@@ -166,10 +274,10 @@ export function droppedUiAuthUrls(uiAuth) {
|
|
|
166
274
|
return [];
|
|
167
275
|
return [
|
|
168
276
|
...(isSafeHttpsUrl(uiAuth.frontendApiUrl) ? [] : ["uiAuth.frontendApiUrl"]),
|
|
169
|
-
...(
|
|
277
|
+
...(isSetValue(uiAuth.signInUrl) && !isSafeHttpsUrl(uiAuth.signInUrl)
|
|
170
278
|
? ["uiAuth.signInUrl"]
|
|
171
279
|
: []),
|
|
172
|
-
...(
|
|
280
|
+
...(isSetValue(uiAuth.signUpUrl) && !isSafeHttpsUrl(uiAuth.signUpUrl)
|
|
173
281
|
? ["uiAuth.signUpUrl"]
|
|
174
282
|
: []),
|
|
175
283
|
];
|
package/dist/call-admission.js
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
|
+
import { Deferred, Duration, Effect } from "effect";
|
|
1
2
|
import { ConnectorCallError } from "./errors.js";
|
|
3
|
+
import { provideCallAdmissionProgram, startCallAdmission, } from "./runtime/call-admission.js";
|
|
4
|
+
import { fromSignal, runEdge } from "./runtime/run.js";
|
|
2
5
|
const DEFAULT_MAX_QUEUE_SIZE = 32;
|
|
3
6
|
const DEFAULT_QUEUE_TIMEOUT_MS = 5_000;
|
|
4
7
|
const DEFAULT_RETRY_AFTER_MS = 1_000;
|
|
@@ -117,103 +120,10 @@ export class ConnectorCallAdmissionController {
|
|
|
117
120
|
this.partitionKey = rule.partitionKey;
|
|
118
121
|
}
|
|
119
122
|
acquire(input) {
|
|
120
|
-
//
|
|
121
|
-
// `input
|
|
122
|
-
// limiter's payload-free state contract.
|
|
123
|
-
|
|
124
|
-
if (this.closed) {
|
|
125
|
-
return Promise.reject(new CallAdmissionError("closed", "unavailable", `Connector "${this.connectorId}" call admission is closed.`));
|
|
126
|
-
}
|
|
127
|
-
if (signal?.aborted) {
|
|
128
|
-
this.cancelledTotal++;
|
|
129
|
-
return Promise.reject(this.cancelled(signal));
|
|
130
|
-
}
|
|
131
|
-
let key;
|
|
132
|
-
try {
|
|
133
|
-
key = this.partitionKey
|
|
134
|
-
? this.partitionKey({
|
|
135
|
-
toolName: input.toolName,
|
|
136
|
-
args: input.args,
|
|
137
|
-
})
|
|
138
|
-
: DEFAULT_PARTITION_KEY;
|
|
139
|
-
}
|
|
140
|
-
catch (cause) {
|
|
141
|
-
this.rejectedTotal++;
|
|
142
|
-
return Promise.reject(new CallAdmissionError("partition", "connector_call_failed", `Connector "${this.connectorId}" call-admission partitionKey threw.`, { cause }));
|
|
143
|
-
}
|
|
144
|
-
if (typeof key !== "string" ||
|
|
145
|
-
enc.encode(key).length > MAX_PARTITION_KEY_BYTES) {
|
|
146
|
-
this.rejectedTotal++;
|
|
147
|
-
return Promise.reject(new CallAdmissionError("partition", "connector_call_failed", `Connector "${this.connectorId}" call-admission partitionKey must return a string of at most ${MAX_PARTITION_KEY_BYTES} UTF-8 bytes.`));
|
|
148
|
-
}
|
|
149
|
-
// A partition callback is operator code and may synchronously abort the
|
|
150
|
-
// caller. Recheck after it returns so that cancellation cannot consume a
|
|
151
|
-
// budget entry or concurrency slot.
|
|
152
|
-
if (signal?.aborted) {
|
|
153
|
-
this.cancelledTotal++;
|
|
154
|
-
return Promise.reject(this.cancelled(signal));
|
|
155
|
-
}
|
|
156
|
-
const now = Date.now();
|
|
157
|
-
let state = this.partitions.get(key);
|
|
158
|
-
if (!state) {
|
|
159
|
-
this.evictIdlePartitions(now);
|
|
160
|
-
if (this.partitions.size >= this.maxPartitions) {
|
|
161
|
-
this.rejectedTotal++;
|
|
162
|
-
return Promise.reject(new CallAdmissionError("partition", "rate_limited", `Connector "${this.connectorId}" call-admission partition capacity is exhausted.`, { retryAfterMs: this.retryAfterMs }));
|
|
163
|
-
}
|
|
164
|
-
state = {
|
|
165
|
-
active: 0,
|
|
166
|
-
waiters: [],
|
|
167
|
-
admittedAt: [],
|
|
168
|
-
};
|
|
169
|
-
this.partitions.set(key, state);
|
|
170
|
-
}
|
|
171
|
-
this.pruneBudget(state, now);
|
|
172
|
-
const budgetRetryAfterMs = this.budgetRetryAfterMs(state, now);
|
|
173
|
-
if (budgetRetryAfterMs !== undefined) {
|
|
174
|
-
this.rateLimitedTotal++;
|
|
175
|
-
return Promise.reject(this.budgetLimited(budgetRetryAfterMs));
|
|
176
|
-
}
|
|
177
|
-
if (this.maxConcurrency === undefined ||
|
|
178
|
-
state.active < this.maxConcurrency) {
|
|
179
|
-
return Promise.resolve(this.admit(state, now, 0));
|
|
180
|
-
}
|
|
181
|
-
if (state.waiters.length >= this.maxQueueSize) {
|
|
182
|
-
this.rejectedTotal++;
|
|
183
|
-
return Promise.reject(this.concurrencyLimited("queue is full"));
|
|
184
|
-
}
|
|
185
|
-
return new Promise((resolve, reject) => {
|
|
186
|
-
const waiter = {
|
|
187
|
-
queuedAt: now,
|
|
188
|
-
resolve,
|
|
189
|
-
reject,
|
|
190
|
-
...(signal ? { signal } : {}),
|
|
191
|
-
};
|
|
192
|
-
waiter.onAbort = () => {
|
|
193
|
-
if (!this.removeWaiter(state, waiter))
|
|
194
|
-
return;
|
|
195
|
-
this.cleanupWaiter(waiter);
|
|
196
|
-
this.cancelledTotal++;
|
|
197
|
-
reject(this.cancelled(signal));
|
|
198
|
-
this.maybeDeletePartition(key, state, Date.now());
|
|
199
|
-
};
|
|
200
|
-
waiter.timer = setTimeout(() => {
|
|
201
|
-
if (!this.removeWaiter(state, waiter))
|
|
202
|
-
return;
|
|
203
|
-
this.cleanupWaiter(waiter);
|
|
204
|
-
this.rejectedTotal++;
|
|
205
|
-
reject(this.concurrencyLimited(`queue wait exceeded ${this.queueTimeoutMs}ms`));
|
|
206
|
-
this.maybeDeletePartition(key, state, Date.now());
|
|
207
|
-
}, this.queueTimeoutMs);
|
|
208
|
-
state.waiters.push(waiter);
|
|
209
|
-
this.queuedTotal++;
|
|
210
|
-
signal?.addEventListener("abort", waiter.onAbort, { once: true });
|
|
211
|
-
// Close the check-to-listener race: an abort before registration is not
|
|
212
|
-
// replayed by AbortSignal, so inspect it once after the waiter is fully
|
|
213
|
-
// removable and its timer is installed.
|
|
214
|
-
if (signal?.aborted)
|
|
215
|
-
waiter.onAbort();
|
|
216
|
-
});
|
|
123
|
+
// The checks run now and the returned effect only waits. It closes over
|
|
124
|
+
// the partition key, not `input`, so a queued call does not retain its
|
|
125
|
+
// `args` — the limiter's payload-free state contract.
|
|
126
|
+
return runEdge(startCallAdmission(this, input));
|
|
217
127
|
}
|
|
218
128
|
/** A principal registry may be evicted only after calls and budgets drain. */
|
|
219
129
|
isIdle() {
|
|
@@ -253,8 +163,7 @@ export class ConnectorCallAdmissionController {
|
|
|
253
163
|
this.closed = true;
|
|
254
164
|
for (const state of this.partitions.values()) {
|
|
255
165
|
for (const waiter of state.waiters.splice(0)) {
|
|
256
|
-
|
|
257
|
-
waiter.reject(new CallAdmissionError("closed", "unavailable", `Connector "${this.connectorId}" call admission is closed.`));
|
|
166
|
+
Deferred.doneUnsafe(waiter.outcome, Effect.fail(new CallAdmissionError("closed", "unavailable", `Connector "${this.connectorId}" call admission is closed.`)));
|
|
258
167
|
}
|
|
259
168
|
}
|
|
260
169
|
}
|
|
@@ -287,22 +196,24 @@ export class ConnectorCallAdmissionController {
|
|
|
287
196
|
while (state.active < this.maxConcurrency &&
|
|
288
197
|
state.waiters.length > 0) {
|
|
289
198
|
const waiter = state.waiters.shift();
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
199
|
+
// The waiter's signal is not read here. This runs in whichever request
|
|
200
|
+
// released a slot, and on Workers reading an AbortSignal that another
|
|
201
|
+
// request created throws, failing the releaser and stranding the
|
|
202
|
+
// waiter. Nor does it need reading: a waiter whose signal aborted took
|
|
203
|
+
// itself out of the queue when it did, from its own abort listener.
|
|
204
|
+
//
|
|
205
|
+
// The waiter's own clock: the fiber's Clock for an Effect caller, the
|
|
206
|
+
// live `Date.now` for a Promise one.
|
|
207
|
+
const now = waiter.now();
|
|
297
208
|
this.pruneBudget(state, now);
|
|
298
209
|
const retryAfterMs = this.budgetRetryAfterMs(state, now);
|
|
299
210
|
if (retryAfterMs !== undefined) {
|
|
300
211
|
this.rateLimitedTotal++;
|
|
301
|
-
waiter.
|
|
212
|
+
Deferred.doneUnsafe(waiter.outcome, Effect.fail(this.budgetLimited(retryAfterMs)));
|
|
302
213
|
continue;
|
|
303
214
|
}
|
|
304
215
|
const waitMs = Math.max(0, now - waiter.queuedAt);
|
|
305
|
-
waiter.
|
|
216
|
+
Deferred.doneUnsafe(waiter.outcome, Effect.succeed(this.admit(state, now, waitMs)));
|
|
306
217
|
}
|
|
307
218
|
}
|
|
308
219
|
pruneBudget(state, now) {
|
|
@@ -350,12 +261,20 @@ export class ConnectorCallAdmissionController {
|
|
|
350
261
|
state.waiters.splice(index, 1);
|
|
351
262
|
return true;
|
|
352
263
|
}
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
264
|
+
// The waiting fiber was interrupted — an Effect caller's scope closed, not a
|
|
265
|
+
// signal. Withdraw the waiter if it is still queued; if the pump handed it a
|
|
266
|
+
// permit in the same breath, give the slot back, because nobody is left to
|
|
267
|
+
// receive it. (A line comment, not JSDoc: TypeScript copies a private
|
|
268
|
+
// member's JSDoc into the published declaration.)
|
|
269
|
+
cleanupWaiter(key, state, waiter) {
|
|
270
|
+
if (this.removeWaiter(state, waiter)) {
|
|
271
|
+
this.cancelledTotal++;
|
|
272
|
+
this.maybeDeletePartition(key, state, waiter.now());
|
|
273
|
+
return Effect.void;
|
|
358
274
|
}
|
|
275
|
+
if (!Deferred.isDoneUnsafe(waiter.outcome))
|
|
276
|
+
return Effect.void;
|
|
277
|
+
return Deferred.await(waiter.outcome).pipe(Effect.tap((permit) => Effect.sync(() => permit.release())), Effect.ignore);
|
|
359
278
|
}
|
|
360
279
|
concurrencyLimited(reason) {
|
|
361
280
|
return new CallAdmissionError("concurrency", "rate_limited", `Connector "${this.connectorId}" call concurrency ${reason}.`, { retryAfterMs: this.retryAfterMs });
|
|
@@ -366,4 +285,111 @@ export class ConnectorCallAdmissionController {
|
|
|
366
285
|
cancelled(signal) {
|
|
367
286
|
return new CallAdmissionError("cancelled", "timeout", `Connector "${this.connectorId}" call was cancelled before admission.`, { cause: signal.reason });
|
|
368
287
|
}
|
|
288
|
+
// The admission program, handed to src/runtime/call-admission.ts. It lives
|
|
289
|
+
// in a static block because the class declaration ships, private member
|
|
290
|
+
// names and all: a new method or field would change the published `.d.ts`,
|
|
291
|
+
// and a static block is the one place outside an instance method that may
|
|
292
|
+
// read this bookkeeping while emitting nothing there.
|
|
293
|
+
//
|
|
294
|
+
// Calling the program decides: everything up to a grant, a refusal, or a
|
|
295
|
+
// place in the queue happens synchronously, and only the queued wait is left
|
|
296
|
+
// for a fiber. Semaphore does not fit that wait — permits here are
|
|
297
|
+
// per-partition, the queue is bounded and counted, and each waiter carries
|
|
298
|
+
// its own timeout and budget recheck — so the queue stays explicit, as in
|
|
299
|
+
// AdmissionController, with a Deferred per waiter.
|
|
300
|
+
static {
|
|
301
|
+
provideCallAdmissionProgram((controller, input, now) => {
|
|
302
|
+
// Copy the signal out before building the wait: the effect returned
|
|
303
|
+
// below must not reach `input`, or a queued call would retain `args`.
|
|
304
|
+
const signal = input.signal;
|
|
305
|
+
if (controller.closed) {
|
|
306
|
+
return Effect.fail(new CallAdmissionError("closed", "unavailable", `Connector "${controller.connectorId}" call admission is closed.`));
|
|
307
|
+
}
|
|
308
|
+
if (signal?.aborted) {
|
|
309
|
+
controller.cancelledTotal++;
|
|
310
|
+
return Effect.fail(controller.cancelled(signal));
|
|
311
|
+
}
|
|
312
|
+
let key;
|
|
313
|
+
try {
|
|
314
|
+
key = controller.partitionKey
|
|
315
|
+
? controller.partitionKey({
|
|
316
|
+
toolName: input.toolName,
|
|
317
|
+
args: input.args,
|
|
318
|
+
})
|
|
319
|
+
: DEFAULT_PARTITION_KEY;
|
|
320
|
+
}
|
|
321
|
+
catch (cause) {
|
|
322
|
+
controller.rejectedTotal++;
|
|
323
|
+
return Effect.fail(new CallAdmissionError("partition", "connector_call_failed", `Connector "${controller.connectorId}" call-admission partitionKey threw.`, { cause }));
|
|
324
|
+
}
|
|
325
|
+
if (typeof key !== "string" ||
|
|
326
|
+
enc.encode(key).length > MAX_PARTITION_KEY_BYTES) {
|
|
327
|
+
controller.rejectedTotal++;
|
|
328
|
+
return Effect.fail(new CallAdmissionError("partition", "connector_call_failed", `Connector "${controller.connectorId}" call-admission partitionKey must return a string of at most ${MAX_PARTITION_KEY_BYTES} UTF-8 bytes.`));
|
|
329
|
+
}
|
|
330
|
+
// A partition callback is operator code and may synchronously abort the
|
|
331
|
+
// caller. Recheck after it returns so that cancellation cannot consume a
|
|
332
|
+
// budget entry or concurrency slot.
|
|
333
|
+
if (signal?.aborted) {
|
|
334
|
+
controller.cancelledTotal++;
|
|
335
|
+
return Effect.fail(controller.cancelled(signal));
|
|
336
|
+
}
|
|
337
|
+
const queuedAt = now();
|
|
338
|
+
let found = controller.partitions.get(key);
|
|
339
|
+
if (!found) {
|
|
340
|
+
controller.evictIdlePartitions(queuedAt);
|
|
341
|
+
if (controller.partitions.size >= controller.maxPartitions) {
|
|
342
|
+
controller.rejectedTotal++;
|
|
343
|
+
return Effect.fail(new CallAdmissionError("partition", "rate_limited", `Connector "${controller.connectorId}" call-admission partition capacity is exhausted.`, { retryAfterMs: controller.retryAfterMs }));
|
|
344
|
+
}
|
|
345
|
+
found = { active: 0, waiters: [], admittedAt: [] };
|
|
346
|
+
controller.partitions.set(key, found);
|
|
347
|
+
}
|
|
348
|
+
const state = found;
|
|
349
|
+
controller.pruneBudget(state, queuedAt);
|
|
350
|
+
const budgetRetryAfterMs = controller.budgetRetryAfterMs(state, queuedAt);
|
|
351
|
+
if (budgetRetryAfterMs !== undefined) {
|
|
352
|
+
controller.rateLimitedTotal++;
|
|
353
|
+
return Effect.fail(controller.budgetLimited(budgetRetryAfterMs));
|
|
354
|
+
}
|
|
355
|
+
if (controller.maxConcurrency === undefined ||
|
|
356
|
+
state.active < controller.maxConcurrency) {
|
|
357
|
+
return Effect.succeed(controller.admit(state, queuedAt, 0));
|
|
358
|
+
}
|
|
359
|
+
if (state.waiters.length >= controller.maxQueueSize) {
|
|
360
|
+
controller.rejectedTotal++;
|
|
361
|
+
return Effect.fail(controller.concurrencyLimited("queue is full"));
|
|
362
|
+
}
|
|
363
|
+
const waiter = {
|
|
364
|
+
queuedAt,
|
|
365
|
+
now,
|
|
366
|
+
outcome: Deferred.makeUnsafe(),
|
|
367
|
+
};
|
|
368
|
+
state.waiters.push(waiter);
|
|
369
|
+
controller.queuedTotal++;
|
|
370
|
+
// A timeout or abort settles the waiter only if it takes it out of the
|
|
371
|
+
// queue first. Otherwise the pump or close() got there in the same tick,
|
|
372
|
+
// and the outcome they recorded is the answer.
|
|
373
|
+
const giveUp = (count, error) => Effect.suspend(() => {
|
|
374
|
+
if (!controller.removeWaiter(state, waiter)) {
|
|
375
|
+
return Deferred.await(waiter.outcome);
|
|
376
|
+
}
|
|
377
|
+
count();
|
|
378
|
+
const refusal = error();
|
|
379
|
+
controller.maybeDeletePartition(key, state, now());
|
|
380
|
+
return Effect.fail(refusal);
|
|
381
|
+
});
|
|
382
|
+
// One flat race; each contender is a forked fiber. The abort contender
|
|
383
|
+
// checks the signal as it starts, which closes the check-to-listener
|
|
384
|
+
// race an AbortSignal would not replay.
|
|
385
|
+
const contenders = [
|
|
386
|
+
Deferred.await(waiter.outcome),
|
|
387
|
+
Effect.sleep(Duration.millis(controller.queueTimeoutMs)).pipe(Effect.andThen(giveUp(() => controller.rejectedTotal++, () => controller.concurrencyLimited(`queue wait exceeded ${controller.queueTimeoutMs}ms`)))),
|
|
388
|
+
];
|
|
389
|
+
if (signal) {
|
|
390
|
+
contenders.push(fromSignal(signal).pipe(Effect.catch(() => giveUp(() => controller.cancelledTotal++, () => controller.cancelled(signal)))));
|
|
391
|
+
}
|
|
392
|
+
return Effect.raceAllFirst(contenders).pipe(Effect.onInterrupt(() => controller.cleanupWaiter(key, state, waiter)));
|
|
393
|
+
});
|
|
394
|
+
}
|
|
369
395
|
}
|