@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
|
@@ -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,106 @@
|
|
|
1
|
+
// One read that several readers in one request wait on, each on its own terms.
|
|
2
|
+
//
|
|
3
|
+
// Discovery and the registry both coalesce a connector's catalog read inside
|
|
4
|
+
// one request, so a search, a describe, and the call after them cost one
|
|
5
|
+
// downstream listing. The read used to run under whichever reader asked
|
|
6
|
+
// first: its signal was the read's signal, so a short-deadline call that
|
|
7
|
+
// happened to start the read failed a search that joined it with the call's
|
|
8
|
+
// timeout, and a cancelled search cancelled a read others were still waiting
|
|
9
|
+
// on (#571).
|
|
10
|
+
//
|
|
11
|
+
// So the read owns its signal. Each reader waits under its own cancellation,
|
|
12
|
+
// and the read is cancelled only when no reader is left to want it. Nothing
|
|
13
|
+
// here is shared across requests: a SharedRead lives in one request's map and
|
|
14
|
+
// is waited on only by that request's fibers, which is why a plain count of
|
|
15
|
+
// readers is enough and no edge is needed between them.
|
|
16
|
+
import { Deferred, Effect, Exit } from "effect";
|
|
17
|
+
export class SharedRead {
|
|
18
|
+
onEnd;
|
|
19
|
+
controller = new AbortController();
|
|
20
|
+
outcome = Deferred.makeUnsafe();
|
|
21
|
+
readers = 0;
|
|
22
|
+
ended = false;
|
|
23
|
+
/**
|
|
24
|
+
* Start `read` at once, under a signal the read owns. `onEnd` runs once:
|
|
25
|
+
* when the read settles, with whether it succeeded, or when it is cancelled
|
|
26
|
+
* first, with `false`. It runs before any reader hears the outcome, so an
|
|
27
|
+
* owner that evicts the read there sends a reader who retries on hearing of
|
|
28
|
+
* a failure to a fresh read, never back to this one.
|
|
29
|
+
*/
|
|
30
|
+
constructor(read, onEnd = () => { }) {
|
|
31
|
+
this.onEnd = onEnd;
|
|
32
|
+
// The executor turns a synchronous throw from `read` into a rejection.
|
|
33
|
+
new Promise((resolve) => resolve(read(this.controller.signal))).then((value) => this.settle(Exit.succeed(value)), (cause) => this.settle(Exit.fail(cause)));
|
|
34
|
+
}
|
|
35
|
+
settle(exit) {
|
|
36
|
+
this.end(Exit.isSuccess(exit));
|
|
37
|
+
Deferred.doneUnsafe(this.outcome, exit);
|
|
38
|
+
}
|
|
39
|
+
end(succeeded) {
|
|
40
|
+
if (this.ended)
|
|
41
|
+
return;
|
|
42
|
+
this.ended = true;
|
|
43
|
+
this.onEnd(succeeded);
|
|
44
|
+
}
|
|
45
|
+
// Cancel the read for good. After this it is no longer joinable: whatever
|
|
46
|
+
// it eventually settles with still reaches the readers already waiting, but
|
|
47
|
+
// its owner has evicted it and a newcomer starts afresh.
|
|
48
|
+
cancel(reason) {
|
|
49
|
+
if (this.ended)
|
|
50
|
+
return;
|
|
51
|
+
this.controller.abort(reason);
|
|
52
|
+
this.end(false);
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Wait for the read under this reader's own cancellation.
|
|
56
|
+
*
|
|
57
|
+
* A reader whose signal aborts while others still wait fails at once with
|
|
58
|
+
* its own reason and leaves the read to them. The last reader's abort is the
|
|
59
|
+
* read's: it is forwarded to the read's signal and that reader waits for the
|
|
60
|
+
* read's answer, as a lone reader always has, so a connector that finishes
|
|
61
|
+
* its listing anyway still hands it over. A reader that is interrupted — its
|
|
62
|
+
* deadline, as `withDeadlineEffect` ends it — simply leaves, and when it was
|
|
63
|
+
* the last one the read is cancelled with its reason.
|
|
64
|
+
*/
|
|
65
|
+
join(signal) {
|
|
66
|
+
return Effect.suspend(() => {
|
|
67
|
+
this.readers++;
|
|
68
|
+
let waiting = true;
|
|
69
|
+
const leave = Effect.sync(() => {
|
|
70
|
+
if (!waiting)
|
|
71
|
+
return;
|
|
72
|
+
waiting = false;
|
|
73
|
+
this.readers--;
|
|
74
|
+
if (this.readers === 0)
|
|
75
|
+
this.cancel(signal?.reason);
|
|
76
|
+
});
|
|
77
|
+
const answer = Deferred.await(this.outcome);
|
|
78
|
+
const own = signal
|
|
79
|
+
? Effect.raceFirst(answer, this.leftBy(signal))
|
|
80
|
+
: answer;
|
|
81
|
+
return Effect.ensuring(own, leave);
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
// Fails with the reader's abort reason when other readers remain; when it is
|
|
85
|
+
// the last, forwards the abort to the read and never completes, leaving the
|
|
86
|
+
// read's own answer to win the race.
|
|
87
|
+
leftBy(signal) {
|
|
88
|
+
return Effect.callback((resume) => {
|
|
89
|
+
const onAbort = () => {
|
|
90
|
+
signal.removeEventListener("abort", onAbort);
|
|
91
|
+
if (this.readers > 1) {
|
|
92
|
+
resume(Effect.fail(signal.reason));
|
|
93
|
+
}
|
|
94
|
+
else {
|
|
95
|
+
this.cancel(signal.reason);
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
if (signal.aborted) {
|
|
99
|
+
onAbort();
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
103
|
+
return Effect.sync(() => signal.removeEventListener("abort", onAbort));
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
}
|
package/dist/server.js
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { Effect } from "effect";
|
|
2
|
+
import { notFoundResponse } from "./branding.js";
|
|
2
3
|
import { aggregateCallAdmissionSnapshots } from "./call-admission.js";
|
|
3
4
|
import { isAdmittingExecutor } from "./executor-admission.js";
|
|
4
5
|
import { createMcpRoute, MCP_CORS_HEADERS } from "./routes/mcp.js";
|
|
5
6
|
import { routeOAuthCallback, } from "./routes/oauth.js";
|
|
6
7
|
import { runEdge } from "./runtime/run.js";
|
|
7
8
|
import { withSecurityHeaders, } from "./routes/shared.js";
|
|
9
|
+
const isArtifactPath = (path) => path === "/artifacts" || path.startsWith("/artifacts/");
|
|
8
10
|
/**
|
|
9
11
|
* Build the Web-standard fetch handler.
|
|
10
12
|
*
|
|
@@ -50,6 +52,10 @@ export function createFetchHandler(opts) {
|
|
|
50
52
|
...(opts.executorName !== undefined
|
|
51
53
|
? { executor: { name: opts.executorName } }
|
|
52
54
|
: {}),
|
|
55
|
+
// Whether a program's write pauses for resume_execution or fails E4.
|
|
56
|
+
// The tool is listed either way; this is the only place an operator
|
|
57
|
+
// can see which answer it gives (#565).
|
|
58
|
+
resumableWrites: opts.resumable !== undefined,
|
|
53
59
|
// Counts only, from refreshes that already happened — the endpoint
|
|
54
60
|
// asks no downstream anything, and `connecta doctor` reads it to
|
|
55
61
|
// report a stale allowlist without a probe of its own (#343).
|
|
@@ -71,6 +77,7 @@ export function createFetchHandler(opts) {
|
|
|
71
77
|
"/health",
|
|
72
78
|
...(opts.ui?.reservedPaths ?? []),
|
|
73
79
|
...(opts.ui && opts.activity?.list ? ["/activity"] : []),
|
|
80
|
+
...(opts.ui && opts.artifactsModule ? ["/artifacts", "/artifacts/*"] : []),
|
|
74
81
|
],
|
|
75
82
|
},
|
|
76
83
|
...(opts.deploymentInfo ? { deployment: opts.deploymentInfo } : {}),
|
|
@@ -101,7 +108,7 @@ export function createFetchHandler(opts) {
|
|
|
101
108
|
const response = yield* metadata(request, baseUrl);
|
|
102
109
|
if (response)
|
|
103
110
|
return response;
|
|
104
|
-
return
|
|
111
|
+
return notFoundResponse(request, opts);
|
|
105
112
|
}
|
|
106
113
|
if (path === "/health")
|
|
107
114
|
return yield* Effect.promise(health);
|
|
@@ -111,7 +118,7 @@ export function createFetchHandler(opts) {
|
|
|
111
118
|
const mcp = yield* routeMcp.handle(context);
|
|
112
119
|
if (mcp)
|
|
113
120
|
return mcp;
|
|
114
|
-
return
|
|
121
|
+
return notFoundResponse(request, opts);
|
|
115
122
|
});
|
|
116
123
|
const serve = (request, runtimeContext) => Effect.suspend(() => {
|
|
117
124
|
const url = new URL(request.url);
|
|
@@ -120,6 +127,27 @@ export function createFetchHandler(opts) {
|
|
|
120
127
|
const defer = runtimeContext
|
|
121
128
|
? runtimeContext.waitUntil.bind(runtimeContext)
|
|
122
129
|
: undefined;
|
|
130
|
+
// The artifact hostname has no MCP, health, OAuth, or operator routes.
|
|
131
|
+
// Dispatch before MCP origin checks so even a hostile /mcp gets the same
|
|
132
|
+
// 404 as any other unserved path.
|
|
133
|
+
if (opts.artifactOrigin && url.origin === new URL(opts.artifactOrigin).origin) {
|
|
134
|
+
if (!isArtifactPath(path) || !opts.ui || !opts.artifactsModule) {
|
|
135
|
+
return Effect.succeed(withSecurityHeaders(notFoundResponse(request, opts), url, path));
|
|
136
|
+
}
|
|
137
|
+
const context = {
|
|
138
|
+
request, url, path, baseUrl: opts.artifactOrigin, opts, defer, runtimeContext,
|
|
139
|
+
};
|
|
140
|
+
return Effect.map(Effect.promise(() => opts.ui.handle(context)), response => withSecurityHeaders(response ?? notFoundResponse(request, opts), url, path));
|
|
141
|
+
}
|
|
142
|
+
if (opts.artifactOrigin && isArtifactPath(path) && opts.ui && opts.artifactsModule) {
|
|
143
|
+
const target = new URL(opts.artifactOrigin);
|
|
144
|
+
target.pathname = path;
|
|
145
|
+
target.search = url.search;
|
|
146
|
+
return Effect.succeed(withSecurityHeaders(new Response(null, {
|
|
147
|
+
status: 308,
|
|
148
|
+
headers: { Location: target.toString() },
|
|
149
|
+
}), url, path));
|
|
150
|
+
}
|
|
123
151
|
const originRefusal = routeMcp.rejectOrigin(request);
|
|
124
152
|
if (originRefusal) {
|
|
125
153
|
return Effect.succeed(withSecurityHeaders(originRefusal, url, path));
|
package/dist/skills.js
CHANGED
|
@@ -1,10 +1,24 @@
|
|
|
1
1
|
import { boundedEchoText } from "./errors.js";
|
|
2
|
-
|
|
2
|
+
const ROUTE = "Choose a route before discovery. A known-address read needs only call_tool. Unknown-address read-only work starts with execute_code to discover, call, and return the answer; use the same route for reduction, multiple or dependent calls, loops, joins, or branches. Keep discovery and calls together when schemas suffice; do not return catalog matches alone. Inspect unfamiliar read results with a small sample before proceeding.";
|
|
3
|
+
const RECOVERY = 'After auth_required use authorize_connector. After a truncated direct result use get_result. Guidance is on demand: fetch skills({ name: "usage" }) only when these instructions and the tool description are insufficient or a run needs repair.';
|
|
4
|
+
/**
|
|
5
|
+
* The MCP instructions of a deployment whose programs pause at writes. The
|
|
6
|
+
* route sentence is its own, not ROUTE's: with writes in programs, "one known
|
|
7
|
+
* write" is the only work left for call_destructive_tool, and an agent told
|
|
8
|
+
* only that programs *may* write reads each write after a lookup as a known
|
|
9
|
+
* one and sends them one prompt at a time.
|
|
10
|
+
*/
|
|
11
|
+
export const CONNECTA_INSTRUCTIONS = `Choose a route before discovery. One known read: call_tool; one known write: call_destructive_tool. Everything else starts with execute_code to discover, call, and return the answer: unknown addresses, reduction, multiple or dependent calls, and writes that follow reads. Keep authorized batches in one program. Sample unfamiliar reads. Reduce a one-time write's full result there or page a direct call with get_result; never repeat it to recover output. Non-exempt program writes pause unsent; resume_execution with the returned token, address, and args approves the call and continues the program. Approval "tool" covers later same-address calls in this run; other approval-required writes need their own approval. ${RECOVERY}`;
|
|
12
|
+
/**
|
|
13
|
+
* The instructions of a deployment without resumable writes, whose programs
|
|
14
|
+
* refuse every write: the routing it always had.
|
|
15
|
+
*/
|
|
16
|
+
export const READ_ONLY_PROGRAM_INSTRUCTIONS = `${ROUTE} Only readOnlyHint: true tools run there. Keep catalog inspection and unannotated, write-capable, or destructive work top level: search_tools then call_destructive_tool when a call is needed. ${RECOVERY}`;
|
|
3
17
|
const USAGE_SKILL_BASE = `# Connecta usage
|
|
4
18
|
|
|
5
19
|
## The surface
|
|
6
20
|
|
|
7
|
-
|
|
21
|
+
Eight tools: \`execute_code\`, \`resume_execution\`, \`search_tools\`, \`call_tool\`, \`call_destructive_tool\`, \`authorize_connector\`, \`get_result\`, \`skills\`. Discovery and multi-call work live in a program. Top-level search remains for catalog inspection and approval-required work.
|
|
8
22
|
|
|
9
23
|
Follow the MCP instructions for routing. Read at most once for syntax or repair.
|
|
10
24
|
|
|
@@ -21,40 +35,45 @@ The minimum guest API is:
|
|
|
21
35
|
|
|
22
36
|
## Discover and select
|
|
23
37
|
|
|
24
|
-
Search and call in one run when schemas suffice.
|
|
38
|
+
Search and call in one run when schemas suffice. Sample unfamiliar reads. Reduce a one-time write's full result here or page a direct call with get_result; never repeat the write to recover output. Search distinct operations separately with 2–4 distinctive action/object terms.
|
|
25
39
|
|
|
26
40
|
For top-level catalog inspection or approval-required discovery, omit \`limit\` initially (the default is 8), then page with a limit up to 50 if needed. Empty or whitespace-only queries browse all tools. A non-empty query with no ASCII terms returns no matches; mixed input searches with its ASCII terms. \`includeSchemas: "compact"\` adds bounded input and available output shapes. An \`outputSchemaSource: "observed"\` shape is a hint, not a contract. Plain objects expose \`inputKeys\`, \`requiredInputKeys\`, and \`outputKeys\`; truncation flags mark incomplete shapes; matches also carry declared annotations.
|
|
27
41
|
|
|
28
|
-
- \`connecta.search({})\` loads all catalogs. Pass \`connector: "<id>"\` when the integration is obvious. Use \`safety: "readOnly"\` for
|
|
42
|
+
- \`connecta.search({})\` loads all catalogs. Pass \`connector: "<id>"\` when the integration is obvious. Use \`safety: "readOnly"\` for reads. These inputs filter discovery; they grant no authority.
|
|
29
43
|
- Use \`includeSchemas: "json"\` for programmatic schema inspection; compact schemas are text, not objects with \`.properties\`. Check connectorTitle for the account/environment, then address, purpose, annotations, inputs, and outputs. Never select only because a result ranks first or has fewer required inputs.
|
|
30
44
|
- Supply every \`requiredInputKey\` from the task or a prior result. For dependencies, match the earlier \`outputKey\` to the later required key. An empty required-key list does not permit invented arguments. Missing \`outputKeys\` means inspect \`outputSchema\`.
|
|
31
45
|
- Use \`connecta.describe({ address })\` or \`{ addresses }\` when a compact schema is truncated or insufficient. Use \`format: "json"\` only for exact constraints. Write the property names the schema displays; never guess positions or aliases.
|
|
32
|
-
- Reduce through available output keys.
|
|
46
|
+
- Reduce through available output keys. An observed key is a hint; later results may differ. Do not guess collection roots such as \`items\` or \`results\`. If a match is missing, re-search or describe.
|
|
33
47
|
- Match provider identifiers and names exactly after resolving them from source data or a connector guide. A broad regular expression that merely finds a plausible value is not identity resolution.
|
|
34
48
|
- Preserve the schema's JSON types exactly: a numeric id is a number, not a numeric-looking string.
|
|
35
49
|
- Validate tabular headers, row arrays, and row widths before mapping them. Never let a header or partial row become data.
|
|
36
50
|
|
|
37
|
-
Only
|
|
51
|
+
Only explicitly \`readOnlyHint: true\` or config-exempt calls run unasked. With resumable writes, non-exempt approval-required calls pause unsent; \`resume_execution\` repeats the pending address, args, and token. Without them, use \`call_destructive_tool\` for writes. Keep each authorized batch in one resumable program, final post included. \`approval: "call"\` covers one write; \`approval: "tool"\` covers later same-address calls in this run. Other approval-required writes need approval. Replay requires the same call order. Sandbox code cannot widen host gates.
|
|
38
52
|
|
|
39
53
|
## Errors and repair
|
|
40
54
|
|
|
41
55
|
Caught Connecta errors expose \`message\`, \`code\`, \`retryable\`, and \`details\`. Promise rejections retain these fields. Branch on fields, never prose. After a shared argument failure, repair one call before repeating it across other records. Do not retry \`retryable: false\`, and do not retry \`rate_limited\` immediately because portable code has no timer.
|
|
42
56
|
|
|
43
|
-
- \`destructive_tool_requires_approval\`: stop
|
|
57
|
+
- \`destructive_tool_requires_approval\`: stop and send the returned address through top-level \`call_destructive_tool\`.
|
|
58
|
+
- \`execution_paused\`: the run already stopped at a write; let it end.
|
|
59
|
+
- \`write_outcome_unknown\`: sent but unanswered, never re-sent; check the target.
|
|
60
|
+
- \`execution_expired\`, \`_diverged\`, \`_interrupted\`: check \`writes\`, then rerun.
|
|
44
61
|
- \`auth_required\`: let the failure reach the model, then use top-level \`authorize_connector\`, give its handoff to the operator, and retry after recovery.
|
|
45
|
-
-
|
|
62
|
+
- Truncated direct call: page with \`get_result\`, never repeat a write. A program result has no page handle; reduce a read in a new program. After a write, check the target or report the gap.
|
|
46
63
|
- Unknown addresses and tools carry scoped search recovery. Use it inside the current run. Do not invent an address.
|
|
47
64
|
|
|
48
65
|
For a direct call, \`resultMode: "value"\` unwraps the result. \`timeoutMs\` sets its deadline. Every call makes one attempt; use the returned error classification and retry hint to decide whether to reissue. \`diagnostics: true\` adds timing.
|
|
49
66
|
|
|
50
67
|
\`get_result({ id, offset?, maxBytes? })\` returns a one-line JSON header \`{ resultId, offset, bytes, totalBytes, hasMore, nextAction? }\`, a newline, and then the page as raw text, for a direct-call result. Both sizes are byte counts: \`maxBytes\` must be a whole number at least 1, defaults to the result's cap, and is clamped to it; \`offset\` must be a whole number at least 0 and defaults to 0. An offset inside a multi-byte character moves back to its first byte, and the header reports the served offset. Follow \`nextAction\` until \`hasMore\` is false to reassemble pages. An unknown or expired id is an error.
|
|
51
68
|
|
|
52
|
-
|
|
69
|
+
The \`execute_code\` description states this deployment's host-call and write budgets and per-call deadline.
|
|
53
70
|
|
|
54
71
|
## Runtime portability
|
|
55
72
|
|
|
56
73
|
Portable code uses standard JavaScript builtins, \`connecta\`, and \`console.*\`. QuickJS blocks imports and lacks fetch, process, timers, crypto, and WebSocket. Dynamic Workers must use only \`{ loader }\`; bindings, modules, or globalOutbound grant ambient authority. With loader only, environment maps are empty; node:fs/http/https are absent; outbound fetch, WebSocket, node:net, and node:tls are denied; DNS is unresolved. Runtime builtins remain through \`import()\` and \`process.getBuiltinModule()\`, including node:path and cloudflare:workers; this set can drift. Timers, process, crypto, WebSocket, and data: fetch remain. Avoid every runtime-only capability because QuickJS fails.
|
|
57
74
|
|
|
75
|
+
\`Date.now()\` is the run's start and does not advance; \`Math.random()\` is seeded per run.
|
|
76
|
+
|
|
58
77
|
## Examples
|
|
59
78
|
|
|
60
79
|
One read-only call at a known address:
|
|
@@ -95,8 +114,8 @@ Connector guides appear in \`skills({})\` and discovery with an exact \`guide\`
|
|
|
95
114
|
/** Shared Connecta routing guidance, byte-identical across deployments. */
|
|
96
115
|
export const USAGE_SKILL = USAGE_SKILL_BASE + CONNECTOR_GUIDES_SECTION;
|
|
97
116
|
/** The always-loaded MCP `instructions` string. */
|
|
98
|
-
export function instructionsFor() {
|
|
99
|
-
return CONNECTA_INSTRUCTIONS;
|
|
117
|
+
export function instructionsFor(resumableWrites) {
|
|
118
|
+
return resumableWrites ? CONNECTA_INSTRUCTIONS : READ_ONLY_PROGRAM_INSTRUCTIONS;
|
|
100
119
|
}
|
|
101
120
|
/** True when at least one of `connectors` carries a usage guide. */
|
|
102
121
|
export function hasConnectorGuides(connectors) {
|
package/dist/timeout.js
CHANGED
|
@@ -31,6 +31,32 @@ export function withDeadline(operation, options) {
|
|
|
31
31
|
catch: (error) => error,
|
|
32
32
|
}), options));
|
|
33
33
|
}
|
|
34
|
+
/**
|
|
35
|
+
* Run `operation` under one signal that aborts when any of `signals` does —
|
|
36
|
+
* the MCP call's own and the inbound request's, for the program tools — and
|
|
37
|
+
* detach every listener afterwards, however it ends.
|
|
38
|
+
*/
|
|
39
|
+
export async function underAnySignal(signals, operation) {
|
|
40
|
+
const controller = new AbortController();
|
|
41
|
+
const forwarders = signals
|
|
42
|
+
.filter((signal) => signal !== undefined)
|
|
43
|
+
.map((signal) => {
|
|
44
|
+
const forward = () => controller.abort(signal.reason);
|
|
45
|
+
if (signal.aborted)
|
|
46
|
+
forward();
|
|
47
|
+
else
|
|
48
|
+
signal.addEventListener("abort", forward, { once: true });
|
|
49
|
+
return { signal, forward };
|
|
50
|
+
});
|
|
51
|
+
try {
|
|
52
|
+
return await operation(controller.signal);
|
|
53
|
+
}
|
|
54
|
+
finally {
|
|
55
|
+
for (const { signal, forward } of forwarders) {
|
|
56
|
+
signal.removeEventListener("abort", forward);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
34
60
|
/**
|
|
35
61
|
* Give one operation a caller-facing deadline and the matching cancellation
|
|
36
62
|
* signal. The timeout rejects with the stable, labelled error while aborting
|