@zackbart/connecta 0.7.4 → 0.7.6

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.
Files changed (41) hide show
  1. package/CHANGELOG.md +69 -0
  2. package/README.md +4 -2
  3. package/dist/execute.d.ts.map +1 -1
  4. package/dist/execute.js +11 -0
  5. package/dist/execute.js.map +1 -1
  6. package/dist/executor-admission.d.ts +18 -1
  7. package/dist/executor-admission.d.ts.map +1 -1
  8. package/dist/executor-admission.js +82 -3
  9. package/dist/executor-admission.js.map +1 -1
  10. package/dist/executors/quickjs-protocol.d.ts +1 -0
  11. package/dist/executors/quickjs-protocol.d.ts.map +1 -1
  12. package/dist/executors/quickjs-protocol.js +7 -4
  13. package/dist/executors/quickjs-protocol.js.map +1 -1
  14. package/dist/executors/quickjs-runtime.d.ts.map +1 -1
  15. package/dist/executors/quickjs-runtime.js +22 -9
  16. package/dist/executors/quickjs-runtime.js.map +1 -1
  17. package/dist/executors/quickjs.d.ts.map +1 -1
  18. package/dist/executors/quickjs.js +44 -10
  19. package/dist/executors/quickjs.js.map +1 -1
  20. package/dist/index.d.ts +30 -1
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js +38 -1
  23. package/dist/index.js.map +1 -1
  24. package/dist/server.d.ts +3 -0
  25. package/dist/server.d.ts.map +1 -1
  26. package/dist/server.js +175 -16
  27. package/dist/server.js.map +1 -1
  28. package/dist/types.d.ts +26 -0
  29. package/dist/types.d.ts.map +1 -1
  30. package/dist/version.d.ts +1 -1
  31. package/dist/version.js +1 -1
  32. package/package.json +2 -1
  33. package/src/execute.ts +11 -0
  34. package/src/executor-admission.ts +90 -3
  35. package/src/executors/quickjs-protocol.ts +7 -4
  36. package/src/executors/quickjs-runtime.ts +31 -9
  37. package/src/executors/quickjs.ts +61 -12
  38. package/src/index.ts +87 -1
  39. package/src/server.ts +217 -29
  40. package/src/types.ts +27 -0
  41. package/src/version.ts +1 -1
@@ -1,6 +1,8 @@
1
1
  import type {
2
2
  AdmittingExecutor,
3
+ AdmissionSnapshot,
3
4
  Executor,
5
+ ExecutorLease,
4
6
  } from "./types.js";
5
7
 
6
8
  export type ExecutorAdmissionErrorCode =
@@ -35,10 +37,13 @@ export class ExecutorAdmissionError extends Error {
35
37
  }
36
38
 
37
39
  export interface AdmissionLease {
40
+ /** Time spent waiting behind active work. Zero for immediate admission. */
41
+ readonly waitMs: number;
38
42
  release(): void;
39
43
  }
40
44
 
