runline 0.10.0 → 0.11.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/core/engine.d.ts
CHANGED
|
@@ -9,6 +9,28 @@ export interface EngineOptions {
|
|
|
9
9
|
timeoutMs?: number;
|
|
10
10
|
memoryLimitBytes?: number;
|
|
11
11
|
}
|
|
12
|
+
/**
|
|
13
|
+
* Whether to arm the host-side RSS watchdog for a worker run.
|
|
14
|
+
*
|
|
15
|
+
* node enforces resourceLimits.maxOldGenerationSizeMb natively, and the
|
|
16
|
+
* watchdog measures *whole-process* RSS — arming it there would risk false
|
|
17
|
+
* kills from unrelated host allocations (e.g. a concurrent execute). bun
|
|
18
|
+
* ignores resourceLimits, so the watchdog is the only memory backstop.
|
|
19
|
+
*/
|
|
20
|
+
export declare function shouldArmRssWatchdog(versions?: Partial<Record<string, string>>): boolean;
|
|
21
|
+
/**
|
|
22
|
+
* Executes agent code in a node:worker_threads worker.
|
|
23
|
+
*
|
|
24
|
+
* The worker is an ergonomic coding surface, not a security sandbox: agent
|
|
25
|
+
* code gets the full host JS runtime (Buffer, crypto, etc.) plus injected
|
|
26
|
+
* action proxies. Isolation properties we do enforce, fail-soft:
|
|
27
|
+
*
|
|
28
|
+
* - timeout: worker.terminate() — interrupts even `while(true){}`
|
|
29
|
+
* - memory: resourceLimits.maxOldGenerationSizeMb (node) + an RSS-delta
|
|
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
|
|
33
|
+
*/
|
|
12
34
|
export declare class ExecutionEngine {
|
|
13
35
|
private registry;
|
|
14
36
|
private config;
|
|
@@ -16,6 +38,4 @@ export declare class ExecutionEngine {
|
|
|
16
38
|
execute(code: string, options?: EngineOptions): Promise<ExecuteResult>;
|
|
17
39
|
private invokeAction;
|
|
18
40
|
private resolveConnection;
|
|
19
|
-
private drainAsync;
|
|
20
|
-
private drainJobs;
|
|
21
41
|
}
|
package/dist/core/engine.js
CHANGED
|
@@ -1,7 +1,34 @@
|
|
|
1
1
|
import { readFileSync } from "node:fs";
|
|
2
|
-
import {
|
|
2
|
+
import { Worker } from "node:worker_threads";
|
|
3
3
|
import { applyEnvOverrides, updateConnectionConfig } from "../config/loader.js";
|
|
4
4
|
import { formatValidationError, helpInputs, isTypedInputSchema, validateTypedInput, } from "../plugin/schema.js";
|
|
5
|
+
/**
|
|
6
|
+
* Whether to arm the host-side RSS watchdog for a worker run.
|
|
7
|
+
*
|
|
8
|
+
* node enforces resourceLimits.maxOldGenerationSizeMb natively, and the
|
|
9
|
+
* watchdog measures *whole-process* RSS — arming it there would risk false
|
|
10
|
+
* kills from unrelated host allocations (e.g. a concurrent execute). bun
|
|
11
|
+
* ignores resourceLimits, so the watchdog is the only memory backstop.
|
|
12
|
+
*/
|
|
13
|
+
export function shouldArmRssWatchdog(versions = process.versions) {
|
|
14
|
+
return Boolean(versions.bun);
|
|
15
|
+
}
|
|
16
|
+
// Extra slack on top of the configured memory limit for the RSS watchdog,
|
|
17
|
+
// since whole-process RSS includes the host's own working set.
|
|
18
|
+
const RSS_WATCHDOG_SLACK_BYTES = 128 * 1024 * 1024;
|
|
19
|
+
/**
|
|
20
|
+
* Executes agent code in a node:worker_threads worker.
|
|
21
|
+
*
|
|
22
|
+
* The worker is an ergonomic coding surface, not a security sandbox: agent
|
|
23
|
+
* code gets the full host JS runtime (Buffer, crypto, etc.) plus injected
|
|
24
|
+
* action proxies. Isolation properties we do enforce, fail-soft:
|
|
25
|
+
*
|
|
26
|
+
* - timeout: worker.terminate() — interrupts even `while(true){}`
|
|
27
|
+
* - memory: resourceLimits.maxOldGenerationSizeMb (node) + an RSS-delta
|
|
28
|
+
* 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
|
|
31
|
+
*/
|
|
5
32
|
export class ExecutionEngine {
|
|
6
33
|
registry;
|
|
7
34
|
config;
|
|
@@ -12,119 +39,121 @@ export class ExecutionEngine {
|
|
|
12
39
|
async execute(code, options) {
|
|
13
40
|
const timeoutMs = options?.timeoutMs ?? this.config.timeoutMs;
|
|
14
41
|
const memoryLimitBytes = options?.memoryLimitBytes ?? this.config.memoryLimitBytes;
|
|
15
|
-
const deadlineMs = Date.now() + timeoutMs;
|
|
16
42
|
const logs = [];
|
|
17
|
-
const
|
|
18
|
-
const
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
runtime.setInterruptHandler(shouldInterruptAfterDeadline(deadlineMs));
|
|
23
|
-
const context = runtime.newContext();
|
|
43
|
+
const plugins = this.registry.listPlugins();
|
|
44
|
+
const source = buildWorkerSource(code, plugins.map((p) => p.name), buildHelpData(plugins));
|
|
45
|
+
return new Promise((resolve) => {
|
|
46
|
+
const memoryLimitMb = Math.max(8, Math.floor(memoryLimitBytes / (1024 * 1024)));
|
|
47
|
+
let worker;
|
|
24
48
|
try {
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
const line = context.getString(lineHandle);
|
|
29
|
-
logs.push(`[${level}] ${line}`);
|
|
30
|
-
return context.undefined;
|
|
49
|
+
worker = new Worker(source, {
|
|
50
|
+
eval: true,
|
|
51
|
+
resourceLimits: { maxOldGenerationSizeMb: memoryLimitMb },
|
|
31
52
|
});
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
const handle = context.newString(serialized);
|
|
53
|
-
deferred.resolve(handle);
|
|
54
|
-
handle.dispose();
|
|
55
|
-
}, (err) => {
|
|
56
|
-
if (!deferred.alive)
|
|
57
|
-
return;
|
|
58
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
59
|
-
const handle = context.newError(msg);
|
|
60
|
-
deferred.reject(handle);
|
|
61
|
-
handle.dispose();
|
|
62
|
-
});
|
|
63
|
-
return deferred.handle;
|
|
53
|
+
}
|
|
54
|
+
catch (err) {
|
|
55
|
+
resolve({ result: null, error: formatError(err), logs });
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
let settled = false;
|
|
59
|
+
const finish = (r) => {
|
|
60
|
+
if (settled)
|
|
61
|
+
return;
|
|
62
|
+
settled = true;
|
|
63
|
+
clearTimeout(timeoutTimer);
|
|
64
|
+
clearInterval(rssTimer);
|
|
65
|
+
void worker.terminate();
|
|
66
|
+
resolve(r);
|
|
67
|
+
};
|
|
68
|
+
const timeoutTimer = setTimeout(() => {
|
|
69
|
+
finish({
|
|
70
|
+
result: null,
|
|
71
|
+
error: `Execution timed out after ${timeoutMs}ms`,
|
|
72
|
+
logs,
|
|
64
73
|
});
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
const
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
const error = context.dump(evaluated.error);
|
|
74
|
-
evaluated.error.dispose();
|
|
75
|
-
return { result: null, error: formatError(error), logs };
|
|
76
|
-
}
|
|
77
|
-
// Set up promise tracking
|
|
78
|
-
context.setProp(context.global, "__runline_result", evaluated.value);
|
|
79
|
-
evaluated.value.dispose();
|
|
80
|
-
const stateResult = context.evalCode(`(function(p){ var s = { v: void 0, e: void 0, settled: false };
|
|
81
|
-
var fmtErr = function(e){ if (e && typeof e === 'object') { var m = typeof e.message === 'string' ? e.message : ''; var st = typeof e.stack === 'string' ? e.stack : ''; if (m && st) return st.indexOf(m) === -1 ? m + '\\n' + st : st; if (m) return m; if (st) return st; } return String(e); };
|
|
82
|
-
p.then(function(v){ s.v = v; s.settled = true; }, function(e){ s.e = fmtErr(e); s.settled = true; }); return s; })(__runline_result)`);
|
|
83
|
-
if (stateResult.error) {
|
|
84
|
-
const error = context.dump(stateResult.error);
|
|
85
|
-
stateResult.error.dispose();
|
|
86
|
-
return { result: null, error: formatError(error), logs };
|
|
87
|
-
}
|
|
88
|
-
const stateHandle = stateResult.value;
|
|
89
|
-
try {
|
|
90
|
-
await this.drainAsync(context, runtime, pendingDeferreds, deadlineMs, timeoutMs);
|
|
91
|
-
const settled = readProp(context, stateHandle, "settled") === true;
|
|
92
|
-
if (!settled) {
|
|
93
|
-
return {
|
|
74
|
+
}, timeoutMs);
|
|
75
|
+
let rssTimer;
|
|
76
|
+
if (shouldArmRssWatchdog()) {
|
|
77
|
+
const baselineRss = process.memoryUsage().rss;
|
|
78
|
+
rssTimer = setInterval(() => {
|
|
79
|
+
const delta = process.memoryUsage().rss - baselineRss;
|
|
80
|
+
if (delta > memoryLimitBytes + RSS_WATCHDOG_SLACK_BYTES) {
|
|
81
|
+
finish({
|
|
94
82
|
result: null,
|
|
95
|
-
error: `
|
|
83
|
+
error: `Memory limit exceeded (${memoryLimitMb}MB)`,
|
|
96
84
|
logs,
|
|
97
|
-
};
|
|
98
|
-
}
|
|
99
|
-
const error = readProp(context, stateHandle, "e");
|
|
100
|
-
if (error !== undefined) {
|
|
101
|
-
return { result: null, error: formatError(error), logs };
|
|
85
|
+
});
|
|
102
86
|
}
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
finally {
|
|
106
|
-
stateHandle.dispose();
|
|
107
|
-
}
|
|
87
|
+
}, 100);
|
|
88
|
+
rssTimer.unref?.();
|
|
108
89
|
}
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
90
|
+
// A reply can race the worker's death; losing it is fine — the run is
|
|
91
|
+
// over either way — but it must never surface as an unhandled
|
|
92
|
+
// rejection in the host.
|
|
93
|
+
const reply = (message) => {
|
|
94
|
+
if (settled)
|
|
95
|
+
return;
|
|
96
|
+
try {
|
|
97
|
+
worker.postMessage(message);
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
// worker already gone
|
|
113
101
|
}
|
|
114
|
-
pendingDeferreds.clear();
|
|
115
|
-
context.dispose();
|
|
116
|
-
}
|
|
117
|
-
}
|
|
118
|
-
catch (err) {
|
|
119
|
-
return {
|
|
120
|
-
result: null,
|
|
121
|
-
error: formatError(err),
|
|
122
|
-
logs,
|
|
123
102
|
};
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
103
|
+
worker.on("message", (msg) => {
|
|
104
|
+
if (settled)
|
|
105
|
+
return;
|
|
106
|
+
if (msg.t === "log") {
|
|
107
|
+
logs.push(`[${msg.level}] ${msg.line}`);
|
|
108
|
+
}
|
|
109
|
+
else if (msg.t === "invoke") {
|
|
110
|
+
this.invokeAction(msg.path, msg.args).then((value) => {
|
|
111
|
+
let serialized;
|
|
112
|
+
try {
|
|
113
|
+
serialized = toPlainJson(value);
|
|
114
|
+
}
|
|
115
|
+
catch (err) {
|
|
116
|
+
reply({
|
|
117
|
+
t: "result",
|
|
118
|
+
id: msg.id,
|
|
119
|
+
ok: false,
|
|
120
|
+
error: `Action result not JSON-serializable: ${err instanceof Error ? err.message : String(err)}`,
|
|
121
|
+
});
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
reply({ t: "result", id: msg.id, ok: true, value: serialized });
|
|
125
|
+
}, (err) => {
|
|
126
|
+
reply({
|
|
127
|
+
t: "result",
|
|
128
|
+
id: msg.id,
|
|
129
|
+
ok: false,
|
|
130
|
+
error: err instanceof Error ? err.message : String(err),
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
else if (msg.t === "done") {
|
|
135
|
+
finish(msg.ok
|
|
136
|
+
? { result: msg.result, logs }
|
|
137
|
+
: { result: null, error: msg.error ?? "Unknown error", logs });
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
worker.on("error", (err) => {
|
|
141
|
+
finish({
|
|
142
|
+
result: null,
|
|
143
|
+
error: err.code === "ERR_WORKER_OUT_OF_MEMORY"
|
|
144
|
+
? `Memory limit exceeded (${memoryLimitMb}MB)`
|
|
145
|
+
: formatError(err),
|
|
146
|
+
logs,
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
worker.on("exit", (exitCode) => {
|
|
150
|
+
finish({
|
|
151
|
+
result: null,
|
|
152
|
+
error: `Worker exited unexpectedly (code ${exitCode})`,
|
|
153
|
+
logs,
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
});
|
|
128
157
|
}
|
|
129
158
|
async invokeAction(path, args) {
|
|
130
159
|
const resolved = this.registry.resolveAction(path);
|
|
@@ -164,53 +193,17 @@ export class ExecutionEngine {
|
|
|
164
193
|
};
|
|
165
194
|
return applyEnvOverrides(base, plugin.connectionConfigSchema);
|
|
166
195
|
}
|
|
167
|
-
async drainAsync(context, runtime, pendingDeferreds, deadlineMs, timeoutMs) {
|
|
168
|
-
this.drainJobs(context, runtime, deadlineMs, timeoutMs);
|
|
169
|
-
while (pendingDeferreds.size > 0) {
|
|
170
|
-
const remainingMs = deadlineMs - Date.now();
|
|
171
|
-
if (remainingMs <= 0) {
|
|
172
|
-
throw new Error(`Execution timed out after ${timeoutMs}ms`);
|
|
173
|
-
}
|
|
174
|
-
let timer;
|
|
175
|
-
try {
|
|
176
|
-
await Promise.race([
|
|
177
|
-
Promise.race([...pendingDeferreds].map((d) => d.settled)),
|
|
178
|
-
new Promise((_, reject) => {
|
|
179
|
-
timer = setTimeout(() => reject(new Error(`Execution timed out after ${timeoutMs}ms`)), remainingMs);
|
|
180
|
-
}),
|
|
181
|
-
]);
|
|
182
|
-
}
|
|
183
|
-
finally {
|
|
184
|
-
if (timer)
|
|
185
|
-
clearTimeout(timer);
|
|
186
|
-
}
|
|
187
|
-
this.drainJobs(context, runtime, deadlineMs, timeoutMs);
|
|
188
|
-
}
|
|
189
|
-
this.drainJobs(context, runtime, deadlineMs, timeoutMs);
|
|
190
|
-
}
|
|
191
|
-
drainJobs(context, runtime, deadlineMs, timeoutMs) {
|
|
192
|
-
while (runtime.hasPendingJob()) {
|
|
193
|
-
if (Date.now() >= deadlineMs) {
|
|
194
|
-
throw new Error(`Execution timed out after ${timeoutMs}ms`);
|
|
195
|
-
}
|
|
196
|
-
const pending = runtime.executePendingJobs();
|
|
197
|
-
if (pending.error) {
|
|
198
|
-
const error = context.dump(pending.error);
|
|
199
|
-
pending.error.dispose();
|
|
200
|
-
throw error instanceof Error ? error : new Error(String(error));
|
|
201
|
-
}
|
|
202
|
-
}
|
|
203
|
-
}
|
|
204
196
|
}
|
|
205
197
|
// ── Helpers ──────────────────────────────────────────────
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
198
|
+
/**
|
|
199
|
+
* JSON round-trip to (a) guarantee structured-clone compatibility and
|
|
200
|
+
* (b) preserve the previous engine's value semantics, where every action
|
|
201
|
+
* result crossed a JSON boundary (Dates → ISO strings, no Maps, etc.).
|
|
202
|
+
*/
|
|
203
|
+
function toPlainJson(value) {
|
|
204
|
+
if (value === undefined)
|
|
205
|
+
return undefined;
|
|
206
|
+
return JSON.parse(JSON.stringify(value));
|
|
214
207
|
}
|
|
215
208
|
function formatError(cause) {
|
|
216
209
|
if (cause instanceof Error)
|
|
@@ -224,13 +217,13 @@ function formatError(cause) {
|
|
|
224
217
|
return String(cause);
|
|
225
218
|
}
|
|
226
219
|
// MiniSearch UMD bundle, vendored at the package root and inlined into the
|
|
227
|
-
//
|
|
228
|
-
//
|
|
220
|
+
// worker source. Inside the worker it is evaluated in a local scope with a
|
|
221
|
+
// fresh `module`/`exports` pair so the UMD takes its CommonJS branch.
|
|
229
222
|
//
|
|
230
223
|
// `../../vendor/...` resolves identically from src/core/engine.ts (dev) and
|
|
231
224
|
// dist/core/engine.js (published) because tsc preserves the `core/` subdir.
|
|
232
225
|
// See vendor/README.md for the upgrade procedure.
|
|
233
|
-
const
|
|
226
|
+
const minisearchSource = readFileSync(new URL("../../vendor/minisearch.umd.js", import.meta.url), "utf8");
|
|
234
227
|
function buildHelpData(plugins) {
|
|
235
228
|
const data = {};
|
|
236
229
|
for (const p of plugins) {
|
|
@@ -242,7 +235,7 @@ function buildHelpData(plugins) {
|
|
|
242
235
|
}
|
|
243
236
|
return data;
|
|
244
237
|
}
|
|
245
|
-
function
|
|
238
|
+
function buildWorkerSource(code, pluginNames = [], helpData = {}) {
|
|
246
239
|
const trimmed = code.trim();
|
|
247
240
|
const looksLikeArrow = (trimmed.startsWith("async") || trimmed.startsWith("(")) &&
|
|
248
241
|
trimmed.includes("=>");
|
|
@@ -250,18 +243,68 @@ function buildExecutionSource(code, pluginNames = [], helpData = {}) {
|
|
|
250
243
|
? `const __fn = (${trimmed});\nif (typeof __fn !== 'function') throw new Error('Code must evaluate to a function');\nreturn await __fn();`
|
|
251
244
|
: code;
|
|
252
245
|
const wrapped = `"use strict";
|
|
253
|
-
const
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
246
|
+
const { parentPort: __port } = require("node:worker_threads");
|
|
247
|
+
|
|
248
|
+
// process.exit would be runtime-divergent here: node kills the worker
|
|
249
|
+
// synchronously, bun lets the completion message race the exit and can
|
|
250
|
+
// report a silent undefined success. Make it a regular, catchable error.
|
|
251
|
+
process.exit = (code) => {
|
|
252
|
+
throw new Error('process.exit(' + (code ?? 0) + ') is not available in the runline sandbox; return a value instead');
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
// ── host bridge ──
|
|
256
|
+
let __seq = 0;
|
|
257
|
+
const __pending = new Map();
|
|
258
|
+
__port.on("message", (m) => {
|
|
259
|
+
if (!m || m.t !== "result") return;
|
|
260
|
+
const p = __pending.get(m.id);
|
|
261
|
+
if (!p) return;
|
|
262
|
+
__pending.delete(m.id);
|
|
263
|
+
if (m.ok) p.resolve(m.value);
|
|
264
|
+
else p.reject(new Error(m.error));
|
|
265
|
+
});
|
|
266
|
+
const __invoke = (path, args) => new Promise((resolve, reject) => {
|
|
267
|
+
const id = ++__seq;
|
|
268
|
+
__pending.set(id, { resolve, reject });
|
|
269
|
+
try {
|
|
270
|
+
__port.postMessage({ t: "invoke", id, path, args });
|
|
271
|
+
} catch (e) {
|
|
272
|
+
__pending.delete(id);
|
|
273
|
+
reject(e);
|
|
274
|
+
}
|
|
275
|
+
});
|
|
276
|
+
const __log = (level, line) => {
|
|
277
|
+
try { __port.postMessage({ t: "log", level, line }); } catch {}
|
|
278
|
+
};
|
|
257
279
|
|
|
258
280
|
const __fmt = (v) => {
|
|
259
281
|
if (typeof v === 'string') return v;
|
|
260
282
|
try { return JSON.stringify(v); } catch { return String(v); }
|
|
261
283
|
};
|
|
262
284
|
|
|
263
|
-
|
|
264
|
-
|
|
285
|
+
const __fmtErr = (e) => {
|
|
286
|
+
if (e && typeof e === 'object') {
|
|
287
|
+
const m = typeof e.message === 'string' ? e.message : '';
|
|
288
|
+
const st = typeof e.stack === 'string' ? e.stack : '';
|
|
289
|
+
if (m && st) return st.indexOf(m) === -1 ? m + '\\n' + st : st;
|
|
290
|
+
if (m) return m;
|
|
291
|
+
if (st) return st;
|
|
292
|
+
}
|
|
293
|
+
return String(e);
|
|
294
|
+
};
|
|
295
|
+
|
|
296
|
+
// JSON round-trip mirrors the host's toPlainJson: keeps results
|
|
297
|
+
// structured-clone-safe and preserves JSON value semantics.
|
|
298
|
+
const __toJson = (v) => v === undefined ? undefined : JSON.parse(JSON.stringify(v));
|
|
299
|
+
|
|
300
|
+
// Inlined MiniSearch UMD, evaluated with a local module/exports so the UMD
|
|
301
|
+
// takes its CommonJS branch regardless of the worker's module scope.
|
|
302
|
+
const MiniSearch = (function () {
|
|
303
|
+
const module = { exports: {} };
|
|
304
|
+
const exports = module.exports;
|
|
305
|
+
${minisearchSource}
|
|
306
|
+
return module.exports;
|
|
307
|
+
})();
|
|
265
308
|
|
|
266
309
|
const __help = ${JSON.stringify(helpData)};
|
|
267
310
|
|
|
@@ -273,8 +316,7 @@ const __makeProxy = (path = []) => new Proxy(() => undefined, {
|
|
|
273
316
|
apply(_t, _this, args) {
|
|
274
317
|
const p = path.join('.');
|
|
275
318
|
if (!p) throw new Error('Action path missing');
|
|
276
|
-
return
|
|
277
|
-
.then((raw) => raw === undefined ? undefined : JSON.parse(raw));
|
|
319
|
+
return __invoke(p, args[0]);
|
|
278
320
|
},
|
|
279
321
|
});
|
|
280
322
|
|
|
@@ -296,7 +338,7 @@ const __formatSignature = (plugin, entry) => {
|
|
|
296
338
|
return plugin + '.' + entry.action + (fields ? '({ ' + fields + ' })' : '()');
|
|
297
339
|
};
|
|
298
340
|
|
|
299
|
-
// Build a MiniSearch index over every action path. Indexed at
|
|
341
|
+
// Build a MiniSearch index over every action path. Indexed at worker
|
|
300
342
|
// startup, queried by actions.find().
|
|
301
343
|
const __search = (() => {
|
|
302
344
|
const docs = [];
|
|
@@ -416,6 +458,18 @@ const fetch = () => { throw new Error('fetch is disabled in runline sandbox'); }
|
|
|
416
458
|
|
|
417
459
|
(async () => {
|
|
418
460
|
${body}
|
|
419
|
-
})()
|
|
461
|
+
})().then(
|
|
462
|
+
(v) => {
|
|
463
|
+
try {
|
|
464
|
+
__port.postMessage({ t: "done", ok: true, result: __toJson(v) });
|
|
465
|
+
} catch (e) {
|
|
466
|
+
__port.postMessage({ t: "done", ok: false, error: __fmtErr(e) });
|
|
467
|
+
}
|
|
468
|
+
},
|
|
469
|
+
(e) => {
|
|
470
|
+
__port.postMessage({ t: "done", ok: false, error: __fmtErr(e) });
|
|
471
|
+
},
|
|
472
|
+
);
|
|
473
|
+
`;
|
|
420
474
|
return wrapped;
|
|
421
475
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
// Test harness for engine robustness scenarios (large payloads, OOM,
|
|
2
|
+
// timeouts, crashes). Runs one named scenario in this process and prints a
|
|
3
|
+
// JSON report to stdout. Tests spawn this file so that a hard process
|
|
4
|
+
// abort or exit kills the harness, not the test runner.
|
|
5
|
+
//
|
|
6
|
+
// Usage: bun run engine-harness.ts <scenario>
|
|
7
|
+
import { createHash } from "node:crypto";
|
|
8
|
+
import { DEFAULT_CONFIG } from "../../config/types.js";
|
|
9
|
+
import { ExecutionEngine } from "../../core/engine.js";
|
|
10
|
+
import { createPluginAPI } from "../../plugin/api.js";
|
|
11
|
+
import { PluginRegistry } from "../../plugin/registry.js";
|
|
12
|
+
const BLOB_MB = 8;
|
|
13
|
+
// ~8MB of raw bytes → ~10.7MB base64
|
|
14
|
+
const base64 = Buffer.alloc(BLOB_MB * 1024 * 1024, 0xab).toString("base64");
|
|
15
|
+
const base64Sha = createHash("sha256").update(base64).digest("hex");
|
|
16
|
+
// Captures what the host-side actions actually received, so tests can
|
|
17
|
+
// verify that data crossing the execution boundary arrives byte-identical.
|
|
18
|
+
const received = {};
|
|
19
|
+
function makeFilesPlugin() {
|
|
20
|
+
const { api, resolve } = createPluginAPI("test");
|
|
21
|
+
api.setName("files");
|
|
22
|
+
api.setVersion("0.0.0");
|
|
23
|
+
api.registerAction("getAttachment", {
|
|
24
|
+
description: "Returns a large base64 payload",
|
|
25
|
+
async execute() {
|
|
26
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
27
|
+
return { filename: "msa.pdf", data: base64 };
|
|
28
|
+
},
|
|
29
|
+
});
|
|
30
|
+
api.registerAction("upload", {
|
|
31
|
+
description: "Accepts a large base64 payload",
|
|
32
|
+
async execute(input) {
|
|
33
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
34
|
+
const { data } = input;
|
|
35
|
+
received.uploadSha =
|
|
36
|
+
typeof data === "string"
|
|
37
|
+
? createHash("sha256").update(data).digest("hex")
|
|
38
|
+
: `not-a-string:${typeof data}`;
|
|
39
|
+
received.uploadBytes = typeof data === "string" ? data.length : -1;
|
|
40
|
+
return { id: "file_123", bytes: received.uploadBytes };
|
|
41
|
+
},
|
|
42
|
+
});
|
|
43
|
+
api.registerAction("send", {
|
|
44
|
+
description: "Accepts a large attachment",
|
|
45
|
+
async execute(input) {
|
|
46
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
47
|
+
const { attachment } = input;
|
|
48
|
+
received.sendBytes =
|
|
49
|
+
typeof attachment === "string" ? attachment.length : -1;
|
|
50
|
+
return { messageId: "msg_456", bytes: received.sendBytes };
|
|
51
|
+
},
|
|
52
|
+
});
|
|
53
|
+
api.registerAction("append", {
|
|
54
|
+
description: "Small side effect",
|
|
55
|
+
async execute() {
|
|
56
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
57
|
+
return { updatedRows: 1 };
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
api.registerAction("slow", {
|
|
61
|
+
description: "Sleeps for the given ms, then returns",
|
|
62
|
+
async execute(input) {
|
|
63
|
+
const { ms } = input;
|
|
64
|
+
await new Promise((r) => setTimeout(r, ms));
|
|
65
|
+
return { waited: ms };
|
|
66
|
+
},
|
|
67
|
+
});
|
|
68
|
+
api.registerAction("circular", {
|
|
69
|
+
description: "Returns a non-JSON-serializable (circular) value",
|
|
70
|
+
async execute() {
|
|
71
|
+
const obj = { name: "loop" };
|
|
72
|
+
obj.self = obj;
|
|
73
|
+
return obj;
|
|
74
|
+
},
|
|
75
|
+
});
|
|
76
|
+
return resolve();
|
|
77
|
+
}
|
|
78
|
+
function makeEngine(memoryMb = 64, timeoutMs = 30_000) {
|
|
79
|
+
const registry = new PluginRegistry();
|
|
80
|
+
registry.register(makeFilesPlugin());
|
|
81
|
+
return new ExecutionEngine(registry, {
|
|
82
|
+
...DEFAULT_CONFIG,
|
|
83
|
+
timeoutMs,
|
|
84
|
+
memoryLimitBytes: memoryMb * 1024 * 1024,
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
const scenarios = {
|
|
88
|
+
// The agent's original failure: multi-step chain with a large payload,
|
|
89
|
+
// default 64MB memory limit. Must complete cleanly.
|
|
90
|
+
async "chain-default"() {
|
|
91
|
+
const engine = makeEngine(64);
|
|
92
|
+
const out = await engine.execute(`
|
|
93
|
+
const att = await files.getAttachment({ messageId: "m1" });
|
|
94
|
+
const up = await files.upload({ name: att.filename, data: att.data });
|
|
95
|
+
const sent = await files.send({ to: "x@y.z", attachment: att.data });
|
|
96
|
+
const row = await files.append({ values: [["Triple-A MSA", "May"]] });
|
|
97
|
+
return { up, sent, row };
|
|
98
|
+
`);
|
|
99
|
+
return { error: out.error ?? null, result: out.result, received };
|
|
100
|
+
},
|
|
101
|
+
// Integrity: the bytes the upload action receives must be byte-identical
|
|
102
|
+
// to what getAttachment produced.
|
|
103
|
+
async integrity() {
|
|
104
|
+
const engine = makeEngine(64);
|
|
105
|
+
const out = await engine.execute(`
|
|
106
|
+
const att = await files.getAttachment({ messageId: "m1" });
|
|
107
|
+
await files.upload({ name: att.filename, data: att.data });
|
|
108
|
+
return "done";
|
|
109
|
+
`);
|
|
110
|
+
return {
|
|
111
|
+
error: out.error ?? null,
|
|
112
|
+
expectedSha: base64Sha,
|
|
113
|
+
expectedBytes: base64.length,
|
|
114
|
+
received,
|
|
115
|
+
};
|
|
116
|
+
},
|
|
117
|
+
// Ergonomics: inside the sandbox a large payload is a plain string the
|
|
118
|
+
// agent can measure, slice, and pass around — no tokens, no proxies.
|
|
119
|
+
async "string-surface"() {
|
|
120
|
+
const engine = makeEngine(64);
|
|
121
|
+
const out = await engine.execute(`
|
|
122
|
+
const att = await files.getAttachment({ messageId: "m1" });
|
|
123
|
+
const d = att.data;
|
|
124
|
+
return { type: typeof d, bytes: d.length, head: d.slice(0, 8) };
|
|
125
|
+
`);
|
|
126
|
+
return {
|
|
127
|
+
error: out.error ?? null,
|
|
128
|
+
result: out.result,
|
|
129
|
+
expectedBytes: base64.length,
|
|
130
|
+
expectedHead: base64.slice(0, 8),
|
|
131
|
+
};
|
|
132
|
+
},
|
|
133
|
+
// A large value in the final return reaches the host caller intact.
|
|
134
|
+
async "final-result-large"() {
|
|
135
|
+
const engine = makeEngine(64);
|
|
136
|
+
const out = await engine.execute(`
|
|
137
|
+
const att = await files.getAttachment({ messageId: "m1" });
|
|
138
|
+
return { data: att.data };
|
|
139
|
+
`);
|
|
140
|
+
const data = out.result?.data;
|
|
141
|
+
return {
|
|
142
|
+
error: out.error ?? null,
|
|
143
|
+
resultBytes: typeof data === "string" ? data.length : -1,
|
|
144
|
+
resultSha: typeof data === "string"
|
|
145
|
+
? createHash("sha256").update(data).digest("hex")
|
|
146
|
+
: null,
|
|
147
|
+
expectedSha: base64Sha,
|
|
148
|
+
expectedBytes: base64.length,
|
|
149
|
+
};
|
|
150
|
+
},
|
|
151
|
+
// An action still in flight when the run times out must not crash the
|
|
152
|
+
// host when its result arrives after the worker is gone — no unhandled
|
|
153
|
+
// rejections, no uncaught exceptions, clean timeout error.
|
|
154
|
+
async "timeout-inflight-action"() {
|
|
155
|
+
const unhandled = [];
|
|
156
|
+
process.on("unhandledRejection", (e) => unhandled.push(String(e)));
|
|
157
|
+
process.on("uncaughtException", (e) => unhandled.push(String(e)));
|
|
158
|
+
const engine = makeEngine(64, 150);
|
|
159
|
+
const out = await engine.execute(`
|
|
160
|
+
await files.slow({ ms: 1000 });
|
|
161
|
+
return "unreachable";
|
|
162
|
+
`);
|
|
163
|
+
// let the in-flight action resolve against the dead worker
|
|
164
|
+
await new Promise((r) => setTimeout(r, 1200));
|
|
165
|
+
return { error: out.error ?? null, unhandled };
|
|
166
|
+
},
|
|
167
|
+
// An action returning a non-JSON-serializable value must surface as a
|
|
168
|
+
// clean per-call error inside the sandbox — catchable by agent code —
|
|
169
|
+
// not a hang or a crash.
|
|
170
|
+
async "circular-result"() {
|
|
171
|
+
const engine = makeEngine(64);
|
|
172
|
+
const out = await engine.execute(`
|
|
173
|
+
try {
|
|
174
|
+
await files.circular({});
|
|
175
|
+
return { caught: false };
|
|
176
|
+
} catch (e) {
|
|
177
|
+
return { caught: true, message: e.message };
|
|
178
|
+
}
|
|
179
|
+
`);
|
|
180
|
+
return { error: out.error ?? null, result: out.result };
|
|
181
|
+
},
|
|
182
|
+
// Two executes on the same engine running concurrently must not
|
|
183
|
+
// interfere — separate workers, separate logs, correct results.
|
|
184
|
+
async concurrent() {
|
|
185
|
+
const engine = makeEngine(64);
|
|
186
|
+
const [a, b] = await Promise.all([
|
|
187
|
+
engine.execute(`console.log("run-a"); const r = await files.slow({ ms: 50 }); return { tag: "a", waited: r.waited };`),
|
|
188
|
+
engine.execute(`console.log("run-b"); const r = await files.slow({ ms: 30 }); return { tag: "b", waited: r.waited };`),
|
|
189
|
+
]);
|
|
190
|
+
return {
|
|
191
|
+
aError: a.error ?? null,
|
|
192
|
+
bError: b.error ?? null,
|
|
193
|
+
aResult: a.result,
|
|
194
|
+
bResult: b.result,
|
|
195
|
+
aLogs: a.logs,
|
|
196
|
+
bLogs: b.logs,
|
|
197
|
+
};
|
|
198
|
+
},
|
|
199
|
+
// Agent code killing its own worker (it has the full runtime, so it can)
|
|
200
|
+
// must fail soft with a descriptive error and leave the engine usable.
|
|
201
|
+
async "worker-suicide"() {
|
|
202
|
+
const engine = makeEngine(64);
|
|
203
|
+
const out = await engine.execute(`process.exit(7);`);
|
|
204
|
+
const after = await engine.execute("return 1 + 1");
|
|
205
|
+
return {
|
|
206
|
+
error: out.error ?? null,
|
|
207
|
+
afterError: after.error ?? null,
|
|
208
|
+
afterResult: after.result,
|
|
209
|
+
};
|
|
210
|
+
},
|
|
211
|
+
// Sandbox code that genuinely exhausts the memory limit must fail soft:
|
|
212
|
+
// a clean error returned from execute(), no process abort, and the engine
|
|
213
|
+
// must remain usable for a subsequent run.
|
|
214
|
+
async "sandbox-oom"() {
|
|
215
|
+
const engine = makeEngine(32);
|
|
216
|
+
const out = await engine.execute(`
|
|
217
|
+
const hog = [];
|
|
218
|
+
while (true) hog.push(new Array(1e6).fill(1));
|
|
219
|
+
`);
|
|
220
|
+
const after = await engine.execute("return 1 + 1");
|
|
221
|
+
return {
|
|
222
|
+
error: out.error ?? null,
|
|
223
|
+
afterError: after.error ?? null,
|
|
224
|
+
afterResult: after.result,
|
|
225
|
+
};
|
|
226
|
+
},
|
|
227
|
+
};
|
|
228
|
+
const name = process.argv[2];
|
|
229
|
+
const scenario = scenarios[name];
|
|
230
|
+
if (!scenario) {
|
|
231
|
+
console.error(`Unknown scenario: ${name}`);
|
|
232
|
+
process.exit(2);
|
|
233
|
+
}
|
|
234
|
+
const report = await scenario();
|
|
235
|
+
console.log(JSON.stringify(report));
|
|
236
|
+
process.exit(0);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "runline",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.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",
|
|
@@ -75,7 +75,6 @@
|
|
|
75
75
|
"commander": "^14.0.3",
|
|
76
76
|
"jiti": "^2.7.0",
|
|
77
77
|
"proper-lockfile": "^4.1.2",
|
|
78
|
-
"quickjs-emscripten": "^0.32.0",
|
|
79
78
|
"rrule": "^2.8.1",
|
|
80
79
|
"typebox": "^1.1.35"
|
|
81
80
|
}
|