runline 0.16.2 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5,5 +5,11 @@ export interface RunlineConfig {
5
5
  timeoutMs: number;
6
6
  /** Memory limit for QuickJS in bytes. Default 64MB. */
7
7
  memoryLimitBytes: number;
8
+ /**
9
+ * Bodies one pooled worker may run before being retired. Bounds cross-run
10
+ * global contamination; memory is a per-thread cost so this does not
11
+ * meaningfully affect it. Default 50.
12
+ */
13
+ maxRunsPerWorker?: number;
8
14
  }
9
15
  export declare const DEFAULT_CONFIG: RunlineConfig;
@@ -28,13 +28,47 @@ export declare function shouldArmRssWatchdog(versions?: Partial<Record<string, s
28
28
  * - timeout: worker.terminate() — interrupts even `while(true){}`
29
29
  * - memory: resourceLimits.maxOldGenerationSizeMb (node) + an RSS-delta
30
30
  * watchdog fallback; both surface as a clean "Memory limit exceeded" error
31
- * - crash containment: a dead worker never takes the host process down, and
32
- * each execute() gets a fresh worker, so the engine stays usable
31
+ * - crash containment: a dead worker never takes the host process down
32
+ *
33
+ * ## Why the worker is pooled
34
+ *
35
+ * Spawning a worker per execution leaks ~175KB of RSS per call under bun
36
+ * (~55KB under node): the runtime does not return a dead thread's arena to
37
+ * the OS, and there is no handle to free from JS — awaiting `terminate()`,
38
+ * dropping listeners, and letting the worker exit naturally all still leak.
39
+ * The only lever is to stop creating threads, so one worker serves many runs
40
+ * (measured 187KB/run -> 4KB/run).
41
+ *
42
+ * Reuse is given up the moment it could be unsafe. A worker is retired on
43
+ * timeout, crash, memory kill, plugin-surface change, leftover timers, or
44
+ * after `maxRunsPerWorker` bodies. A run that arrives while the pooled worker
45
+ * is busy gets its own throwaway worker, so concurrency semantics are
46
+ * unchanged.
33
47
  */
34
48
  export declare class ExecutionEngine {
35
49
  private registry;
36
50
  private config;
51
+ private pooled;
52
+ private readonly maxRunsPerWorker;
37
53
  constructor(registry: PluginRegistry, config: RunlineConfig);
54
+ /** Retire the pooled worker, if any. Safe to call repeatedly. */
55
+ dispose(): void;
56
+ /**
57
+ * Hand back a worker to run one body in.
58
+ *
59
+ * Prefers the pooled worker when it is idle and shaped correctly. Anything
60
+ * else — wrong shape, quota spent, or already busy — gets a fresh worker.
61
+ * A worker created because the pool was occupied stays private to that run
62
+ * (`pooled` is false) and is destroyed when the run ends, so overlapping
63
+ * callers keep the parallelism they had before pooling existed.
64
+ */
65
+ private acquire;
66
+ /**
67
+ * Finish with a worker. `retire` means the worker is no longer trustworthy
68
+ * — anything other than a clean completion. Private (non-pooled) workers
69
+ * are always destroyed.
70
+ */
71
+ private release;
38
72
  execute(code: string, options?: EngineOptions): Promise<ExecuteResult>;
39
73
  private invokeAction;
40
74
  /**
@@ -47,3 +81,11 @@ export declare class ExecutionEngine {
47
81
  private selfPrefixHint;
48
82
  private resolveConnection;
49
83
  }
84
+ /**
85
+ * Normalize a caller's code into a function body. Kept host-side so the
86
+ * worker source carries no user code and can therefore be reused.
87
+ *
88
+ * Exported for tests: this is the one place the "bare expression vs arrow
89
+ * function" calling convention is decided.
90
+ */
91
+ export declare function buildRunBody(code: string): string;
@@ -1,7 +1,42 @@
1
+ import { randomUUID } from "node:crypto";
1
2
  import { readFileSync } from "node:fs";
2
3
  import { Worker } from "node:worker_threads";
3
4
  import { applyEnvOverrides, updateConnectionConfig } from "../config/loader.js";
4
5
  import { formatValidationError, helpInputs, isTypedInputSchema, validateTypedInput, } from "../plugin/schema.js";
6
+ /**
7
+ * How many bodies one worker may run before it is retired.
8
+ *
9
+ * This is a memory dial, not a safety dial. Every recycle spawns a thread
10
+ * whose arena the runtime never returns (~190KB), so the cap sets a floor on
11
+ * the residual leak: measured over 100k executions, 50 costs 3.85 KB/run and
12
+ * never plateaus, while an unbounded worker settles at 0.06 KB/run.
13
+ *
14
+ * It is not what keeps runs isolated. A body that leaves a timer, an
15
+ * undeletable global, or a touched intrinsic retires its worker immediately
16
+ * (see `dirty` in the worker source), so contamination never survives the run
17
+ * that caused it. The cap only bounds hazards the detector cannot see, which
18
+ * after descriptor-level intrinsic checking is a narrow set: prototype-chain
19
+ * surgery and mutation of objects reachable but not enumerated.
20
+ *
21
+ * 500 keeps that backstop while cutting the residual leak ~10x.
22
+ */
23
+ const DEFAULT_MAX_RUNS_PER_WORKER = 500;
24
+ /**
25
+ * Identity of a worker's immutable configuration.
26
+ *
27
+ * Must be a real digest, not a length or a count: a collision hands a run a
28
+ * worker whose action surface or memory ceiling is not the one it asked for.
29
+ */
30
+ function workerShape(pluginNames, helpData, memoryLimitMb) {
31
+ const surface = JSON.stringify([pluginNames, helpData]);
32
+ // FNV-1a: no dependency, stable across processes, ample for a cache key.
33
+ let hash = 0x811c9dc5;
34
+ for (let i = 0; i < surface.length; i++) {
35
+ hash ^= surface.charCodeAt(i);
36
+ hash = Math.imul(hash, 0x01000193) >>> 0;
37
+ }
38
+ return `${hash.toString(36)}:${surface.length}:${memoryLimitMb}`;
39
+ }
5
40
  /**
6
41
  * Whether to arm the host-side RSS watchdog for a worker run.
7
42
  *
@@ -26,51 +61,137 @@ const RSS_WATCHDOG_SLACK_BYTES = 128 * 1024 * 1024;
26
61
  * - timeout: worker.terminate() — interrupts even `while(true){}`
27
62
  * - memory: resourceLimits.maxOldGenerationSizeMb (node) + an RSS-delta
28
63
  * watchdog fallback; both surface as a clean "Memory limit exceeded" error
29
- * - crash containment: a dead worker never takes the host process down, and
30
- * each execute() gets a fresh worker, so the engine stays usable
64
+ * - crash containment: a dead worker never takes the host process down
65
+ *
66
+ * ## Why the worker is pooled
67
+ *
68
+ * Spawning a worker per execution leaks ~175KB of RSS per call under bun
69
+ * (~55KB under node): the runtime does not return a dead thread's arena to
70
+ * the OS, and there is no handle to free from JS — awaiting `terminate()`,
71
+ * dropping listeners, and letting the worker exit naturally all still leak.
72
+ * The only lever is to stop creating threads, so one worker serves many runs
73
+ * (measured 187KB/run -> 4KB/run).
74
+ *
75
+ * Reuse is given up the moment it could be unsafe. A worker is retired on
76
+ * timeout, crash, memory kill, plugin-surface change, leftover timers, or
77
+ * after `maxRunsPerWorker` bodies. A run that arrives while the pooled worker
78
+ * is busy gets its own throwaway worker, so concurrency semantics are
79
+ * unchanged.
31
80
  */
32
81
  export class ExecutionEngine {
33
82
  registry;
34
83
  config;
84
+ pooled = null;
85
+ maxRunsPerWorker;
35
86
  constructor(registry, config) {
36
87
  this.registry = registry;
37
88
  this.config = config;
89
+ this.maxRunsPerWorker =
90
+ config.maxRunsPerWorker ?? DEFAULT_MAX_RUNS_PER_WORKER;
91
+ }
92
+ /** Retire the pooled worker, if any. Safe to call repeatedly. */
93
+ dispose() {
94
+ const entry = this.pooled;
95
+ if (!entry)
96
+ return;
97
+ this.pooled = null;
98
+ destroyWorker(entry.worker);
99
+ }
100
+ /**
101
+ * Hand back a worker to run one body in.
102
+ *
103
+ * Prefers the pooled worker when it is idle and shaped correctly. Anything
104
+ * else — wrong shape, quota spent, or already busy — gets a fresh worker.
105
+ * A worker created because the pool was occupied stays private to that run
106
+ * (`pooled` is false) and is destroyed when the run ends, so overlapping
107
+ * callers keep the parallelism they had before pooling existed.
108
+ */
109
+ acquire(shape, build) {
110
+ const idle = this.pooled && !this.pooled.busy ? this.pooled : null;
111
+ if (idle && (idle.shape !== shape || idle.runs >= this.maxRunsPerWorker)) {
112
+ this.dispose();
113
+ }
114
+ else if (idle) {
115
+ return { entry: idle, pooled: true };
116
+ }
117
+ const entry = {
118
+ worker: build(),
119
+ shape,
120
+ runs: 0,
121
+ busy: false,
122
+ };
123
+ // An idle pooled worker must not hold the host's event loop open, or a
124
+ // one-shot CLI would never exit. The per-run timeout timer keeps the loop
125
+ // alive for the duration of an actual run.
126
+ entry.worker.unref?.();
127
+ if (!this.pooled) {
128
+ this.pooled = entry;
129
+ return { entry, pooled: true };
130
+ }
131
+ return { entry, pooled: false };
132
+ }
133
+ /**
134
+ * Finish with a worker. `retire` means the worker is no longer trustworthy
135
+ * — anything other than a clean completion. Private (non-pooled) workers
136
+ * are always destroyed.
137
+ */
138
+ release(entry, pooled, retire) {
139
+ entry.busy = false;
140
+ if (!pooled || retire) {
141
+ if (this.pooled === entry)
142
+ this.pooled = null;
143
+ destroyWorker(entry.worker);
144
+ }
38
145
  }
39
146
  async execute(code, options) {
40
147
  const timeoutMs = options?.timeoutMs ?? this.config.timeoutMs;
41
148
  const memoryLimitBytes = options?.memoryLimitBytes ?? this.config.memoryLimitBytes;
42
149
  const logs = [];
43
150
  const plugins = this.registry.listPlugins();
44
- const source = buildWorkerSource(code, plugins.map((p) => p.name), buildHelpData(plugins));
151
+ const pluginNames = plugins.map((p) => p.name);
152
+ const helpData = buildHelpData(plugins);
153
+ const memoryLimitMb = Math.max(8, Math.floor(memoryLimitBytes / (1024 * 1024)));
154
+ // resourceLimits are fixed at construction, so a run asking for a
155
+ // different ceiling must not inherit an existing worker's.
156
+ const shape = workerShape(pluginNames, helpData, memoryLimitMb);
157
+ const body = buildRunBody(code);
158
+ let acquired;
159
+ try {
160
+ acquired = this.acquire(shape, () => new Worker(buildWorkerSource(pluginNames, helpData), {
161
+ eval: true,
162
+ resourceLimits: { maxOldGenerationSizeMb: memoryLimitMb },
163
+ }));
164
+ }
165
+ catch (err) {
166
+ return { result: null, error: formatError(err), logs };
167
+ }
168
+ const { entry, pooled } = acquired;
45
169
  return new Promise((resolve) => {
46
- const memoryLimitMb = Math.max(8, Math.floor(memoryLimitBytes / (1024 * 1024)));
47
- let worker;
48
- try {
49
- worker = new Worker(source, {
50
- eval: true,
51
- resourceLimits: { maxOldGenerationSizeMb: memoryLimitMb },
52
- });
53
- }
54
- catch (err) {
55
- resolve({ result: null, error: formatError(err), logs });
56
- return;
57
- }
170
+ const worker = entry.worker;
171
+ const runId = newRunId();
172
+ entry.busy = true;
173
+ entry.runs++;
58
174
  let settled = false;
59
- const finish = (r) => {
175
+ const finish = (r, retire) => {
60
176
  if (settled)
61
177
  return;
62
178
  settled = true;
63
179
  clearTimeout(timeoutTimer);
64
180
  clearInterval(rssTimer);
65
- void worker.terminate();
181
+ worker.off("message", onMessage);
182
+ worker.off("error", onError);
183
+ worker.off("exit", onExit);
184
+ this.release(entry, pooled, retire);
66
185
  resolve(r);
67
186
  };
187
+ // A spinning body blocks the worker's event loop, so it can never be
188
+ // asked to stop — terminate is the only remedy and the worker is gone.
68
189
  const timeoutTimer = setTimeout(() => {
69
190
  finish({
70
191
  result: null,
71
192
  error: `Execution timed out after ${timeoutMs}ms`,
72
193
  logs,
73
- });
194
+ }, true);
74
195
  }, timeoutMs);
75
196
  let rssTimer;
76
197
  if (shouldArmRssWatchdog()) {
@@ -82,7 +203,7 @@ export class ExecutionEngine {
82
203
  result: null,
83
204
  error: `Memory limit exceeded (${memoryLimitMb}MB)`,
84
205
  logs,
85
- });
206
+ }, true);
86
207
  }
87
208
  }, 100);
