@zackbart/connecta 0.7.5 → 0.7.7
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/CHANGELOG.md +108 -0
- package/README.md +1 -0
- package/dist/activity.d.ts +17 -0
- package/dist/activity.d.ts.map +1 -1
- package/dist/activity.js.map +1 -1
- package/dist/auth/clerk.d.ts.map +1 -1
- package/dist/auth/clerk.js +101 -0
- package/dist/auth/clerk.js.map +1 -1
- package/dist/auth/downstream-oauth.d.ts +57 -20
- package/dist/auth/downstream-oauth.d.ts.map +1 -1
- package/dist/auth/downstream-oauth.js +275 -67
- package/dist/auth/downstream-oauth.js.map +1 -1
- package/dist/connectors/remote-mcp.d.ts.map +1 -1
- package/dist/connectors/remote-mcp.js +165 -103
- package/dist/connectors/remote-mcp.js.map +1 -1
- package/dist/execute.d.ts.map +1 -1
- package/dist/execute.js +11 -0
- package/dist/execute.js.map +1 -1
- package/dist/executor-admission.d.ts +18 -1
- package/dist/executor-admission.d.ts.map +1 -1
- package/dist/executor-admission.js +82 -3
- package/dist/executor-admission.js.map +1 -1
- package/dist/executors/quickjs-protocol.d.ts +1 -0
- package/dist/executors/quickjs-protocol.d.ts.map +1 -1
- package/dist/executors/quickjs-protocol.js +7 -4
- package/dist/executors/quickjs-protocol.js.map +1 -1
- package/dist/executors/quickjs-runtime.d.ts.map +1 -1
- package/dist/executors/quickjs-runtime.js +22 -9
- package/dist/executors/quickjs-runtime.js.map +1 -1
- package/dist/executors/quickjs.d.ts.map +1 -1
- package/dist/executors/quickjs.js +44 -10
- package/dist/executors/quickjs.js.map +1 -1
- package/dist/index.d.ts +31 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +38 -1
- package/dist/index.js.map +1 -1
- package/dist/registry.d.ts +12 -0
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +70 -15
- package/dist/registry.js.map +1 -1
- package/dist/server.d.ts +3 -0
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +413 -35
- package/dist/server.js.map +1 -1
- package/dist/storage/file.d.ts.map +1 -1
- package/dist/storage/file.js +8 -0
- package/dist/storage/file.js.map +1 -1
- package/dist/types.d.ts +47 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/ui.d.ts +7 -1
- package/dist/ui.d.ts.map +1 -1
- package/dist/ui.js +118 -4
- package/dist/ui.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +2 -1
- package/src/activity.ts +20 -0
- package/src/auth/clerk.ts +124 -0
- package/src/auth/downstream-oauth.ts +359 -68
- package/src/connectors/remote-mcp.ts +172 -104
- package/src/execute.ts +11 -0
- package/src/executor-admission.ts +90 -3
- package/src/executors/quickjs-protocol.ts +7 -4
- package/src/executors/quickjs-runtime.ts +31 -9
- package/src/executors/quickjs.ts +61 -12
- package/src/index.ts +90 -1
- package/src/registry.ts +79 -19
- package/src/server.ts +523 -45
- package/src/storage/file.ts +7 -0
- package/src/types.ts +50 -0
- package/src/ui.ts +124 -3
- package/src/version.ts +1 -1
|
@@ -14,6 +14,10 @@ import {
|
|
|
14
14
|
type QuickJSDeferredPromise,
|
|
15
15
|
} from "quickjs-emscripten";
|
|
16
16
|
import type { ExecuteResult, ExecutorProvider } from "../types.js";
|
|
17
|
+
import {
|
|
18
|
+
MAX_QUICKJS_LOG_TRANSPORT_BYTES,
|
|
19
|
+
serializedBytes,
|
|
20
|
+
} from "./quickjs-protocol.js";
|
|
17
21
|
|
|
18
22
|
export interface QuickJsRuntimeOptions {
|
|
19
23
|
timeoutMs: number;
|
|
@@ -31,11 +35,16 @@ const MAX_LOG_ENTRIES = 200;
|
|
|
31
35
|
// Cap each entry AND the cumulative buffer at capture time so untrusted guest
|
|
32
36
|
// code can't retain unbounded host memory: a single `console.log("x".repeat(N))`
|
|
33
37
|
// otherwise copies the whole N-char guest string into a host array we hold for
|
|
34
|
-
// the entire execution.
|
|
35
|
-
//
|
|
36
|
-
// keeps the worst case — 200 maxed-out entries — bounded well under a MiB.
|
|
38
|
+
// the entire execution. The separate transport-byte budget below accounts for
|
|
39
|
+
// JSON escaping and is what keeps the result envelope below its IPC ceiling.
|
|
37
40
|
const MAX_LOG_ENTRY_CHARS = 8_000;
|
|
38
41
|
const MAX_LOG_TOTAL_CHARS = 256_000;
|
|
42
|
+
const LOG_ENTRY_LIMIT_MARKER = `[log truncated after ${MAX_LOG_ENTRIES} entries]`;
|
|
43
|
+
const LOG_SIZE_LIMIT_MARKER = "[log truncated: size budget exceeded]";
|
|
44
|
+
const MAX_LOG_MARKER_TRANSPORT_BYTES = Math.max(
|
|
45
|
+
logTransportBytes(LOG_ENTRY_LIMIT_MARKER),
|
|
46
|
+
logTransportBytes(LOG_SIZE_LIMIT_MARKER),
|
|
47
|
+
);
|
|
39
48
|
// Keep one host result below the range where quickjs-emscripten@0.32.0 can
|
|
40
49
|
// nondeterministically fail during runtime disposal under concurrent load.
|
|
41
50
|
// This still lets guest code reduce data more than ten times larger than
|
|
@@ -46,6 +55,12 @@ function msg(err: unknown): string {
|
|
|
46
55
|
return err instanceof Error ? err.message : String(err);
|
|
47
56
|
}
|
|
48
57
|
|
|
58
|
+
function logTransportBytes(entry: string): number {
|
|
59
|
+
// The log is encoded into ExecutionPayload, then that payloadJson string is
|
|
60
|
+
// encoded into ChildToParentMessage. Measure the units the IPC cap sees.
|
|
61
|
+
return serializedBytes(JSON.stringify(JSON.stringify(entry)));
|
|
62
|
+
}
|
|
63
|
+
|
|
49
64
|
function exceedsUtf8ByteLimit(value: string, limit: number): boolean {
|
|
50
65
|
let bytes = 0;
|
|
51
66
|
for (let index = 0; index < value.length; index += 1) {
|
|
@@ -175,15 +190,16 @@ function installBridge(
|
|
|
175
190
|
};
|
|
176
191
|
armWake(bridge);
|
|
177
192
|
|
|
178
|
-
//
|
|
179
|
-
// budget
|
|
180
|
-
//
|
|
193
|
+
// Keep both the in-memory character budget and the twice-JSON-encoded
|
|
194
|
+
// transport budget. Reserve enough byte budget for whichever truncation
|
|
195
|
+
// marker ends the stream.
|
|
181
196
|
let logTotalChars = 0;
|
|
197
|
+
let logTotalTransportBytes = 0;
|
|
182
198
|
let logBudgetSpent = false;
|
|
183
199
|
const logFn = ctx.newFunction("__log", (h) => {
|
|
184
200
|
if (logs.length >= MAX_LOG_ENTRIES) {
|
|
185
201
|
if (logs.length === MAX_LOG_ENTRIES) {
|
|
186
|
-
logs.push(
|
|
202
|
+
logs.push(LOG_ENTRY_LIMIT_MARKER);
|
|
187
203
|
}
|
|
188
204
|
return;
|
|
189
205
|
}
|
|
@@ -194,13 +210,19 @@ function installBridge(
|
|
|
194
210
|
if (entry.length > MAX_LOG_ENTRY_CHARS) {
|
|
195
211
|
entry = `${entry.slice(0, MAX_LOG_ENTRY_CHARS)}…[entry truncated]`;
|
|
196
212
|
}
|
|
197
|
-
|
|
198
|
-
|
|
213
|
+
const entryTransportBytes = logTransportBytes(entry);
|
|
214
|
+
if (
|
|
215
|
+
logTotalChars + entry.length > MAX_LOG_TOTAL_CHARS ||
|
|
216
|
+
logTotalTransportBytes + entryTransportBytes >
|
|
217
|
+
MAX_QUICKJS_LOG_TRANSPORT_BYTES - MAX_LOG_MARKER_TRANSPORT_BYTES
|
|
218
|
+
) {
|
|
219
|
+
logs.push(LOG_SIZE_LIMIT_MARKER);
|
|
199
220
|
logBudgetSpent = true;
|
|
200
221
|
return;
|
|
201
222
|
}
|
|
202
223
|
logs.push(entry);
|
|
203
224
|
logTotalChars += entry.length;
|
|
225
|
+
logTotalTransportBytes += entryTransportBytes;
|
|
204
226
|
});
|
|
205
227
|
ctx.setProp(ctx.global, "__log", logFn);
|
|
206
228
|
logFn.dispose();
|
package/src/executors/quickjs.ts
CHANGED
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
// child process: guest CPU, WASM aborts, and interpreter OOMs cannot block or
|
|
3
3
|
// terminate the HTTP-serving process.
|
|
4
4
|
|
|
5
|
+
import { Buffer } from "node:buffer";
|
|
5
6
|
import { fork, type ChildProcess } from "node:child_process";
|
|
7
|
+
import { existsSync } from "node:fs";
|
|
6
8
|
import { fileURLToPath } from "node:url";
|
|
7
9
|
import {
|
|
8
10
|
AdmissionController,
|
|
@@ -10,6 +12,7 @@ import {
|
|
|
10
12
|
} from "../executor-admission.js";
|
|
11
13
|
import type {
|
|
12
14
|
AdmittingExecutor,
|
|
15
|
+
AdmissionSnapshot,
|
|
13
16
|
ExecuteResult,
|
|
14
17
|
ExecutorLease,
|
|
15
18
|
ExecutorProvider,
|
|
@@ -84,12 +87,33 @@ const DEFAULT_MAX_QUEUE_SIZE = 32;
|
|
|
84
87
|
const DEFAULT_QUEUE_TIMEOUT_MS = 5_000;
|
|
85
88
|
const CHILD_EXIT_GRACE_MS = 250;
|
|
86
89
|
const CHILD_STARTUP_TIMEOUT_MS = 10_000;
|
|
90
|
+
const MAX_CHILD_STDERR_BYTES = 8 * 1024;
|
|
87
91
|
const MAX_ERROR_CHARS = 4_000;
|
|
88
92
|
|
|
89
93
|
function msg(err: unknown): string {
|
|
90
94
|
return err instanceof Error ? err.message : String(err);
|
|
91
95
|
}
|
|
92
96
|
|
|
97
|
+
function retainStderrTail(current: Buffer, chunk: Buffer | string): Buffer {
|
|
98
|
+
const incoming = typeof chunk === "string" ? Buffer.from(chunk) : chunk;
|
|
99
|
+
if (incoming.length >= MAX_CHILD_STDERR_BYTES) {
|
|
100
|
+
return Buffer.from(
|
|
101
|
+
incoming.subarray(incoming.length - MAX_CHILD_STDERR_BYTES),
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
const combined = Buffer.concat([current, incoming]);
|
|
105
|
+
if (combined.length <= MAX_CHILD_STDERR_BYTES) return combined;
|
|
106
|
+
return Buffer.from(combined.subarray(combined.length - MAX_CHILD_STDERR_BYTES));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function childExitError(message: string, stderrTail: Buffer): Error {
|
|
110
|
+
if (stderrTail.length === 0) return new Error(message);
|
|
111
|
+
return new Error(
|
|
112
|
+
`${message}\nRecent child stderr (last ${stderrTail.length} bytes):\n` +
|
|
113
|
+
stderrTail.toString("utf8"),
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
93
117
|
function positiveWhole(
|
|
94
118
|
value: number | undefined,
|
|
95
119
|
fallback: number,
|
|
@@ -256,6 +280,7 @@ class QuickJsChildPool implements AdmittingExecutor {
|
|
|
256
280
|
let released = false;
|
|
257
281
|
let executed = false;
|
|
258
282
|
return {
|
|
283
|
+
waitMs: admission.waitMs,
|
|
259
284
|
execute: async (code, providers) => {
|
|
260
285
|
if (released) throw new Error("Executor lease was already released.");
|
|
261
286
|
if (executed) throw new Error("Executor lease may execute only once.");
|
|
@@ -278,6 +303,10 @@ class QuickJsChildPool implements AdmittingExecutor {
|
|
|
278
303
|
};
|
|
279
304
|
}
|
|
280
305
|
|
|
306
|
+
admissionSnapshot(): AdmissionSnapshot {
|
|
307
|
+
return this.admission.snapshot();
|
|
308
|
+
}
|
|
309
|
+
|
|
281
310
|
async execute(
|
|
282
311
|
code: string,
|
|
283
312
|
providers: ExecutorProvider[],
|
|
@@ -425,10 +454,26 @@ class QuickJsChildPool implements AdmittingExecutor {
|
|
|
425
454
|
sourceMode ? "./quickjs-child.ts" : "./quickjs-child.js",
|
|
426
455
|
import.meta.url,
|
|
427
456
|
);
|
|
428
|
-
const
|
|
457
|
+
const childPath = fileURLToPath(childUrl);
|
|
458
|
+
if (!existsSync(childPath)) {
|
|
459
|
+
throw new Error(
|
|
460
|
+
`QuickJS child entry is missing at ${childPath}. ` +
|
|
461
|
+
"The @zackbart/connecta/quickjs subpath requires the package file " +
|
|
462
|
+
"layout on disk; externalize @zackbart/connecta (or at least " +
|
|
463
|
+
"@zackbart/connecta/quickjs) when bundling the server.",
|
|
464
|
+
);
|
|
465
|
+
}
|
|
466
|
+
const child = fork(childPath, [], {
|
|
429
467
|
execArgv: sourceMode ? ["--import", "tsx"] : [],
|
|
430
|
-
stdio: ["ignore", "ignore", "
|
|
468
|
+
stdio: ["ignore", "ignore", "pipe", "ipc"],
|
|
431
469
|
});
|
|
470
|
+
let stderrTail: Buffer = Buffer.alloc(0);
|
|
471
|
+
child.stderr?.on("data", (chunk: Buffer | string) => {
|
|
472
|
+
stderrTail = retainStderrTail(stderrTail, chunk);
|
|
473
|
+
});
|
|
474
|
+
(
|
|
475
|
+
child.stderr as (NodeJS.ReadableStream & { unref?: () => void }) | null
|
|
476
|
+
)?.unref?.();
|
|
432
477
|
slot.child = child;
|
|
433
478
|
slot.ready = new Promise<void>((resolve, reject) => {
|
|
434
479
|
slot.resolveReady = resolve;
|
|
@@ -453,26 +498,30 @@ class QuickJsChildPool implements AdmittingExecutor {
|
|
|
453
498
|
this.rejectActive(slot, error);
|
|
454
499
|
this.recycle(slot);
|
|
455
500
|
});
|
|
456
|
-
|
|
501
|
+
// `close`, unlike `exit`, runs after the stdio streams have closed, so the
|
|
502
|
+
// diagnostic includes stderr bytes flushed immediately before a crash.
|
|
503
|
+
child.on("close", (code, exitSignal) => {
|
|
457
504
|
const expected = slot.expectedExit === child;
|
|
458
505
|
if (slot.expectedExit === child) slot.expectedExit = undefined;
|
|
506
|
+
const exitDescription = `${
|
|
507
|
+
exitSignal ? `signal ${exitSignal}` : `code ${String(code)}`
|
|
508
|
+
}`;
|
|
459
509
|
this.rejectChildReady(
|
|
460
510
|
slot,
|
|
461
511
|
child,
|
|
462
|
-
|
|
463
|
-
`QuickJS child exited before becoming ready
|
|
464
|
-
|
|
512
|
+
childExitError(
|
|
513
|
+
`QuickJS child exited before becoming ready (${exitDescription}).`,
|
|
514
|
+
stderrTail,
|
|
465
515
|
),
|
|
466
516
|
);
|
|
467
517
|
if (slot.child === child) slot.child = undefined;
|
|
468
518
|
if (expected) return;
|
|
469
519
|
this.recordCrash(slot);
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
});
|
|
520
|
+
const error = childExitError(
|
|
521
|
+
`QuickJS child exited unexpectedly (${exitDescription}).`,
|
|
522
|
+
stderrTail,
|
|
523
|
+
);
|
|
524
|
+
this.resolveActive(slot, { result: undefined, error: error.message });
|
|
476
525
|
});
|
|
477
526
|
child.unref();
|
|
478
527
|
child.channel?.unref();
|
package/src/index.ts
CHANGED
|
@@ -14,6 +14,11 @@ import {
|
|
|
14
14
|
} from "./toolkits.js";
|
|
15
15
|
import { memoryStorage } from "./storage/memory.js";
|
|
16
16
|
import { CONNECTA_VERSION } from "./version.js";
|
|
17
|
+
import {
|
|
18
|
+
AdmissionController,
|
|
19
|
+
isAdmittingExecutor,
|
|
20
|
+
withExecutorAdmission,
|
|
21
|
+
} from "./executor-admission.js";
|
|
17
22
|
import type { ActivityReadGate, ActivityStore } from "./activity.js";
|
|
18
23
|
import type {
|
|
19
24
|
CredentialCheckResult,
|
|
@@ -108,6 +113,35 @@ export interface ConnectaCallsConfig {
|
|
|
108
113
|
maxResultBytes?: number;
|
|
109
114
|
}
|
|
110
115
|
|
|
116
|
+
export interface AdmissionPoolConfig {
|
|
117
|
+
/** Simultaneous work admitted to this pool. */
|
|
118
|
+
concurrency?: number;
|
|
119
|
+
/** Callers allowed to wait behind active work. Set zero to fail fast. */
|
|
120
|
+
maxQueueSize?: number;
|
|
121
|
+
/** Maximum queue wait in milliseconds. */
|
|
122
|
+
queueTimeoutMs?: number;
|
|
123
|
+
/** Retry hint returned with overload failures, in milliseconds. */
|
|
124
|
+
retryAfterMs?: number;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Runtime-portable server-memory boundaries. `/health` and operator routes do
|
|
129
|
+
* not consume these permits, so they remain responsive during MCP saturation.
|
|
130
|
+
*/
|
|
131
|
+
export interface ConnectaAdmissionConfig {
|
|
132
|
+
/**
|
|
133
|
+
* The `/mcp` request boundary. Defaults to 16 active, 32 queued, and a
|
|
134
|
+
* 5-second maximum wait.
|
|
135
|
+
*/
|
|
136
|
+
requests?: AdmissionPoolConfig;
|
|
137
|
+
/**
|
|
138
|
+
* Fallback pool for an `executor` that does not implement its own `acquire`.
|
|
139
|
+
* Defaults to 2 active, 8 queued, and a 5-second maximum wait. Bounded
|
|
140
|
+
* executors (including `quickJsExecutor`) keep their own tighter pool.
|
|
141
|
+
*/
|
|
142
|
+
code?: AdmissionPoolConfig;
|
|
143
|
+
}
|
|
144
|
+
|
|
111
145
|
export interface ConnectaConfig {
|
|
112
146
|
connectors: Connector[];
|
|
113
147
|
/**
|
|
@@ -159,6 +193,8 @@ export interface ConnectaConfig {
|
|
|
159
193
|
discovery?: ConnectaDiscoveryConfig;
|
|
160
194
|
/** Deployment-wide call deadlines and result paging threshold. */
|
|
161
195
|
calls?: ConnectaCallsConfig;
|
|
196
|
+
/** Bounded MCP and fallback code-mode admission. */
|
|
197
|
+
admission?: ConnectaAdmissionConfig;
|
|
162
198
|
/** Optional browser UI and OAuth result-page labels. */
|
|
163
199
|
branding?: ConnectaBranding;
|
|
164
200
|
logger?: Logger;
|
|
@@ -222,6 +258,32 @@ export interface Connecta {
|
|
|
222
258
|
close: () => Promise<void>;
|
|
223
259
|
}
|
|
224
260
|
|
|
261
|
+
const REQUEST_ADMISSION_DEFAULTS = {
|
|
262
|
+
concurrency: 16,
|
|
263
|
+
maxQueueSize: 32,
|
|
264
|
+
queueTimeoutMs: 5_000,
|
|
265
|
+
retryAfterMs: 1_000,
|
|
266
|
+
} as const;
|
|
267
|
+
|
|
268
|
+
const CODE_ADMISSION_DEFAULTS = {
|
|
269
|
+
concurrency: 2,
|
|
270
|
+
maxQueueSize: 8,
|
|
271
|
+
queueTimeoutMs: 5_000,
|
|
272
|
+
retryAfterMs: 1_000,
|
|
273
|
+
} as const;
|
|
274
|
+
|
|
275
|
+
function admissionController(
|
|
276
|
+
options: AdmissionPoolConfig | undefined,
|
|
277
|
+
defaults: typeof REQUEST_ADMISSION_DEFAULTS | typeof CODE_ADMISSION_DEFAULTS,
|
|
278
|
+
): AdmissionController {
|
|
279
|
+
return new AdmissionController({
|
|
280
|
+
concurrency: options?.concurrency ?? defaults.concurrency,
|
|
281
|
+
maxQueueSize: options?.maxQueueSize ?? defaults.maxQueueSize,
|
|
282
|
+
queueTimeoutMs: options?.queueTimeoutMs ?? defaults.queueTimeoutMs,
|
|
283
|
+
retryAfterMs: options?.retryAfterMs ?? defaults.retryAfterMs,
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
|
|
225
287
|
function defaultLogger(): Logger {
|
|
226
288
|
return {
|
|
227
289
|
debug: (...a) => console.debug("[connecta]", ...a),
|
|
@@ -495,6 +557,26 @@ export function createConnecta(config: ConnectaConfig): Connecta {
|
|
|
495
557
|
// with a 403 its client reports as a transport failure. Throw here instead.
|
|
496
558
|
validateToolkitBindings(inboundAuth, toolkits);
|
|
497
559
|
warnInsecureConfig(config, inboundAuth, toolkits, logger);
|
|
560
|
+
const requestAdmission = admissionController(
|
|
561
|
+
config.admission?.requests,
|
|
562
|
+
REQUEST_ADMISSION_DEFAULTS,
|
|
563
|
+
);
|
|
564
|
+
const configuredCodeAdmission = admissionController(
|
|
565
|
+
config.admission?.code,
|
|
566
|
+
CODE_ADMISSION_DEFAULTS,
|
|
567
|
+
);
|
|
568
|
+
let codeAdmission: AdmissionController | undefined;
|
|
569
|
+
let executor = config.executor;
|
|
570
|
+
if (executor && !isAdmittingExecutor(executor)) {
|
|
571
|
+
codeAdmission = configuredCodeAdmission;
|
|
572
|
+
executor = withExecutorAdmission(executor, codeAdmission);
|
|
573
|
+
} else if (executor && config.admission?.code) {
|
|
574
|
+
logger.warn(
|
|
575
|
+
"[connecta] admission.code is ignored because the configured executor " +
|
|
576
|
+
"implements acquire() and owns its admission pool; configure that " +
|
|
577
|
+
"executor's concurrency and queue options instead.",
|
|
578
|
+
);
|
|
579
|
+
}
|
|
498
580
|
const handler = createFetchHandler({
|
|
499
581
|
registry,
|
|
500
582
|
auth: inboundAuth,
|
|
@@ -508,7 +590,8 @@ export function createConnecta(config: ConnectaConfig): Connecta {
|
|
|
508
590
|
activity: config.activity?.store,
|
|
509
591
|
activityReadGate: config.activity?.readGate,
|
|
510
592
|
activityDeploymentId: config.activity?.deploymentId,
|
|
511
|
-
executor
|
|
593
|
+
executor,
|
|
594
|
+
requestAdmission,
|
|
512
595
|
defaultToolTimeoutMs: config.calls?.defaultTimeoutMs,
|
|
513
596
|
probeTimeoutMs: config.discovery?.probeTimeoutMs,
|
|
514
597
|
credentialVault,
|
|
@@ -555,6 +638,8 @@ export function createConnecta(config: ConnectaConfig): Connecta {
|
|
|
555
638
|
},
|
|
556
639
|
close: async () => {
|
|
557
640
|
closePromise ??= Promise.resolve().then(async () => {
|
|
641
|
+
requestAdmission.close();
|
|
642
|
+
codeAdmission?.close();
|
|
558
643
|
await config.executor?.close?.();
|
|
559
644
|
});
|
|
560
645
|
await closePromise;
|
|
@@ -614,6 +699,7 @@ export type {
|
|
|
614
699
|
ConnectorStatus,
|
|
615
700
|
CredentialTestResult,
|
|
616
701
|
AdmittingExecutor,
|
|
702
|
+
AdmissionSnapshot,
|
|
617
703
|
ExecuteResult,
|
|
618
704
|
Executor,
|
|
619
705
|
ExecutorLease,
|
|
@@ -633,8 +719,11 @@ export type {
|
|
|
633
719
|
ActivityCallSource,
|
|
634
720
|
ActivityOutcome,
|
|
635
721
|
ActivityPage,
|
|
722
|
+
ActivityReadActor,
|
|
723
|
+
ActivityReadEvent,
|
|
636
724
|
ActivityReader,
|
|
637
725
|
ActivityReadGate,
|
|
726
|
+
ActivityReadPage,
|
|
638
727
|
ActivitySink,
|
|
639
728
|
ActivityStore,
|
|
640
729
|
ToolCallActivityEvent,
|
package/src/registry.ts
CHANGED
|
@@ -235,6 +235,10 @@ export class Registry implements RegistryView {
|
|
|
235
235
|
private readonly connectors = new Map<string, Connector>();
|
|
236
236
|
private readonly cache = new Map<string, CacheEntry>();
|
|
237
237
|
private readonly invalidated = new Set<string>();
|
|
238
|
+
/** Per-connector epoch preventing a pre-invalidation refresh from publishing. */
|
|
239
|
+
private readonly catalogGenerations = new Map<string, number>();
|
|
240
|
+
/** Serialize persisted catalog set/delete operations within this isolate. */
|
|
241
|
+
private readonly catalogMutations = new Map<string, Promise<void>>();
|
|
238
242
|
/** Deployment-wide observations — every call, whatever view made it. */
|
|
239
243
|
private readonly health = new HealthLog();
|
|
240
244
|
private readonly ttlMs: number;
|
|
@@ -459,6 +463,36 @@ export class Registry implements RegistryView {
|
|
|
459
463
|
});
|
|
460
464
|
}
|
|
461
465
|
|
|
466
|
+
private catalogGeneration(id: string): number {
|
|
467
|
+
return this.catalogGenerations.get(id) ?? 0;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
private advanceCatalogGeneration(id: string): void {
|
|
471
|
+
this.catalogGenerations.set(id, this.catalogGeneration(id) + 1);
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/**
|
|
475
|
+
* Keep this isolate's writes and invalidations ordered. Without the queue, an
|
|
476
|
+
* old refresh can finish its storage.set after a credential change deletes
|
|
477
|
+
* the catalog and resurrect the pre-change listing.
|
|
478
|
+
*/
|
|
479
|
+
private enqueueCatalogMutation(
|
|
480
|
+
id: string,
|
|
481
|
+
operation: () => Promise<void>,
|
|
482
|
+
): Promise<void> {
|
|
483
|
+
const previous = this.catalogMutations.get(id) ?? Promise.resolve();
|
|
484
|
+
const next = previous.catch(() => {}).then(operation);
|
|
485
|
+
this.catalogMutations.set(id, next);
|
|
486
|
+
void next
|
|
487
|
+
.finally(() => {
|
|
488
|
+
if (this.catalogMutations.get(id) === next) {
|
|
489
|
+
this.catalogMutations.delete(id);
|
|
490
|
+
}
|
|
491
|
+
})
|
|
492
|
+
.catch(() => {});
|
|
493
|
+
return next;
|
|
494
|
+
}
|
|
495
|
+
|
|
462
496
|
/** Force a live listTools refresh and replace both catalog cache layers. */
|
|
463
497
|
async refreshTools(
|
|
464
498
|
id: string,
|
|
@@ -468,11 +502,16 @@ export class Registry implements RegistryView {
|
|
|
468
502
|
): Promise<ToolDef[]> {
|
|
469
503
|
const connector = this.connectors.get(id);
|
|
470
504
|
if (!connector) throw new Error(`Unknown connector "${id}"`);
|
|
505
|
+
const generation = this.catalogGeneration(id);
|
|
471
506
|
const tools = connector.staticTools
|
|
472
507
|
? connector.staticTools
|
|
473
508
|
: await connector.listTools(
|
|
474
509
|
this.contextFor(id, baseUrl, requestScope, callOptions),
|
|
475
510
|
);
|
|
511
|
+
// The caller that began this refresh may still use its result, but a
|
|
512
|
+
// credential/OAuth change that landed while listTools was in flight means
|
|
513
|
+
// the listing must not enter either shared cache layer.
|
|
514
|
+
if (generation !== this.catalogGeneration(id)) return tools;
|
|
476
515
|
const now = Date.now();
|
|
477
516
|
const previous = this.cache.get(id);
|
|
478
517
|
const catalogChanged =
|
|
@@ -487,13 +526,16 @@ export class Registry implements RegistryView {
|
|
|
487
526
|
});
|
|
488
527
|
this.invalidated.delete(id);
|
|
489
528
|
if (shouldPersist) {
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
529
|
+
await this.enqueueCatalogMutation(id, async () => {
|
|
530
|
+
if (generation !== this.catalogGeneration(id)) return;
|
|
531
|
+
try {
|
|
532
|
+
await this.storeCatalog(id, tools);
|
|
533
|
+
} catch (err) {
|
|
534
|
+
this.opts.logger.warn(
|
|
535
|
+
`[connecta] connector "${id}" catalog persistence failed: ${msg(err)}`,
|
|
536
|
+
);
|
|
537
|
+
}
|
|
538
|
+
});
|
|
497
539
|
}
|
|
498
540
|
return tools;
|
|
499
541
|
}
|
|
@@ -510,11 +552,13 @@ export class Registry implements RegistryView {
|
|
|
510
552
|
if (connector.staticTools) return connector.staticTools;
|
|
511
553
|
|
|
512
554
|
const now = Date.now();
|
|
555
|
+
const requestGeneration = this.catalogGeneration(id);
|
|
513
556
|
const hit = this.cache.get(id);
|
|
514
557
|
if (hit && hit.exp > now) return hit.tools;
|
|
515
558
|
|
|
516
559
|
let stale = hit && hit.staleUntil > now ? hit.tools : undefined;
|
|
517
560
|
if (this.persistToolCatalog && !this.invalidated.has(id)) {
|
|
561
|
+
const generation = this.catalogGeneration(id);
|
|
518
562
|
let persisted: PersistedCatalog | null = null;
|
|
519
563
|
try {
|
|
520
564
|
persisted = this.validCatalog(
|
|
@@ -525,6 +569,10 @@ export class Registry implements RegistryView {
|
|
|
525
569
|
`[connecta] connector "${id}" catalog read failed: ${msg(err)}`,
|
|
526
570
|
);
|
|
527
571
|
}
|
|
572
|
+
if (generation !== this.catalogGeneration(id)) {
|
|
573
|
+
persisted = null;
|
|
574
|
+
stale = undefined;
|
|
575
|
+
}
|
|
528
576
|
if (persisted && persisted.staleUntil > now) {
|
|
529
577
|
this.cache.set(id, {
|
|
530
578
|
tools: persisted.tools,
|
|
@@ -539,7 +587,11 @@ export class Registry implements RegistryView {
|
|
|
539
587
|
try {
|
|
540
588
|
return await this.refreshTools(id, baseUrl, requestScope, callOptions);
|
|
541
589
|
} catch (err) {
|
|
542
|
-
if (
|
|
590
|
+
if (
|
|
591
|
+
stale &&
|
|
592
|
+
requestGeneration === this.catalogGeneration(id) &&
|
|
593
|
+
!this.invalidated.has(id)
|
|
594
|
+
) {
|
|
543
595
|
this.opts.logger.warn(
|
|
544
596
|
`[connecta] connector "${id}" catalog refresh failed; serving stale catalog: ${msg(err)}`,
|
|
545
597
|
);
|
|
@@ -675,29 +727,37 @@ export class Registry implements RegistryView {
|
|
|
675
727
|
|
|
676
728
|
/** Drop a connector's cached tool list (e.g. after auth completes). */
|
|
677
729
|
invalidate(id: string): void {
|
|
730
|
+
this.advanceCatalogGeneration(id);
|
|
678
731
|
this.cache.delete(id);
|
|
679
732
|
this.invalidated.add(id);
|
|
680
733
|
if (this.persistToolCatalog) {
|
|
681
|
-
void this.
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
)
|
|
734
|
+
void this.enqueueCatalogMutation(id, async () => {
|
|
735
|
+
try {
|
|
736
|
+
await this.opts.storage.delete(this.catalogKey(id));
|
|
737
|
+
} catch (err) {
|
|
738
|
+
this.opts.logger.warn(
|
|
739
|
+
`[connecta] connector "${id}" catalog invalidation failed: ${msg(err)}`,
|
|
740
|
+
);
|
|
741
|
+
}
|
|
685
742
|
});
|
|
686
743
|
}
|
|
687
744
|
}
|
|
688
745
|
|
|
689
746
|
/** Drop both in-memory and persisted tool catalogs. */
|
|
690
747
|
async invalidateStored(id: string): Promise<void> {
|
|
748
|
+
this.advanceCatalogGeneration(id);
|
|
691
749
|
this.cache.delete(id);
|
|
692
750
|
this.invalidated.add(id);
|
|
693
751
|
if (this.persistToolCatalog) {
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
752
|
+
await this.enqueueCatalogMutation(id, async () => {
|
|
753
|
+
try {
|
|
754
|
+
await this.opts.storage.delete(this.catalogKey(id));
|
|
755
|
+
} catch (err) {
|
|
756
|
+
this.opts.logger.warn(
|
|
757
|
+
`[connecta] connector "${id}" catalog invalidation failed: ${msg(err)}`,
|
|
758
|
+
);
|
|
759
|
+
}
|
|
760
|
+
});
|
|
701
761
|
}
|
|
702
762
|
}
|
|
703
763
|
}
|