41
45
  interface Waiter {
46
+ queuedAt: number;
42
47
  resolve: (lease: AdmissionLease) => void;
43
48
  reject: (error: ExecutorAdmissionError) => void;
44
49
  signal?: AbortSignal;
@@ -82,6 +87,14 @@ export class AdmissionController {
82
87
  private active = 0;
83
88
  private closed = false;
84
89
  private readonly waiters: Waiter[] = [];
90
+ private admittedTotal = 0;
91
+ private queuedTotal = 0;
92
+ private rejectedTotal = 0;
93
+ private cancelledTotal = 0;
94
+ private closedTotal = 0;
95
+ private queueWaitCount = 0;
96
+ private queueWaitTotalMs = 0;
97
+ private queueWaitMaxMs = 0;
85
98
 
86
99
  constructor(options: AdmissionControllerOptions) {
87
100
  this.concurrency = positiveWhole(options.concurrency, "concurrency");
@@ -107,8 +120,33 @@ export class AdmissionController {
107
120
  return this.waiters.length;
108
121
  }
109
122
 
123
+ snapshot(): AdmissionSnapshot {
124
+ return {
125
+ concurrency: this.concurrency,
126
+ maxQueueSize: this.maxQueueSize,
127
+ queueTimeoutMs: this.queueTimeoutMs,
128
+ retryAfterMs: this.retryAfterMs,
129
+ active: this.active,
130
+ queued: this.waiters.length,
131
+ closed: this.closed,
132
+ totals: {
133
+ admitted: this.admittedTotal,
134
+ queued: this.queuedTotal,
135
+ rejected: this.rejectedTotal,
136
+ cancelled: this.cancelledTotal,
137
+ closed: this.closedTotal,
138
+ },
139
+ queueWaitMs: {
140
+ count: this.queueWaitCount,
141
+ total: this.queueWaitTotalMs,
142
+ max: this.queueWaitMaxMs,
143
+ },
144
+ };
145
+ }
146
+
110
147
  acquire(options: { signal?: AbortSignal } = {}): Promise<AdmissionLease> {
111
148
  if (this.closed) {
149
+ this.closedTotal++;
112
150
  return Promise.reject(
113
151
  new ExecutorAdmissionError(
114
152
  "executor_closed",
@@ -117,6 +155,7 @@ export class AdmissionController {
117
155
  );
118
156
  }
119
157
  if (options.signal?.aborted) {
158
+ this.cancelledTotal++;
120
159
  return Promise.reject(
121
160
  new ExecutorAdmissionError(
122
161
  "executor_cancelled",
@@ -126,14 +165,17 @@ export class AdmissionController {
126
165
  }
127
166
  if (this.active < this.concurrency) {
128
167
  this.active++;
129
- return Promise.resolve(this.makeLease());
168
+ this.admittedTotal++;
169
+ return Promise.resolve(this.makeLease(0));
130
170
  }
131
171
  if (this.waiters.length >= this.maxQueueSize) {
172
+ this.rejectedTotal++;
132
173
  return Promise.reject(this.overloaded("Executor queue is full."));
133
174
  }
134
175
 
135
176
  return new Promise<AdmissionLease>((resolve, reject) => {
136
177
  const waiter: Waiter = {
178
+ queuedAt: Date.now(),
137
179
  resolve,
138
180
  reject,
139
181
  ...(options.signal ? { signal: options.signal } : {}),
@@ -141,6 +183,7 @@ export class AdmissionController {
141
183
  waiter.onAbort = () => {
142
184
  if (!this.remove(waiter)) return;
143
185
  this.cleanup(waiter);
186
+ this.cancelledTotal++;
144
187
  reject(
145
188
  new ExecutorAdmissionError(
146
189
  "executor_cancelled",
@@ -152,6 +195,7 @@ export class AdmissionController {
152
195
  waiter.timer = setTimeout(() => {
153
196
  if (!this.remove(waiter)) return;
154
197
  this.cleanup(waiter);
198
+ this.rejectedTotal++;
155
199
  reject(
156
200
  this.overloaded(
157
201
  `Executor admission timed out after ${this.queueTimeoutMs}ms.`,
@@ -159,6 +203,7 @@ export class AdmissionController {
159
203
  );
160
204
  }, this.queueTimeoutMs);
161
205
  this.waiters.push(waiter);
206
+ this.queuedTotal++;
162
207
  });
163
208
  }
164
209
 
@@ -167,6 +212,7 @@ export class AdmissionController {
167
212
  this.closed = true;
168
213
  for (const waiter of this.waiters.splice(0)) {
169
214
  this.cleanup(waiter);
215
+ this.closedTotal++;
170
216
  waiter.reject(
171
217
  new ExecutorAdmissionError(
172
218
  "executor_closed",
@@ -182,9 +228,10 @@ export class AdmissionController {
182
228
  });
183
229
  }
184
230
 
185
- private makeLease(): AdmissionLease {
231
+ private makeLease(waitMs: number): AdmissionLease {
186
232
  let released = false;
187
233
  return {
234
+ waitMs,
188
235
  release: () => {
189
236
  if (released) return;
190
237
  released = true;
@@ -199,8 +246,13 @@ export class AdmissionController {
199
246
  const waiter = this.waiters.shift();
200
247
  if (!waiter) return;
201
248
  this.cleanup(waiter);
249
+ const waitMs = Math.max(0, Date.now() - waiter.queuedAt);
250
+ this.admittedTotal++;
251
+ this.queueWaitCount++;
252
+ this.queueWaitTotalMs += waitMs;
253
+ this.queueWaitMaxMs = Math.max(this.queueWaitMaxMs, waitMs);
202
254
  this.active++;
203
- waiter.resolve(this.makeLease());
255
+ waiter.resolve(this.makeLease(waitMs));
204
256
  }
205
257
 
206
258
  private remove(waiter: Waiter): boolean {
@@ -227,3 +279,38 @@ export function isAdmittingExecutor(
227
279
  typeof (executor as { acquire?: unknown }).acquire === "function"
228
280
  );
229
281
  }
282
+
283
+ /**
284
+ * Give a structurally-compatible but otherwise unbounded executor the same
285
+ * admission contract as the built-in Node executor. The wrapper owns only the
286
+ * queue; closing the underlying runtime remains the Connecta lifecycle's job.
287
+ */
288
+ export function withExecutorAdmission(
289
+ executor: Executor,
290
+ admission: AdmissionController,
291
+ ): AdmittingExecutor {
292
+ return {
293
+ async acquire(options = {}): Promise<ExecutorLease> {
294
+ const token = await admission.acquire(options);
295
+ let released = false;
296
+ return {
297
+ waitMs: token.waitMs,
298
+ execute: (code, providers) => executor.execute(code, providers),
299
+ release: () => {
300
+ if (released) return;
301
+ released = true;
302
+ token.release();
303
+ },
304
+ };
305
+ },
306
+ async execute(code, providers) {
307
+ const lease = await this.acquire();
308
+ try {
309
+ return await lease.execute(code, providers);
310
+ } finally {
311
+ lease.release();
312
+ }
313
+ },
314
+ admissionSnapshot: () => admission.snapshot(),
315
+ };
316
+ }
@@ -1,11 +1,14 @@
1
1
  import type { ExecuteResult } from "../types.js";
2
2
  import type { QuickJsRuntimeOptions } from "./quickjs-runtime.js";
3
3
 
4
- // The guest result is shaped to 24k characters before transport and QuickJS
5
- // capture already bounds logs to 256k characters. Every complete message is
6
- // measured against this ceiling before process.send; one MiB leaves room for
7
- // multibyte log text and envelope escaping while remaining a hard IPC bound.
4
+ // An execution result is serialized once into payloadJson and again as that
5
+ // string is embedded in ChildToParentMessage. Captured logs therefore get a
6
+ // separate 512 KiB budget measured after both JSON encodings. The final result
7
+ // is shaped to 24k characters; even if every character takes its worst-case
8
+ // seven transport bytes, that leaves ample structural overhead below this hard
9
+ // process.send ceiling.
8
10
  export const MAX_QUICKJS_IPC_BYTES = 1024 * 1024;
11
+ export const MAX_QUICKJS_LOG_TRANSPORT_BYTES = 512 * 1024;
9
12
  /** Preserve #84's stopgap before a host value enters the child/WASM process. */
10
13
  export const MAX_QUICKJS_HOST_RPC_BYTES = 256 * 1024;
11
14
 
@@ -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. 8k chars/entry is generous for glue-code logging (the
35
- // join in execute.ts trims the assembled log to 4k anyway), and 256k total
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
- // Running total of chars actually retained in `logs`; once the cumulative
179
- // budget is spent we push one marker and drop the rest, so a flood of large
180
- // entries can't grow the host array without bound.
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(`[log truncated after ${MAX_LOG_ENTRIES} entries]`);
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
- if (logTotalChars + entry.length > MAX_LOG_TOTAL_CHARS) {
198
- logs.push("[log truncated: size budget exceeded]");
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();
@@ -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 child = fork(fileURLToPath(childUrl), [], {
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", "ignore", "ipc"],
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
- child.on("exit", (code, exitSignal) => {
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
- new Error(
463
- `QuickJS child exited before becoming ready` +
464
- ` (${exitSignal ? `signal ${exitSignal}` : `code ${String(code)}`}).`,
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
- this.resolveActive(slot, {
471
- result: undefined,
472
- error:
473
- `QuickJS child exited unexpectedly` +
474
- ` (${exitSignal ? `signal ${exitSignal}` : `code ${String(code)}`}).`,
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: config.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,