88
209
  rssTimer.unref?.();
@@ -100,9 +221,12 @@ export class ExecutionEngine {
100
221
  // worker already gone
101
222
  }
102
223
  };
103
- worker.on("message", (msg) => {
224
+ const onMessage = (msg) => {
104
225
  if (settled)
105
226
  return;
227
+ // Straggler from an earlier body on this same worker.
228
+ if (msg.runId !== runId)
229
+ return;
106
230
  if (msg.t === "log") {
107
231
  logs.push(`[${msg.level}] ${msg.line}`);
108
232
  }
@@ -115,16 +239,24 @@ export class ExecutionEngine {
115
239
  catch (err) {
116
240
  reply({
117
241
  t: "result",
242
+ runId,
118
243
  id: msg.id,
119
244
  ok: false,
120
245
  error: `Action result not JSON-serializable: ${err instanceof Error ? err.message : String(err)}`,
121
246
  });
122
247
  return;
123
248
  }
124
- reply({ t: "result", id: msg.id, ok: true, value: serialized });
249
+ reply({
250
+ t: "result",
251
+ runId,
252
+ id: msg.id,
253
+ ok: true,
254
+ value: serialized,
255
+ });
125
256
  }, (err) => {
126
257
  reply({
127
258
  t: "result",
259
+ runId,
128
260
  id: msg.id,
129
261
  ok: false,
130
262
  error: err instanceof Error ? err.message : String(err),
@@ -132,27 +264,33 @@ export class ExecutionEngine {
132
264
  });
133
265
  }
134
266
  else if (msg.t === "done") {
267
+ // A body that left timers running keeps consuming the worker's
268
+ // event loop and can post into later runs: never reuse it.
135
269
  finish(msg.ok
136
270
  ? { result: msg.result, logs }
137
- : { result: null, error: msg.error ?? "Unknown error", logs });
271
+ : { result: null, error: msg.error ?? "Unknown error", logs }, msg.dirty === true);
138
272
  }
139
- });
140
- worker.on("error", (err) => {
273
+ };
274
+ const onError = (err) => {
141
275
  finish({
142
276
  result: null,
143
277
  error: err.code === "ERR_WORKER_OUT_OF_MEMORY"
144
278
  ? `Memory limit exceeded (${memoryLimitMb}MB)`
145
279
  : formatError(err),
146
280
  logs,
147
- });
148
- });
149
- worker.on("exit", (exitCode) => {
281
+ }, true);
282
+ };
283
+ const onExit = (exitCode) => {
150
284
  finish({
151
285
  result: null,
152
286
  error: `Worker exited unexpectedly (code ${exitCode})`,
153
287
  logs,
154
- });
155
- });
288
+ }, true);
289
+ };
290
+ worker.on("message", onMessage);
291
+ worker.on("error", onError);
292
+ worker.on("exit", onExit);
293
+ reply({ t: "run", runId, body });
156
294
  });
