@zackbart/connecta 0.24.4 → 0.26.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 +43 -12
- package/CHANGELOG.md +524 -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/advertised-schema.js +53 -0
- package/dist/artifacts/connector.d.ts +55 -0
- package/dist/artifacts/connector.js +724 -0
- package/dist/artifacts/document.js +76 -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 +449 -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 +194 -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 +652 -0
- package/dist/artifacts.d.ts +6 -0
- package/dist/artifacts.js +9 -0
- package/dist/auth/downstream-oauth.js +490 -106
- package/dist/call-admission.js +139 -113
- package/dist/catalog-service.js +152 -106
- package/dist/catalog.js +297 -38
- package/dist/concurrency.js +24 -23
- 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/connector-scope.js +6 -37
- package/dist/connectors/api.d.ts +2 -2
- package/dist/connectors/remote-mcp.d.ts +6 -5
- package/dist/connectors/remote-mcp.js +330 -240
- 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 +16 -1
- package/dist/errors.js +28 -0
- package/dist/execute.js +745 -282
- package/dist/executor-admission.d.ts +11 -1
- package/dist/executor-admission.js +145 -63
- package/dist/executors/quickjs-runtime.js +4 -2
- package/dist/executors/quickjs.js +255 -212
- package/dist/fix-prompt.js +37 -0
- package/dist/index.d.ts +72 -7
- package/dist/index.js +281 -26
- package/dist/invocation.js +371 -286
- package/dist/meta-tools.js +213 -94
- package/dist/module-contracts.d.ts +21 -1
- 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 +103 -1
- package/dist/operator-ui/setup-commands.js +53 -0
- package/dist/operator-ui/view.js +128 -3
- package/dist/program-source.js +321 -0
- package/dist/providers/notion.js +2 -2
- package/dist/providers/revenuecat.js +32 -5
- package/dist/providers/stripe.d.ts +3 -2
- package/dist/providers/vercel.js +1 -1
- package/dist/registry.d.ts +43 -7
- package/dist/registry.js +434 -288
- package/dist/resumable.js +1291 -0
- package/dist/routes/activity.js +74 -154
- package/dist/routes/actor-labels.js +114 -0
- package/dist/routes/credentials.js +196 -163
- package/dist/routes/mcp.js +228 -112
- package/dist/routes/oauth-management.js +73 -71
- package/dist/routes/oauth.js +44 -16
- package/dist/routes/operator.js +83 -0
- package/dist/routes/shared.d.ts +25 -1
- package/dist/routes/shared.js +20 -2
- package/dist/routes/ui-data.js +211 -0
- package/dist/routes/ui.js +76 -35
- package/dist/run-journal.js +347 -0
- 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/shared-read.js +106 -0
- package/dist/runtime/storage.js +66 -0
- package/dist/server.js +137 -89
- package/dist/skills.js +28 -9
- package/dist/storage/file.js +39 -2
- package/dist/storage/memory.js +28 -16
- package/dist/timeout.js +39 -43
- package/dist/tool-safety.d.ts +27 -1
- package/dist/tool-safety.js +31 -0
- package/dist/types.d.ts +45 -7
- package/dist/ui.d.ts +25 -4
- package/dist/ui.js +77 -167
- package/dist/url-safety.js +157 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/documentation/architecture.md +367 -16
- package/documentation/auth.md +163 -2
- package/documentation/code-mode.md +274 -56
- package/documentation/meta-tools.md +200 -44
- package/ethos.md +46 -47
- package/examples/worker/AGENTS.md +6 -0
- package/examples/worker/README.md +224 -14
- package/examples/worker/src/d1-activity-row.ts +14 -2
- package/examples/worker/src/d1-activity.ts +4 -2
- package/examples/worker/src/d1-storage.ts +116 -0
- package/examples/worker/src/index.ts +42 -7
- package/examples/worker/src/r2-artifact-blobs.ts +29 -0
- package/examples/worker/wrangler.jsonc +21 -5
- package/package.json +16 -4
- 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/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 -96
- 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 -188
- 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/routes/ui.js
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
|
+
import { Effect } from "effect";
|
|
1
2
|
import { CONNECTA_VERSION } from "../version.js";
|
|
2
3
|
import { CONNECTA_FAVICON_ICO } from "../favicon.js";
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
4
|
+
import { CONNECTA_FAVICON_SVG, operatorPageForPath, renderUiHtml, } from "../ui.js";
|
|
5
|
+
import { authorized, refuse, scopeFor, serveOperator, visibleRegistry, } from "./operator.js";
|
|
6
|
+
import { mayManageConnector, mayViewArtifacts, privateJson, } from "./shared.js";
|
|
7
|
+
import { uiData } from "./ui-data.js";
|
|
5
8
|
/**
|
|
6
9
|
* Headers that make an operator-supplied favicon body inert on this origin.
|
|
7
10
|
* The SVG route is the sharp one: `image/svg+xml` is an *active* content type,
|
|
@@ -38,7 +41,7 @@ function uiScriptNonce() {
|
|
|
38
41
|
return btoa(binary);
|
|
39
42
|
}
|
|
40
43
|
export async function routeUi(context) {
|
|
41
|
-
const { request, url, path, baseUrl, opts,
|
|
44
|
+
const { request, url, path, baseUrl, opts, runtimeContext } = context;
|
|
42
45
|
if (request.method === "GET" && path === "/favicon.svg") {
|
|
43
46
|
return new Response(opts.branding?.favicon?.svg ?? CONNECTA_FAVICON_SVG, {
|
|
44
47
|
headers: {
|
|
@@ -68,7 +71,10 @@ export async function routeUi(context) {
|
|
|
68
71
|
});
|
|
69
72
|
}
|
|
70
73
|
const operatorPage = operatorPageForPath(path);
|
|
71
|
-
|
|
74
|
+
const artifactPage = operatorPage === "artifacts" || operatorPage === "artifact";
|
|
75
|
+
if (operatorPage &&
|
|
76
|
+
(operatorPage !== "activity" || opts.activity?.list) &&
|
|
77
|
+
(!artifactPage || opts.artifactsModule)) {
|
|
72
78
|
if (request.method !== "GET" && request.method !== "HEAD") {
|
|
73
79
|
return privateJson({ error: "method not allowed" }, { status: 405 });
|
|
74
80
|
}
|
|
@@ -86,14 +92,18 @@ export async function routeUi(context) {
|
|
|
86
92
|
// style/font/network needs and the page's inline <style> stay unrestricted
|
|
87
93
|
// — only script execution, the XSS sink, is gated.
|
|
88
94
|
const nonce = uiScriptNonce();
|
|
95
|
+
// An artifact shell frames the sandboxed page from its own origin and
|
|
96
|
+
// nothing else; Connections stays on the deployment's public origin.
|
|
97
|
+
const homeUrl = opts.publicUrl ? new URL("/", opts.publicUrl).toString() : "/";
|
|
89
98
|
return new Response(request.method === "HEAD"
|
|
90
99
|
? null
|
|
91
|
-
: renderUiHtml(uiAuth, mcpUrl, opts.branding, nonce, operatorPage), {
|
|
100
|
+
: renderUiHtml(uiAuth, mcpUrl, opts.branding, nonce, operatorPage, { homeUrl }), {
|
|
92
101
|
status: 200,
|
|
93
102
|
headers: {
|
|
94
103
|
"Content-Type": "text/html; charset=utf-8",
|
|
95
104
|
"Content-Security-Policy": `script-src 'nonce-${nonce}' 'strict-dynamic' https: 'unsafe-inline'; ` +
|
|
96
|
-
"object-src 'none'; base-uri 'none'; frame-ancestors 'none'"
|
|
105
|
+
"object-src 'none'; base-uri 'none'; frame-ancestors 'none'" +
|
|
106
|
+
(artifactPage ? "; frame-src 'self'" : ""),
|
|
97
107
|
"X-Content-Type-Options": "nosniff",
|
|
98
108
|
},
|
|
99
109
|
});
|
|
@@ -103,22 +113,11 @@ export async function routeUi(context) {
|
|
|
103
113
|
return null;
|
|
104
114
|
if (request.method !== "GET")
|
|
105
115
|
return privateJson({ error: "method not allowed" }, { status: 405 });
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
validateAuthPermissions(authz, opts.registry);
|
|
112
|
-
registry = opts.registry.scoped({
|
|
113
|
-
connectorIds: authz.connectorIds,
|
|
114
|
-
...(authz.toolAccess ? { toolAccess: authz.toolAccess } : {}),
|
|
115
|
-
...(authz.subjectKey ? { subjectKey: authz.subjectKey } : {}),
|
|
116
|
-
...(authz.principalKey ? { principalKey: authz.principalKey } : {}),
|
|
117
|
-
});
|
|
118
|
-
}
|
|
119
|
-
catch (error) {
|
|
120
|
-
return privateJson({ error: msg(error) }, { status: 403 });
|
|
121
|
-
}
|
|
116
|
+
// Reads: a caller who leaves stops whatever the payload was waiting on.
|
|
117
|
+
return serveOperator(detail ? connectorDetail(context, detail[1]) : summary(context), request.signal);
|
|
118
|
+
}
|
|
119
|
+
/** What this identity may see and do, for the summary and a detail alike. */
|
|
120
|
+
function operatorView({ opts }, authz, registry) {
|
|
122
121
|
const visible = registry.listConnectors();
|
|
123
122
|
const mayManage = (id) => { const connector = registry.getConnector(id); return Boolean(connector && mayManageConnector(authz, connector)); };
|
|
124
123
|
const permissions = (connector) => ({
|
|
@@ -130,20 +129,62 @@ export async function routeUi(context) {
|
|
|
130
129
|
const credentialManagement = visible.some(c => c.credential && mayManage(c.id))
|
|
131
130
|
? opts.credentialVault ? "available" : "vault_not_configured"
|
|
132
131
|
: authz.identity.interactive && !visible.some(c => c.credential) ? "no_slots" : "requires_operator";
|
|
133
|
-
|
|
134
|
-
|
|
132
|
+
return { visible, mayManage, permissions, activityEnabled, credentialManagement };
|
|
133
|
+
}
|
|
134
|
+
/** One connector's probed row, the page's second request per card. */
|
|
135
|
+
function connectorDetail(context, id) {
|
|
136
|
+
const { request, baseUrl, opts, defer } = context;
|
|
137
|
+
return Effect.gen(function* () {
|
|
138
|
+
const authz = yield* authorized(context);
|
|
139
|
+
const registry = yield* visibleRegistry(context, authz);
|
|
140
|
+
const { mayManage, permissions, activityEnabled, credentialManagement } = operatorView(context, authz, registry);
|
|
141
|
+
const connector = registry.getConnector(id);
|
|
135
142
|
if (!connector)
|
|
136
|
-
return
|
|
137
|
-
const
|
|
138
|
-
|
|
143
|
+
return yield* refuse("unknown connector", 404);
|
|
144
|
+
const data = yield* uiData(opts.registry.scoped(scopeFor(authz, [connector.id])), baseUrl, {
|
|
145
|
+
serverInfo: opts.serverInfo,
|
|
146
|
+
credentialVault: opts.credentialVault,
|
|
147
|
+
activityEnabled,
|
|
148
|
+
credentialManagement,
|
|
149
|
+
defer,
|
|
150
|
+
oauthManagement: false,
|
|
151
|
+
discoveryConcurrency: 1,
|
|
152
|
+
personalCredentialOwner: authz.principalKey,
|
|
153
|
+
mayManage,
|
|
154
|
+
timeoutMs: opts.probeTimeoutMs ?? 30_000,
|
|
155
|
+
signal: request.signal,
|
|
156
|
+
approval: opts.approval,
|
|
157
|
+
});
|
|
139
158
|
return privateJson({ ...data.connectors[0], permissions: permissions(connector) });
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
/** The summary: every visible connector as "loading", with no probe at all. */
|
|
162
|
+
function summary(context) {
|
|
163
|
+
const { opts } = context;
|
|
164
|
+
return Effect.gen(function* () {
|
|
165
|
+
const authz = yield* authorized(context);
|
|
166
|
+
const registry = yield* visibleRegistry(context, authz);
|
|
167
|
+
const { visible, mayManage, permissions, activityEnabled, credentialManagement } = operatorView(context, authz, registry);
|
|
168
|
+
// Setup commands are offered per pool, but only for pools this identity's
|
|
169
|
+
// grant admits: `/mcp/<name>` answers every other name with one flat 404 so
|
|
170
|
+
// a credential cannot enumerate them, and the page must not undo that.
|
|
171
|
+
const pools = [];
|
|
172
|
+
for (const [name, pool] of opts.pools ?? []) {
|
|
173
|
+
const granted = yield* Effect.tryPromise(async () => (await pool.grant(authz.identity)) === true).pipe(
|
|
174
|
+
// A throwing grant is a refusal at the endpoint, and so a refusal here.
|
|
175
|
+
Effect.orElseSucceed(() => false));
|
|
176
|
+
if (granted)
|
|
177
|
+
pools.push(name);
|
|
178
|
+
}
|
|
179
|
+
return privateJson({
|
|
180
|
+
...(pools.length ? { pools } : {}),
|
|
181
|
+
serverInfo: opts.serverInfo,
|
|
182
|
+
connectaVersion: CONNECTA_VERSION,
|
|
183
|
+
activityEnabled,
|
|
184
|
+
...(opts.artifactsModule && mayViewArtifacts(authz, opts.registry) ? { artifactsEnabled: true } : {}),
|
|
185
|
+
credentialManagement,
|
|
186
|
+
oauthManagement: visible.some(c => mayManage(c.id)),
|
|
187
|
+
connectors: visible.map(c => ({ id: c.id, ...(c.title ? { title: c.title } : {}), ...(c.description ? { description: c.description } : {}), authScope: c.authScope ?? "shared", status: "loading", toolCount: 0, tools: [], oauth: Boolean(c.startAuth && c.disconnectAuth), permissions: permissions(c) })),
|
|
188
|
+
});
|
|
148
189
|
});
|
|
149
190
|
}
|
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
// The paused-run journal: layout, tokens, and the storage it lives in.
|
|
2
|
+
//
|
|
3
|
+
// A paused `execute_code` run is not a held program. It is data — the
|
|
4
|
+
// program's source, every host call it made and what each returned, and one
|
|
5
|
+
// header that says where the run stands — written to the same subject-
|
|
6
|
+
// partitioned storage the result stash uses, at the same trust level, and
|
|
7
|
+
// expiring on its own. Resuming replays the program from the top against
|
|
8
|
+
// those records. Nothing request-bound is in here: a journal is data, not a
|
|
9
|
+
// request (ethos, "Nothing request-bound survives a request").
|
|
10
|
+
//
|
|
11
|
+
// Web-API only, like everything reachable from the root entry.
|
|
12
|
+
import { withDeadline } from "./timeout.js";
|
|
13
|
+
/** Entry chunks are the result stash's size (`RESULT_CHUNK_BYTES`), in characters. */
|
|
14
|
+
const JOURNAL_CHUNK_CHARS = 49_152;
|
|
15
|
+
/**
|
|
16
|
+
* The most one run may journal: its source plus every recorded host call.
|
|
17
|
+
* Internal rather than configurable — it bounds what one caller can park in
|
|
18
|
+
* shared storage for half an hour, and nothing about a deployment makes a
|
|
19
|
+
* larger notebook the right answer. L6 bounds each host call's result at 256
|
|
20
|
+
* KiB on QuickJS, so a run near the host-call budget can reach it.
|
|
21
|
+
*/
|
|
22
|
+
export const MAX_JOURNAL_BYTES = 4 * 1024 * 1024;
|
|
23
|
+
/**
|
|
24
|
+
* The largest pending write a pause will hold, serialized. The arguments are
|
|
25
|
+
* echoed whole in the pause result and must be repeated whole to resume, so
|
|
26
|
+
* this is a bound on what a human is asked to read in a prompt, not a storage
|
|
27
|
+
* limit. A bigger write goes through `call_destructive_tool`.
|
|
28
|
+
*/
|
|
29
|
+
export const MAX_PENDING_ARGS_BYTES = 16 * 1024;
|
|
30
|
+
/** How long an ended run keeps its answer for a repeated resume, in ms. */
|
|
31
|
+
const FINAL_HOLD_MS = 30 * 60 * 1_000;
|
|
32
|
+
/** A completed run keeps at most this much of its answer for a retried resume. */
|
|
33
|
+
export const MAX_FINAL_CHARS = 24_000;
|
|
34
|
+
const encoder = new TextEncoder();
|
|
35
|
+
export function utf8Bytes(text) {
|
|
36
|
+
return encoder.encode(text).byteLength;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* JSON with object keys sorted at every depth, so two argument objects that
|
|
40
|
+
* differ only in key order serialize identically. Arrays keep their order and
|
|
41
|
+
* a type change is a different string: `1` and `"1"` never match.
|
|
42
|
+
*
|
|
43
|
+
* Written out directly rather than by building sorted objects and handing
|
|
44
|
+
* them to `JSON.stringify`: assigning an own `__proto__` key onto a plain
|
|
45
|
+
* object sets its prototype instead, so a key that `JSON.parse` kept would
|
|
46
|
+
* vanish from the canonical form, and two different writes would compare
|
|
47
|
+
* equal.
|
|
48
|
+
*/
|
|
49
|
+
export function canonicalJson(value) {
|
|
50
|
+
return canonical(value) ?? "null";
|
|
51
|
+
}
|
|
52
|
+
function canonical(input) {
|
|
53
|
+
let value = input;
|
|
54
|
+
if (value !== null &&
|
|
55
|
+
typeof value === "object" &&
|
|
56
|
+
typeof value.toJSON === "function") {
|
|
57
|
+
value = value.toJSON();
|
|
58
|
+
}
|
|
59
|
+
// Boxed primitives serialize as their value, as JSON.stringify has them.
|
|
60
|
+
if (value instanceof Number ||
|
|
61
|
+
value instanceof String ||
|
|
62
|
+
value instanceof Boolean) {
|
|
63
|
+
value = value.valueOf();
|
|
64
|
+
}
|
|
65
|
+
if (value === null || typeof value !== "object")
|
|
66
|
+
return JSON.stringify(value);
|
|
67
|
+
if (Array.isArray(value)) {
|
|
68
|
+
// Indexed, not mapped: `map` skips a hole, and JSON has none — a hole is
|
|
69
|
+
// `null`, as JSON.stringify writes it.
|
|
70
|
+
const items = [];
|
|
71
|
+
for (let index = 0; index < value.length; index++) {
|
|
72
|
+
items.push(canonical(value[index]) ?? "null");
|
|
73
|
+
}
|
|
74
|
+
return `[${items.join(",")}]`;
|
|
75
|
+
}
|
|
76
|
+
const parts = [];
|
|
77
|
+
for (const key of Object.keys(value).sort()) {
|
|
78
|
+
const item = canonical(value[key]);
|
|
79
|
+
if (item !== undefined)
|
|
80
|
+
parts.push(`${JSON.stringify(key)}:${item}`);
|
|
81
|
+
}
|
|
82
|
+
return `{${parts.join(",")}}`;
|
|
83
|
+
}
|
|
84
|
+
/** Whether a `__proto__` key appears anywhere in a JSON value. */
|
|
85
|
+
export function hasProtoKey(value) {
|
|
86
|
+
if (value === null || typeof value !== "object")
|
|
87
|
+
return false;
|
|
88
|
+
if (Array.isArray(value))
|
|
89
|
+
return value.some(hasProtoKey);
|
|
90
|
+
return Object.keys(value).some((key) => key === "__proto__" ||
|
|
91
|
+
hasProtoKey(value[key]));
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* How a replayed host call finds its record: the operation, the address as
|
|
95
|
+
* the program wrote it, and its canonical arguments. Several calls may share a
|
|
96
|
+
* key; they answer in the order they were first issued.
|
|
97
|
+
*/
|
|
98
|
+
export function journalKey(op, address, args) {
|
|
99
|
+
return `${op}\n${address}\n${canonicalJson(args ?? {})}`;
|
|
100
|
+
}
|
|
101
|
+
const TOKEN_RE = /^r1\.([0-9a-f]{32})\.([0-9a-f]{32})\.(\d{1,15})$/;
|
|
102
|
+
export function formatToken(token) {
|
|
103
|
+
return `r1.${token.runId}.${token.nonce}.${token.expiresAt}`;
|
|
104
|
+
}
|
|
105
|
+
export function parseToken(value) {
|
|
106
|
+
const match = TOKEN_RE.exec(value);
|
|
107
|
+
if (!match)
|
|
108
|
+
return undefined;
|
|
109
|
+
return {
|
|
110
|
+
runId: match[1] ?? "",
|
|
111
|
+
nonce: match[2] ?? "",
|
|
112
|
+
expiresAt: Number(match[3]),
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
/** 128 random bits as hex: a run id, a pause nonce, or a claim id. */
|
|
116
|
+
export function randomId() {
|
|
117
|
+
const words = new Uint32Array(4);
|
|
118
|
+
crypto.getRandomValues(words);
|
|
119
|
+
return Array.from(words, (word) => word.toString(16).padStart(8, "0")).join("");
|
|
120
|
+
}
|
|
121
|
+
export async function sha256Hex(text) {
|
|
122
|
+
const digest = await crypto.subtle.digest("SHA-256", encoder.encode(text));
|
|
123
|
+
return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Whether a consequential call landed, as far as anyone can know.
|
|
127
|
+
*
|
|
128
|
+
* `unknown` is the one outcome replay must never paper over: the call left
|
|
129
|
+
* connecta and no answer came back, so sending it again could do it twice and
|
|
130
|
+
* not sending it could leave it undone. That is a dispatched call that timed
|
|
131
|
+
* out, was cancelled, found the service unavailable, or failed any way that
|
|
132
|
+
* is not a verdict. A refusal code (`REFUSALS`) or a downstream tool's own
|
|
133
|
+
* `isError` is an answer, so it is `failed`, except that an `isError` whose text
|
|
134
|
+
* classifies as a timeout is a gateway reporting that it gave up, and stays
|
|
135
|
+
* unknown. A call that was never dispatched is `failed`: nothing was sent.
|
|
136
|
+
* `result_processing_failed` means the downstream call completed.
|
|
137
|
+
*/
|
|
138
|
+
export function classifyWriteOutcome(outcome) {
|
|
139
|
+
if (outcome.ok)
|
|
140
|
+
return "ok";
|
|
141
|
+
if (!outcome.dispatched)
|
|
142
|
+
return "failed";
|
|
143
|
+
const code = outcome.error?.code;
|
|
144
|
+
if (code === "result_processing_failed")
|
|
145
|
+
return "ok";
|
|
146
|
+
if (code === "timeout" || code === "cancelled" || code === "unavailable") {
|
|
147
|
+
return "unknown";
|
|
148
|
+
}
|
|
149
|
+
if (code !== undefined && REFUSALS.has(code))
|
|
150
|
+
return "failed";
|
|
151
|
+
return outcome.answered === true ? "failed" : "unknown";
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Codes that say the other side refused the call rather than acted on it:
|
|
155
|
+
* the credential, the arguments, the resource, the rate, or a base version
|
|
156
|
+
* someone else already moved past (`conflict`). Anything else a
|
|
157
|
+
* connector reports after dispatch — `connector_call_failed` from a response
|
|
158
|
+
* too large to read, a redirect it would not follow, a body it could not
|
|
159
|
+
* parse, a 5xx — may come after the write landed, so only a downstream
|
|
160
|
+
* tool's own `isError` answer makes it a known failure.
|
|
161
|
+
*/
|
|
162
|
+
const REFUSALS = new Set([
|
|
163
|
+
"auth_required",
|
|
164
|
+
"invalid_args",
|
|
165
|
+
"not_found",
|
|
166
|
+
"rate_limited",
|
|
167
|
+
"input_required_unsupported",
|
|
168
|
+
"conflict",
|
|
169
|
+
]);
|
|
170
|
+
function isRecord(value) {
|
|
171
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
172
|
+
}
|
|
173
|
+
/** A header read back from storage, or undefined when it is not one. */
|
|
174
|
+
function parseHeader(raw) {
|
|
175
|
+
let value;
|
|
176
|
+
try {
|
|
177
|
+
value = JSON.parse(raw);
|
|
178
|
+
}
|
|
179
|
+
catch {
|
|
180
|
+
return undefined;
|
|
181
|
+
}
|
|
182
|
+
if (!isRecord(value) ||
|
|
183
|
+
value.v !== 1 ||
|
|
184
|
+
!["paused", "running", "completed", "failed"].includes(String(value.state)) ||
|
|
185
|
+
typeof value.nonce !== "string" ||
|
|
186
|
+
typeof value.expiresAt !== "number" ||
|
|
187
|
+
typeof value.clock !== "number" ||
|
|
188
|
+
!Array.isArray(value.seed) ||
|
|
189
|
+
value.seed.length !== 4 ||
|
|
190
|
+
!Array.isArray(value.approvals) ||
|
|
191
|
+
!Array.isArray(value.writes) ||
|
|
192
|
+
typeof value.entries !== "number" ||
|
|
193
|
+
typeof value.bytes !== "number" ||
|
|
194
|
+
typeof value.playFrom !== "number" ||
|
|
195
|
+
typeof value.programHash !== "string") {
|
|
196
|
+
return undefined;
|
|
197
|
+
}
|
|
198
|
+
return value;
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* One run's keys inside the caller's result storage.
|
|
202
|
+
*
|
|
203
|
+
* `run:<id>` is the header and the only key ever compare-and-set.
|
|
204
|
+
* `run:<id>:src` is the program, written once. `run:<id>:e:<n>` is entry `n`,
|
|
205
|
+
* split into `#<k>` continuation keys past one chunk, written before the
|
|
206
|
+
* header that counts it. Every key expires with the run.
|
|
207
|
+
*
|
|
208
|
+
* Every storage call here answers within `timeoutMs` or rejects, as a
|
|
209
|
+
* failing store would: a play reserving or settling a write cannot be
|
|
210
|
+
* interrupted, and a store that never answers must not hold it — or the
|
|
211
|
+
* request waiting on it — forever. A write that timed out may still land;
|
|
212
|
+
* each caller treats the call as failed, and every write here is one that is
|
|
213
|
+
* safe to find landed later (a compare-and-set that landed after all only
|
|
214
|
+
* makes the next one lose).
|
|
215
|
+
*/
|
|
216
|
+
export class RunJournal {
|
|
217
|
+
storage;
|
|
218
|
+
runId;
|
|
219
|
+
timeoutMs;
|
|
220
|
+
constructor(storage, runId, timeoutMs) {
|
|
221
|
+
this.storage = storage;
|
|
222
|
+
this.runId = runId;
|
|
223
|
+
this.timeoutMs = timeoutMs;
|
|
224
|
+
}
|
|
225
|
+
bounded(operation) {
|
|
226
|
+
if (this.timeoutMs === undefined)
|
|
227
|
+
return operation();
|
|
228
|
+
return withDeadline(() => operation(), {
|
|
229
|
+
timeoutMs: this.timeoutMs,
|
|
230
|
+
timeoutError: new Error(`paused-run storage did not answer within ${this.timeoutMs}ms`),
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
get headerKey() {
|
|
234
|
+
return `run:${this.runId}`;
|
|
235
|
+
}
|
|
236
|
+
/** Seconds until `expiresAt`, never less than one. */
|
|
237
|
+
static ttlSeconds(expiresAt, now = Date.now()) {
|
|
238
|
+
return Math.max(1, Math.ceil((expiresAt - now) / 1_000));
|
|
239
|
+
}
|
|
240
|
+
async readHeader() {
|
|
241
|
+
const raw = await this.bounded(() => this.storage.get(this.headerKey));
|
|
242
|
+
if (raw === null)
|
|
243
|
+
return undefined;
|
|
244
|
+
const header = parseHeader(raw);
|
|
245
|
+
return header ? { raw, header } : undefined;
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* Write `next` only if the header is still exactly `expected` (`null`:
|
|
249
|
+
* absent). Returns the stored string on success, `undefined` when another
|
|
250
|
+
* writer got there first.
|
|
251
|
+
*/
|
|
252
|
+
async casHeader(expected, next) {
|
|
253
|
+
const cas = this.storage.compareAndSet;
|
|
254
|
+
if (!cas)
|
|
255
|
+
throw new Error("resumable writes need KVStorage.compareAndSet");
|
|
256
|
+
const raw = JSON.stringify(next);
|
|
257
|
+
// The header outlives `expiresAt` while a claim is live, so a play that
|
|
258
|
+
// runs past it can still record what it sent, and after the run ends, so
|
|
259
|
+
// a repeated resume gets the same answer rather than "expired, run it
|
|
260
|
+
// again" — which would repeat every write the run made. For the same
|
|
261
|
+
// reason a run that has sent writes keeps its header past its deadline
|
|
262
|
+
// and past a lapsed claim, as a tombstone with their counts.
|
|
263
|
+
const sent = next.writes.length > 0;
|
|
264
|
+
const keepUntil = Math.max(next.expiresAt, next.claim?.until ?? 0, next.final ? Date.now() + FINAL_HOLD_MS : 0, sent ? next.expiresAt + FINAL_HOLD_MS : 0, sent && next.claim ? next.claim.until + FINAL_HOLD_MS : 0);
|
|
265
|
+
const written = await this.bounded(() => cas.call(this.storage, this.headerKey, expected, raw, {
|
|
266
|
+
ttlSeconds: RunJournal.ttlSeconds(keepUntil),
|
|
267
|
+
}));
|
|
268
|
+
return written ? raw : undefined;
|
|
269
|
+
}
|
|
270
|
+
async writeSource(source, expiresAt) {
|
|
271
|
+
await this.bounded(() => this.storage.set(`${this.headerKey}:src`, source, {
|
|
272
|
+
ttlSeconds: RunJournal.ttlSeconds(expiresAt),
|
|
273
|
+
}));
|
|
274
|
+
}
|
|
275
|
+
async readSource(programHash) {
|
|
276
|
+
const source = await this.bounded(() => this.storage.get(`${this.headerKey}:src`));
|
|
277
|
+
if (source === null)
|
|
278
|
+
return undefined;
|
|
279
|
+
return (await sha256Hex(source)) === programHash ? source : undefined;
|
|
280
|
+
}
|
|
281
|
+
/** Serialized size of an entry as journaled, for the run's byte bound. */
|
|
282
|
+
static entryText(entry) {
|
|
283
|
+
return JSON.stringify(entry);
|
|
284
|
+
}
|
|
285
|
+
writeEntry(index, entry, expiresAt) {
|
|
286
|
+
return this.bounded(() => this.writeEntryUnbounded(index, entry, expiresAt));
|
|
287
|
+
}
|
|
288
|
+
async writeEntryUnbounded(index, entry, expiresAt) {
|
|
289
|
+
const text = RunJournal.entryText(entry);
|
|
290
|
+
const chunks = [];
|
|
291
|
+
for (let at = 0; at < text.length; at += JOURNAL_CHUNK_CHARS) {
|
|
292
|
+
chunks.push(text.slice(at, at + JOURNAL_CHUNK_CHARS));
|
|
293
|
+
}
|
|
294
|
+
if (chunks.length === 0)
|
|
295
|
+
chunks.push("");
|
|
296
|
+
const ttlSeconds = RunJournal.ttlSeconds(expiresAt);
|
|
297
|
+
const key = `${this.headerKey}:e:${index}`;
|
|
298
|
+
// Continuations first, the head last: a head that exists names chunks
|
|
299
|
+
// that all exist.
|
|
300
|
+
for (let part = 1; part < chunks.length; part++) {
|
|
301
|
+
await this.storage.set(`${key}#${part}`, chunks[part] ?? "", { ttlSeconds });
|
|
302
|
+
}
|
|
303
|
+
await this.storage.set(key, `${chunks.length}\n${chunks[0] ?? ""}`, {
|
|
304
|
+
ttlSeconds,
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
/**
|
|
308
|
+
* Entries `0 … count - 1`, or undefined when one is missing or unreadable:
|
|
309
|
+
* a journal with a hole cannot be replayed. (A slot a `sending` write
|
|
310
|
+
* reserved and never filled is such a hole, and a run holding one has
|
|
311
|
+
* failed rather than paused, so it is never read.)
|
|
312
|
+
*/
|
|
313
|
+
readEntries(count) {
|
|
314
|
+
return this.bounded(() => this.readEntriesUnbounded(count));
|
|
315
|
+
}
|
|
316
|
+
async readEntriesUnbounded(count) {
|
|
317
|
+
const entries = [];
|
|
318
|
+
for (let index = 0; index < count; index++) {
|
|
319
|
+
const key = `${this.headerKey}:e:${index}`;
|
|
320
|
+
const head = await this.storage.get(key);
|
|
321
|
+
if (head === null)
|
|
322
|
+
return undefined;
|
|
323
|
+
const newline = head.indexOf("\n");
|
|
324
|
+
const parts = Number(head.slice(0, newline));
|
|
325
|
+
if (newline < 1 || !Number.isSafeInteger(parts) || parts < 1)
|
|
326
|
+
return undefined;
|
|
327
|
+
let text = head.slice(newline + 1);
|
|
328
|
+
for (let part = 1; part < parts; part++) {
|
|
329
|
+
const chunk = await this.storage.get(`${key}#${part}`);
|
|
330
|
+
if (chunk === null)
|
|
331
|
+
return undefined;
|
|
332
|
+
text += chunk;
|
|
333
|
+
}
|
|
334
|
+
try {
|
|
335
|
+
const entry = JSON.parse(text);
|
|
336
|
+
if (!isRecord(entry) || typeof entry.key !== "string" || !isRecord(entry.outcome)) {
|
|
337
|
+
return undefined;
|
|
338
|
+
}
|
|
339
|
+
entries.push(entry);
|
|
340
|
+
}
|
|
341
|
+
catch {
|
|
342
|
+
return undefined;
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
return entries;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// The Effect face of bounded admission (src/executor-admission.ts).
|
|
2
|
+
//
|
|
3
|
+
// AdmissionController is a published class, and its declaration — private
|
|
4
|
+
// member names included — is part of what ships, so its Effect program cannot
|
|
5
|
+
// hang off a new field or method. The class hands that program here from a
|
|
6
|
+
// static block instead: the one place outside its instance methods that may
|
|
7
|
+
// read its private bookkeeping, and one that emits nothing into the `.d.ts`.
|
|
8
|
+
// Promise callers keep `acquire()`, which runs the same program through
|
|
9
|
+
// runEdge; Effect callers come through this module and never see a Promise.
|
|
10
|
+
import { Effect } from "effect";
|
|
11
|
+
let program;
|
|
12
|
+
/**
|
|
13
|
+
* Install the controller's admission program. Called once, by
|
|
14
|
+
* AdmissionController's static block, when that module evaluates — which any
|
|
15
|
+
* importer of this module's value exports has already caused, since they need
|
|
16
|
+
* a controller to pass in.
|
|
17
|
+
*/
|
|
18
|
+
export function provideAdmissionProgram(admit) {
|
|
19
|
+
program = admit;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Admit one unit of work, as `controller.acquire()` does, without a Promise.
|
|
23
|
+
*
|
|
24
|
+
* Fails with the same ExecutorAdmissionError objects, bumps the same counters,
|
|
25
|
+
* and honours the same queue bound and timeout. Interrupting the fiber while
|
|
26
|
+
* it is queued withdraws it (counted as cancelled); interrupting it after a
|
|
27
|
+
* slot was handed over releases that slot rather than stranding it.
|
|
28
|
+
*/
|
|
29
|
+
export function admit(controller, options = {}) {
|
|
30
|
+
if (!program) {
|
|
31
|
+
throw new Error("AdmissionController's admission program was never installed.");
|
|
32
|
+
}
|
|
33
|
+
return program(controller, options.signal);
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* A lease owned by the enclosing Scope: released when the scope closes, on
|
|
37
|
+
* success, failure, or interruption alike. The wait itself stays
|
|
38
|
+
* interruptible — a queued request whose caller left must not hold its place
|
|
39
|
+
* in line until the queue timeout.
|
|
40
|
+
*/
|
|
41
|
+
export function acquireScoped(controller, options = {}) {
|
|
42
|
+
return Effect.acquireRelease(admit(controller, options), (lease) => Effect.sync(() => lease.release()), { interruptible: true });
|
|
43
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// The Effect face of downstream call admission (src/call-admission.ts).
|
|
2
|
+
//
|
|
3
|
+
// ConnectorCallAdmissionController is a published class whose declaration —
|
|
4
|
+
// private member names included — ships, so its Effect program cannot hang
|
|
5
|
+
// off a new field or method. As with AdmissionController (see
|
|
6
|
+
// src/runtime/admission.ts), the class hands that program here from a static
|
|
7
|
+
// block: the one place outside its instance methods that may read its private
|
|
8
|
+
// bookkeeping, and one that emits nothing into the `.d.ts`.
|
|
9
|
+
//
|
|
10
|
+
// The two controllers stay separate on purpose (#453): request admission
|
|
11
|
+
// bounds executor work, this bounds calls to one downstream connector, and a
|
|
12
|
+
// shared queue between them was refused.
|
|
13
|
+
import { Effect } from "effect";
|
|
14
|
+
let program;
|
|
15
|
+
// What the live Clock reads, looked up on every call rather than bound once,
|
|
16
|
+
// so a Date faked after a caller queued (vitest's setSystemTime) still counts.
|
|
17
|
+
const liveNow = () => Date.now();
|
|
18
|
+
/**
|
|
19
|
+
* Install the controller's admission program. Called once, by
|
|
20
|
+
* ConnectorCallAdmissionController's static block, when that module
|
|
21
|
+
* evaluates — which any caller has already caused, since it needs a
|
|
22
|
+
* controller to pass in.
|
|
23
|
+
*/
|
|
24
|
+
export function provideCallAdmissionProgram(admit) {
|
|
25
|
+
program = admit;
|
|
26
|
+
}
|
|
27
|
+
function installed() {
|
|
28
|
+
if (!program) {
|
|
29
|
+
throw new Error("ConnectorCallAdmissionController's admission program was never installed.");
|
|
30
|
+
}
|
|
31
|
+
return program;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* The Promise shell's entry: decide now, on `Date.now()` — the value the live
|
|
35
|
+
* Clock reads — and return only the wait.
|
|
36
|
+
*
|
|
37
|
+
* Deciding eagerly is what keeps the limiter payload-free. The returned
|
|
38
|
+
* effect closes over the partition key, never the request, so a queued call
|
|
39
|
+
* does not retain its tool arguments for as long as it waits.
|
|
40
|
+
*/
|
|
41
|
+
export function startCallAdmission(controller, request) {
|
|
42
|
+
return installed()(controller, request, liveNow);
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Admit one downstream call, as `controller.acquire()` does, without a
|
|
46
|
+
* Promise.
|
|
47
|
+
*
|
|
48
|
+
* Fails with the same CallAdmissionError objects and bumps the same counters.
|
|
49
|
+
* The rolling window and queue-wait times read the fiber's Clock. Nothing
|
|
50
|
+
* happens until the effect runs, and each run is a fresh admission.
|
|
51
|
+
* Interrupting the fiber while it is queued withdraws it (counted as
|
|
52
|
+
* cancelled); interrupting it after a slot was handed over releases that slot
|
|
53
|
+
* rather than stranding it.
|
|
54
|
+
*/
|
|
55
|
+
export function admitCall(controller, request) {
|
|
56
|
+
const admit = installed();
|
|
57
|
+
return Effect.clockWith((clock) => admit(controller, request, () => clock.currentTimeMillisUnsafe()));
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* A permit owned by the enclosing Scope: released when the scope closes, on
|
|
61
|
+
* success, failure, or interruption alike. The queued wait stays
|
|
62
|
+
* interruptible, so a caller that left does not hold its place in line until
|
|
63
|
+
* the queue timeout.
|
|
64
|
+
*/
|
|
65
|
+
export function acquireCallScoped(controller, request) {
|
|
66
|
+
return Effect.acquireRelease(admitCall(controller, request), (permit) => Effect.sync(() => permit.release()), { interruptible: true });
|
|
67
|
+
}
|