runline 0.16.1 → 0.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/config/types.d.ts +6 -0
- package/dist/core/engine.d.ts +44 -2
- package/dist/core/engine.js +431 -54
- package/dist/plugins/linear/src/issues.js +19 -21
- package/dist/plugins/linear/src/shared.js +4 -2
- package/dist/sdk.d.ts +19 -1
- package/dist/sdk.js +29 -3
- package/package.json +1 -1
package/dist/config/types.d.ts
CHANGED
|
@@ -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;
|
package/dist/core/engine.d.ts
CHANGED
|
@@ -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
|
|
32
|
-
*
|
|
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;
|
package/dist/core/engine.js
CHANGED
|
@@ -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
|
|
30
|
-
*
|
|
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
|
|
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
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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({
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
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
|
-
|
|
487
|
-
} catch
|
|
488
|
-
|
|
768
|
+
delete globalThis[k];
|
|
769
|
+
} catch {
|
|
770
|
+
clean = false;
|
|
489
771
|
}
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
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
|
}
|
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
import * as t from "typebox";
|
|
2
2
|
import { assertIssueInScope, buildConnArgs, COMMENT_FIELDS, ensureScopeLabelsOnCreateOrReplace, forbidScopeLabelRemoval, gql, ISSUE_FIELDS, ISSUE_LITE, issueHasScope, key, LIST_INPUT_SCHEMA, mergeIssueScopeFilter, } from "./shared.js";
|
|
3
|
+
/**
|
|
4
|
+
* Linear's IssueUpdateInput accepts explicit null for clearable relations
|
|
5
|
+
* (e.g. cycleId: null removes the issue from its cycle). Omitted = leave
|
|
6
|
+
* unchanged, null = clear — the schema must express both.
|
|
7
|
+
*/
|
|
8
|
+
function clearable(schema, description) {
|
|
9
|
+
return t.Optional(t.Union([schema, t.Null()], {
|
|
10
|
+
description: `${description}. Pass null to clear`,
|
|
11
|
+
}));
|
|
12
|
+
}
|
|
3
13
|
export function registerIssueActions(rl) {
|
|
4
14
|
rl.registerAction("issue.create", {
|
|
5
15
|
access: "write",
|
|
@@ -86,7 +96,7 @@ export function registerIssueActions(rl) {
|
|
|
86
96
|
...LIST_INPUT_SCHEMA,
|
|
87
97
|
teamId: t.Optional(t.String({ description: "Convenience: filter by team" })),
|
|
88
98
|
assigneeId: t.Optional(t.String({ description: "Convenience: filter by assignee" })),
|
|
89
|
-
}),
|
|
99
|
+
}, { additionalProperties: false }),
|
|
90
100
|
async execute(input, ctx) {
|
|
91
101
|
const opts = (input ?? {});
|
|
92
102
|
// Merge convenience filters into `filter`
|
|
@@ -111,9 +121,7 @@ export function registerIssueActions(rl) {
|
|
|
111
121
|
}),
|
|
112
122
|
title: t.Optional(t.String({ description: "The issue title" })),
|
|
113
123
|
description: t.Optional(t.String({ description: "The issue description in markdown format" })),
|
|
114
|
-
assigneeId:
|
|
115
|
-
description: "The identifier of the user to assign the issue to",
|
|
116
|
-
})),
|
|
124
|
+
assigneeId: clearable(t.String(), "The identifier of the user to assign the issue to"),
|
|
117
125
|
stateId: t.Optional(t.String({ description: "The team workflow state of the issue" })),
|
|
118
126
|
priority: t.Optional(t.Number({
|
|
119
127
|
description: "Priority. 0=No priority, 1=Urgent, 2=High, 3=Medium, 4=Low",
|
|
@@ -127,23 +135,15 @@ export function registerIssueActions(rl) {
|
|
|
127
135
|
removedLabelIds: t.Optional(t.Array(t.Unknown(), {
|
|
128
136
|
description: "The identifiers of issue labels to be removed from this issue",
|
|
129
137
|
})),
|
|
130
|
-
projectId:
|
|
131
|
-
projectMilestoneId:
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
cycleId: t.Optional(t.String({ description: "The cycle associated with the issue" })),
|
|
135
|
-
parentId: t.Optional(t.String({
|
|
136
|
-
description: "The identifier of the parent issue. UUID or issue identifier (e.g., 'LIN-123')",
|
|
137
|
-
})),
|
|
138
|
+
projectId: clearable(t.String(), "The project associated with the issue"),
|
|
139
|
+
projectMilestoneId: clearable(t.String(), "The project milestone associated with the issue"),
|
|
140
|
+
cycleId: clearable(t.String(), "The cycle associated with the issue"),
|
|
141
|
+
parentId: clearable(t.String(), "The identifier of the parent issue. UUID or issue identifier (e.g., 'LIN-123')"),
|
|
138
142
|
teamId: t.Optional(t.String({
|
|
139
143
|
description: "The identifier of the team associated with the issue (move issue to a different team)",
|
|
140
144
|
})),
|
|
141
|
-
estimate:
|
|
142
|
-
|
|
143
|
-
})),
|
|
144
|
-
dueDate: t.Optional(t.String({
|
|
145
|
-
description: "The date at which the issue is due (TimelessDate, YYYY-MM-DD)",
|
|
146
|
-
})),
|
|
145
|
+
estimate: clearable(t.Number(), "The estimated complexity of the issue (Int)"),
|
|
146
|
+
dueDate: clearable(t.String(), "The date at which the issue is due (TimelessDate, YYYY-MM-DD)"),
|
|
147
147
|
subscriberIds: t.Optional(t.Array(t.Unknown(), {
|
|
148
148
|
description: "The identifiers of the users subscribing to this ticket",
|
|
149
149
|
})),
|
|
@@ -153,9 +153,7 @@ export function registerIssueActions(rl) {
|
|
|
153
153
|
subIssueSortOrder: t.Optional(t.Number({
|
|
154
154
|
description: "The position of the issue in its parent's sub-issue list (Float)",
|
|
155
155
|
})),
|
|
156
|
-
snoozedUntilAt:
|
|
157
|
-
description: "The time until which the issue will be snoozed in Triage view (DateTime)",
|
|
158
|
-
})),
|
|
156
|
+
snoozedUntilAt: clearable(t.String(), "The time until which the issue will be snoozed in Triage view (DateTime)"),
|
|
159
157
|
releaseIds: t.Optional(t.Array(t.Unknown(), {
|
|
160
158
|
description: "The identifiers of the releases associated with this issue (replaces all)",
|
|
161
159
|
})),
|
|
@@ -112,7 +112,7 @@ export const ISSUE_FIELDS = `id identifier title description url priority estima
|
|
|
112
112
|
projectMilestone { id name } parent { id identifier }
|
|
113
113
|
labels { nodes { id name color } }
|
|
114
114
|
createdAt updatedAt completedAt canceledAt archivedAt`;
|
|
115
|
-
export const ISSUE_LITE = `id identifier title url priority state { id name type } assignee { id name } team { key } updatedAt`;
|
|
115
|
+
export const ISSUE_LITE = `id identifier title url priority state { id name type } assignee { id name } team { key } cycle { id number name } updatedAt`;
|
|
116
116
|
export const COMMENT_FIELDS = `id body url issue { id identifier } user { id name } parent { id } createdAt updatedAt editedAt resolvedAt`;
|
|
117
117
|
export const STATE_FIELDS = `id name type color position description team { id key }`;
|
|
118
118
|
export const LABEL_FIELDS = `id name color description isGroup parent { id name } team { id key } createdAt`;
|
|
@@ -180,7 +180,9 @@ export function buildConnArgs(opts, filterTypeName) {
|
|
|
180
180
|
};
|
|
181
181
|
}
|
|
182
182
|
export const LIST_INPUT_SCHEMA = {
|
|
183
|
-
limit: t.Optional(t.Number({
|
|
183
|
+
limit: t.Optional(t.Number({
|
|
184
|
+
description: "Max results per page (default 50, max 250). For more, paginate with `after` = pageInfo.endCursor",
|
|
185
|
+
})),
|
|
184
186
|
filter: t.Optional(t.Object({}, { description: "Linear filter object (see schema for the resource)" })),
|
|
185
187
|
includeArchived: t.Optional(t.Boolean({ description: "Include archived items" })),
|
|
186
188
|
orderBy: t.Optional(t.String({ description: "createdAt | updatedAt" })),
|
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
|
|
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
|
|
50
|
+
/** Execute JavaScript code in a pooled worker. */
|
|
27
51
|
async execute(code, options) {
|
|
28
|
-
|
|
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() {
|