157
295
  }
158
296
  async invokeAction(path, args) {
@@ -213,7 +351,19 @@ export class ExecutionEngine {
213
351
  return applyEnvOverrides(base, plugin.connectionConfigSchema);
214
352
  }
215
353
  }
216
- // ── Helpers ──────────────────────────────────────────────
354
+ // ── Helpers ──────────────────────────────
355
+ /**
356
+ * Drop every listener before terminating: a worker being torn down can still
357
+ * emit `exit`, and a stale handler would resolve a run that already settled.
358
+ */
359
+ function destroyWorker(worker) {
360
+ worker.removeAllListeners();
361
+ void worker.terminate();
362
+ }
363
+ /** Unguessable per-run token. See the WorkerMessage comment for why. */
364
+ function newRunId() {
365
+ return randomUUID();
366
+ }
217
367
  /**
218
368
  * JSON round-trip to (a) guarantee structured-clone compatibility and
219
369
  * (b) preserve the previous engine's value semantics, where every action
@@ -255,13 +405,35 @@ function buildHelpData(plugins) {
255
405
  }
256
406
  return data;
257
407
  }
258
- function buildWorkerSource(code, pluginNames = [], helpData = {}) {
408
+ /**
409
+ * Normalize a caller's code into a function body. Kept host-side so the
410
+ * worker source carries no user code and can therefore be reused.
411
+ *
412
+ * Exported for tests: this is the one place the "bare expression vs arrow
413
+ * function" calling convention is decided.
414
+ */
415
+ export function buildRunBody(code) {
259
416
  const trimmed = code.trim();
260
417
  const looksLikeArrow = (trimmed.startsWith("async") || trimmed.startsWith("(")) &&
261
418
  trimmed.includes("=>");
262
- const body = looksLikeArrow
419
+ return looksLikeArrow
263
420
  ? `const __fn = (${trimmed});\nif (typeof __fn !== 'function') throw new Error('Code must evaluate to a function');\nreturn await __fn();`
264
421
  : code;
422
+ }
423
+ function buildWorkerSource(pluginNames = [], helpData = {}) {
424
+ // Injected into every body's scope. `require` is here because bodies are
425
+ // compiled with `new Function`, whose scope is the worker's *global* object
426
+ // — not the module scope that used to hold the inlined body. Without it,
427
+ // pooling would silently revoke a documented capability ("agent code gets
428
+ // the full host JS runtime") as a side effect of a memory fix.
429
+ const injectNames = [
430
+ ...pluginNames,
431
+ "actions",
432
+ "console",
433
+ "fetch",
434
+ "require",
435
+ "MiniSearch",
436
+ ];
265
437
  const wrapped = `"use strict";
266
438
  const { parentPort: __port } = require("node:worker_threads");
267
439
 
@@ -272,29 +444,62 @@ process.exit = (code) => {
272
444
  throw new Error('process.exit(' + (code ?? 0) + ') is not available in the runline sandbox; return a value instead');
273
445
  };
274
446
 
447
+ // ── run state ──
448
+ // This worker outlives any single body, so everything the host correlates
449
+ // on is scoped to __runId, an unguessable token supplied by the host.
450
+ let __runId = null;
451
+
452
+ // ── timer tracking ──
453
+ // A body that leaves a timer behind keeps consuming this worker's event
454
+ // loop and can fire into a later run. Count outstanding handles so the host
455
+ // can retire the worker instead of reusing it.
456
+ let __liveTimers = 0;
457
+ const __realSetTimeout = setTimeout;
458
+ const __realSetInterval = setInterval;
459
+ const __realClearTimeout = clearTimeout;
460
+ const __realClearInterval = clearInterval;
461
+ const __timerHandles = new Set();
462
+ globalThis.setTimeout = function (fn, ms, ...rest) {
463
+ let h;
464
+ const wrapped = (...a) => {
465
+ if (__timerHandles.delete(h)) __liveTimers--;
466
+ return fn(...a);
467
+ };
468
+ h = __realSetTimeout(wrapped, ms, ...rest);
469
+ __timerHandles.add(h);
470
+ __liveTimers++;
471
+ return h;
472
+ };
473
+ globalThis.setInterval = function (fn, ms, ...rest) {
474
+ const h = __realSetInterval(fn, ms, ...rest);
475
+ __timerHandles.add(h);
476
+ __liveTimers++;
477
+ return h;
478
+ };
479
+ globalThis.clearTimeout = function (h) {
480
+ if (__timerHandles.delete(h)) __liveTimers--;
481
+ return __realClearTimeout(h);
482
+ };
483
+ globalThis.clearInterval = function (h) {
484
+ if (__timerHandles.delete(h)) __liveTimers--;
485
+ return __realClearInterval(h);
486
+ };
487
+
275
488
  // ── host bridge ──
276
489
  let __seq = 0;
277
490
  const __pending = new Map();
278
- __port.on("message", (m) => {
279
- if (!m || m.t !== "result") return;
280
- const p = __pending.get(m.id);
281
- if (!p) return;
282
- __pending.delete(m.id);
283
- if (m.ok) p.resolve(m.value);
284
- else p.reject(new Error(m.error));
285
- });
286
491
  const __invoke = (path, args) => new Promise((resolve, reject) => {
287
492
  const id = ++__seq;
288
493
  __pending.set(id, { resolve, reject });
289
494
  try {
290
- __port.postMessage({ t: "invoke", id, path, args });
495
+ __port.postMessage({ t: "invoke", runId: __runId, id, path, args });
291
496
  } catch (e) {
292
497
  __pending.delete(id);
293
498
  reject(e);
294
499
  }
295
500
  });
296
501
  const __log = (level, line) => {
297
- try { __port.postMessage({ t: "log", level, line }); } catch {}
502
+ try { __port.postMessage({ t: "log", runId: __runId, level, line }); } catch {}
298
503
  };
299
504
 
300
505
  const __fmt = (v) => {
@@ -478,20 +683,192 @@ const console = {
478
683
 
479
684
  const fetch = () => { throw new Error('fetch is disabled in runline sandbox'); };
480
685
 
481
- (async () => {
482
- ${body}
483
- })().then(
484
- (v) => {
686
+ // Stable identity for this worker instance. Set before the baseline snapshot
687
+ // so the scrub never removes it. Thread ids are recycled by the runtime and
688
+ // cannot be used to tell one worker from the next; this can.
689
+ globalThis.__runlineWorkerId =
690
+ Date.now().toString(36) + "-" + Math.random().toString(36).slice(2, 10);
691
+
692
+ // ── cross-run contamination ──
693
+ //
694
+ // Two shapes, one policy: anything a body leaves behind that the next body
695
+ // could observe makes this worker untrustworthy. Removable state is removed;
696
+ // unremovable state retires the worker. Nothing is left for the next run to
697
+ // trip over.
698
+
699
+ // 1. Own properties added to globalThis. Removable — delete them.
700
+ const __baselineGlobals = new Set(Object.getOwnPropertyNames(globalThis));
701
+
702
+ // 2. Mutations to shared intrinsics (Array.prototype.push = ..., a new own
703
+ // property on Object, etc). NOT removable: restoring an arbitrary mutation
704
+ // would mean snapshotting every descriptor of every intrinsic. Instead
705
+ // fingerprint their own-property names and compare — a change means the
706
+ // body reshaped something shared and the worker must not be reused.
707
+ const __intrinsics = [
708
+ Object, Object.prototype, Array, Array.prototype, Function.prototype,
709
+ String.prototype, Number.prototype, Boolean.prototype, Promise,
710
+ Promise.prototype, Map.prototype, Set.prototype, Date.prototype,
711
+ RegExp.prototype, Error.prototype, JSON, Math, Reflect,
712
+ ];
713
+ // Snapshot of every intrinsic's own data properties, by reference. Taken
714
+ // once; compared after each run. Catches BOTH shapes of tampering:
715
+ // - additions/removals (the own-name set changes)
716
+ // - replacements (Array.prototype.push = fn — same name, new value)
717
+ // A name-only fingerprint misses the second, which is the more likely
718
+ // monkey-patch. Descriptors are read rather than the properties themselves so
719
+ // accessor getters are never invoked here.
720
+ const __snapshot = [];
721
+ for (const target of __intrinsics) {
722
+ let names;
723
+ try { names = Object.getOwnPropertyNames(target); } catch { continue; }
724
+ for (const n of names) {
725
+ let d;
726
+ try { d = Object.getOwnPropertyDescriptor(target, n); } catch { continue; }
727
+ if (!d) continue;
728
+ // Accessors are recorded by their get/set identity, data props by value.
729
+ __snapshot.push([target, n, "value" in d ? d.value : d.get, names.length]);
730
+ }
731
+ }
732
+
733
+ const __intrinsicsIntact = () => {
734
+ for (let i = 0; i < __snapshot.length; i++) {
735
+ const [target, name, expected] = __snapshot[i];
736
+ let d;
737
+ try { d = Object.getOwnPropertyDescriptor(target, name); } catch { return false; }
738
+ if (!d) return false;
739
+ if (("value" in d ? d.value : d.get) !== expected) return false;
740
+ }
741
+ // Additions are caught by the own-name count, which is recorded per target
742
+ // alongside each of its entries.
743
+ for (let i = 0; i < __snapshot.length; i++) {
744
+ const [target, , , count] = __snapshot[i];
745
+ let names;
746
+ try { names = Object.getOwnPropertyNames(target); } catch { return false; }
747
+ if (names.length !== count) return false;
748
+ }
749
+ return true;
750
+ };
751
+
752
+ /**
753
+ * Returns true when this worker is still safe to reuse. Removes what it can;
754
+ * reports what it cannot.
755
+ */
756
+ const __scrubGlobals = () => {
757
+ let clean = true;
758
+
759
+ // Assigning parentPort.onmessage is a setter-based route to the host
760
+ // channel; restore it and distrust the worker. See __bodyRequire.
761
+ if (__port.onmessage !== __baselineOnMessage) {
762
+ __port.onmessage = __baselineOnMessage;
763
+ clean = false;
764
+ }
765
+ for (const k of Object.getOwnPropertyNames(globalThis)) {
766
+ if (__baselineGlobals.has(k)) continue;
485
767
  try {
486
- __port.postMessage({ t: "done", ok: true, result: __toJson(v) });
487
- } catch (e) {
488
- __port.postMessage({ t: "done", ok: false, error: __fmtErr(e) });
768
+ delete globalThis[k];
769
+ } catch {
770
+ clean = false;
489
771
  }
490
- },
491
- (e) => {
492
- __port.postMessage({ t: "done", ok: false, error: __fmtErr(e) });
493
- },
494
- );
772
+ // A non-configurable property survives a silent delete: verify, don't
773
+ // assume the absence of a throw means success.
774
+ if (Object.prototype.hasOwnProperty.call(globalThis, k)) clean = false;
775
+ }
776
+ if (!__intrinsicsIntact()) clean = false;
777
+ return clean;
778
+ };
779
+
780
+ // The host channel is the one capability a body must not hold.
781
+ //
782
+ // A body can require("node:worker_threads"). Before pooling that was
783
+ // harmless: anything it attached died with the worker after a single run.
784
+ // Under reuse a surviving listener sees the {t:"run", runId} of EVERY later
785
+ // body and can post a forged "done" for it, handing that run attacker-chosen
786
+ // output. Randomising the run id does not help — the listener is simply told
787
+ // what it is.
788
+ //
789
+ // Bun's MessagePort exposes no listenerCount/removeAllListeners, so listeners
790
+ // cannot be enumerated and stripped after the fact. Instead the route in is
791
+ // closed: the require handed to bodies returns a worker_threads module with
792
+ // the port fields blanked. Every other module, and everything else about this
793
+ // one, is untouched.
794
+ const __bodyRequire = (id) => {
795
+ const mod = require(id);
796
+ if (id === "worker_threads" || id === "node:worker_threads") {
797
+ return Object.freeze({
798
+ ...mod,
799
+ parentPort: null,
800
+ receiveMessageOnPort: undefined,
801
+ });
802
+ }
803
+ return mod;
804
+ };
805
+
806
+ // Names injected into every body's scope. Passing them as parameters gives
807
+ // each run a fresh function scope, so top-level declarations in user code
808
+ // cannot collide across runs.
809
+ const __injectNames = ${JSON.stringify(injectNames)};
810
+ const __injectValues = __injectNames.map((n) => {
811
+ switch (n) {
812
+ case "actions": return actions;
813
+ case "console": return console;
814
+ case "fetch": return fetch;
815
+ case "require": return __bodyRequire;
816
+ case "MiniSearch": return MiniSearch;
817
+ default: return __makeProxy([n]);
818
+ }
819
+ });
820
+
821
+ // One body, start to finish. Never throws: every outcome is a "done".
822
+ const __baselineOnMessage = __port.onmessage;
823
+
824
+ const __run = async (runId, body) => {
825
+ __runId = runId;
826
+ __pending.clear();
827
+ let msg;
828
+ try {
829
+ const fn = new Function(
830
+ ...__injectNames,
831
+ '"use strict"; return (async () => {\\n' + body + '\\n})();',
832
+ );
833
+ const v = await fn(...__injectValues);
834
+ msg = { t: "done", runId, ok: true, result: __toJson(v) };
835
+ } catch (e) {
836
+ msg = { t: "done", runId, ok: false, error: __fmtErr(e) };
837
+ }
838
+ // Scrub before reporting so the host's reuse decision and the worker's
839
+ // actual state agree. Dirty means "do not reuse me": a leftover timer, a
840
+ // global that would not delete, or a mutated shared intrinsic.
841
+ const __clean = __scrubGlobals();
842
+ msg.dirty = __liveTimers > 0 || !__clean;
843
+ try {
844
+ __port.postMessage(msg);
845
+ } catch (e) {
846
+ __port.postMessage({
847
+ t: "done",
848
+ runId,
849
+ ok: false,
850
+ error: __fmtErr(e),
851
+ dirty: true,
852
+ });
853
+ }
854
+ };
855
+
856
+ function __hostMessageHandler(m) {
857
+ if (!m) return;
858
+ if (m.t === "run") {
859
+ void __run(m.runId, m.body);
860
+ return;
861
+ }
862
+ if (m.t !== "result") return;
863
+ // Replies for a superseded run are dropped: its promises are gone.
864
+ if (m.runId !== __runId) return;
865
+ const p = __pending.get(m.id);
866
+ if (!p) return;
867
+ __pending.delete(m.id);
868
+ if (m.ok) p.resolve(m.value);
869
+ else p.reject(new Error(m.error));
870
+ }
871
+ __port.on("message", __hostMessageHandler);
495
872
  `;
496
873
  return wrapped;
497
874
  }
@@ -55,4 +55,43 @@ export function registerCycleActions(rl) {
55
55
  return data.cycleUpdate?.cycle;
56
56
  },
57
57
  });
58
+ // Archive is the only way to remove a single cycle: Linear has no
59
+ // per-cycle delete mutation. Issues are unlinked from the cycle first,
60
+ // they are not archived with it.
61
+ rl.registerAction("cycle.archive", {
62
+ access: "write",
63
+ description: "Archive one cycle. Issues assigned to it are unlinked from the cycle first, not archived. This is the per-cycle removal; Linear has no single-cycle delete.",
64
+ inputSchema: t.Object({
65
+ id: t.String({ description: "The identifier of the cycle to archive" }),
66
+ }),
67
+ async execute(input, ctx) {
68
+ requireUnscoped(ctx, "cycles.*");
69
+ const { id } = input;
70
+ const data = await gql(key(ctx), `mutation($id: String!) { cycleArchive(id: $id) { success } }`, { id });
71
+ return data.cycleArchive;
72
+ },
73
+ });
74
+ // Deliberately NOT named cycle.delete.
75
+ //
76
+ // Linear's teamCyclesDelete takes a *team* id and wipes every cycle that
77
+ // team has, disabling the cycles feature entirely. Exposing it as
78
+ // `cycle.delete({ id })` would read as "delete this one cycle" and let an
79
+ // agent destroy a team's whole cycle history by analogy with every other
80
+ // `*.delete` action in this plugin. The name and the parameter both say
81
+ // team, and the description leads with the blast radius.
82
+ rl.registerAction("team.cyclesDeleteAll", {
83
+ access: "write",
84
+ description: "DESTRUCTIVE: delete ALL cycle data for a team and disable the cycles feature. Removes every cycle and its issue associations, not just one. To remove a single cycle use cycle.archive.",
85
+ inputSchema: t.Object({
86
+ teamId: t.String({
87
+ description: "The identifier of the TEAM whose cycles will all be deleted",
88
+ }),
89
+ }),
90
+ async execute(input, ctx) {
91
+ requireUnscoped(ctx, "cycles.*");
92
+ const { teamId } = input;
93
+ const data = await gql(key(ctx), `mutation($id: String!) { teamCyclesDelete(id: $id) { success } }`, { id: teamId });
94
+ return data.teamCyclesDelete;
95
+ },
96
+ });
58
97
  }
package/dist/sdk.d.ts CHANGED
@@ -6,6 +6,11 @@ export interface RunlineOptions {
6
6
  connections?: ConnectionConfig[];
7
7
  timeoutMs?: number;
8
8
  memoryLimitBytes?: number;
9
+ /**
10
+ * Bodies one pooled worker may run before it is retired. See
11
+ * `ExecutionEngine` — this bounds cross-run contamination, not memory.
12
+ */
13
+ maxRunsPerWorker?: number;
9
14
  }
10
15
  export interface RunlineExecuteOptions {
11
16
  timeoutMs?: number;
@@ -14,9 +19,22 @@ export interface RunlineExecuteOptions {
14
19
  export declare class Runline {
15
20
  private _registry;
16
21
  private _config;
22
+ /**
23
+ * One engine per Runline instance, so its pooled worker survives across
24
+ * `execute()` calls. Constructing an engine per call would spawn (and
25
+ * leak) a fresh worker every time — the exact bug pooling exists to fix.
26
+ */
27
+ private _engine;
17
28
  private constructor();
29
+ private engine;
30
+ /**
31
+ * Retire the pooled worker. Idempotent. Callers holding a long-lived
32
+ * Runline should call this on shutdown; an idle pooled worker is unref'd
33
+ * so it will not by itself keep a process alive.
34
+ */
35
+ dispose(): void;
18
36
  static create(options?: RunlineOptions): Runline;
19
- /** Execute JavaScript code in the QuickJS runtime. */
37
+ /** Execute JavaScript code in a pooled worker. */
20
38
  execute(code: string, options?: RunlineExecuteOptions): Promise<ExecuteResult>;
21
39
  /** Register an additional plugin after creation. */
22
40
  addPlugin(pluginOrFn: PluginDef | PluginFunction, connections?: ConnectionConfig[]): void;
package/dist/sdk.js CHANGED
@@ -8,6 +8,12 @@ import { PluginRegistry } from "./plugin/registry.js";
8
8
  export class Runline {
9
9
  _registry;
10
10
  _config;
11
+ /**
12
+ * One engine per Runline instance, so its pooled worker survives across
13
+ * `execute()` calls. Constructing an engine per call would spawn (and
14
+ * leak) a fresh worker every time — the exact bug pooling exists to fix.
15
+ */
16
+ _engine = null;
11
17
  constructor(options) {
12
18
  this._registry = new PluginRegistry();
13
19
  for (const pluginOrFn of options.plugins ?? []) {
@@ -18,15 +24,32 @@ export class Runline {
18
24
  connections: options.connections ?? [],
19
25
  timeoutMs: options.timeoutMs ?? DEFAULT_CONFIG.timeoutMs,
20
26
  memoryLimitBytes: options.memoryLimitBytes ?? DEFAULT_CONFIG.memoryLimitBytes,
27
+ ...(options.maxRunsPerWorker !== undefined
28
+ ? { maxRunsPerWorker: options.maxRunsPerWorker }
29
+ : {}),
21
30
  };
22
31
  }
32
+ engine() {
33
+ if (!this._engine) {
34
+ this._engine = new ExecutionEngine(this._registry, this._config);
35
+ }
36
+ return this._engine;
37
+ }
38
+ /**
39
+ * Retire the pooled worker. Idempotent. Callers holding a long-lived
40
+ * Runline should call this on shutdown; an idle pooled worker is unref'd
41
+ * so it will not by itself keep a process alive.
42
+ */
43
+ dispose() {
44
+ this._engine?.dispose();
45
+ this._engine = null;
46
+ }
23
47
  static create(options = {}) {
24
48
  return new Runline(options);
25
49
  }
26
- /** Execute JavaScript code in the QuickJS runtime. */
50
+ /** Execute JavaScript code in a pooled worker. */
27
51
  async execute(code, options) {
28
- const engine = new ExecutionEngine(this._registry, this._config);
29
- return engine.execute(code, options);
52
+ return this.engine().execute(code, options);
30
53
  }
31
54
  /** Register an additional plugin after creation. */
32
55
  addPlugin(pluginOrFn, connections) {
@@ -38,6 +61,9 @@ export class Runline {
38
61
  connections: [...this._config.connections, ...connections],
39
62
  };
40
63
  }
64
+ // The pooled worker baked in the old plugin surface and the engine holds
65
+ // the old config object: both are stale now.
66
+ this.dispose();
41
67
  }
42
68
  /** List all available actions across all plugins. */
43
69
  actions() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "runline",
3
- "version": "0.16.2",
3
+ "version": "0.18.0",
4
4
  "description": "Code mode for agents — turn any API or command into a callable action",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",