@signalridge/pi-workflows 1.7.5 → 1.9.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.
@@ -0,0 +1,502 @@
1
+ import { AsyncResource } from "node:async_hooks";
2
+ import { spawn } from "node:child_process";
3
+ import { basename } from "node:path";
4
+ import { performance } from "node:perf_hooks";
5
+ import { markScriptThrow, scriptThrowText, WorkflowError, WorkflowErrorCode } from "./errors.js";
6
+ import { VM_BOOTSTRAP } from "./vm-bootstrap.js";
7
+
8
+ const MAX_BYTES = 4 * 1024 * 1024;
9
+ const MAX_PENDING = 8192;
10
+ const MAX_CONTEXTS = 2 * MAX_PENDING;
11
+ const fail = (message: string) =>
12
+ new WorkflowError(message, WorkflowErrorCode.SCRIPT_VALIDATION_ERROR, { recoverable: false });
13
+ type Wire = unknown[];
14
+ type Callback = (...args: unknown[]) => Promise<unknown>;
15
+
16
+ /** Encode intermediate JS data, not just the final (JSON-only) workflow result. */
17
+ function encode(value: unknown, callbackId?: (fn: object) => number | undefined): Wire {
18
+ const seen = new Map<object, number>();
19
+ let nodes = 0;
20
+ const visit = (v: unknown, depth: number): Wire => {
21
+ if (++nodes > 100000 || depth > 64) throw fail("workflow intermediate value exceeds IPC limits");
22
+ if (v === undefined) return ["u"];
23
+ if (v === null || typeof v === "string" || typeof v === "boolean") return ["v", v];
24
+ if (typeof v === "number") return ["n", String(v)];
25
+ if (typeof v === "bigint") return ["b", String(v)];
26
+ if (typeof v === "function") {
27
+ const id = callbackId?.(v);
28
+ if (id !== undefined) return ["f", id];
29
+ throw fail("unsupported native workflow function");
30
+ }
31
+ if (typeof v !== "object") throw fail("unsupported native workflow value");
32
+ if (seen.has(v)) return ["r", seen.get(v)];
33
+ const id = seen.size;
34
+ seen.set(v, id);
35
+ if (v instanceof Date) return ["d", id, v.toISOString()];
36
+ if (v instanceof RegExp) return ["x", id, v.source, v.flags];
37
+ if (v instanceof Map) return ["m", id, [...v].map(([k, x]) => [visit(k, depth + 1), visit(x, depth + 1)])];
38
+ if (v instanceof Set) return ["s", id, [...v].map((x) => visit(x, depth + 1))];
39
+ if (Array.isArray(v))
40
+ return [
41
+ "a",
42
+ id,
43
+ Array.from({ length: v.length }, (_, index) => (Object.hasOwn(v, index) ? visit(v[index], depth + 1) : ["h"])),
44
+ ];
45
+ if (v instanceof Error) {
46
+ const classified = v as Error & Partial<WorkflowError>;
47
+ return [
48
+ "e",
49
+ id,
50
+ visit(
51
+ {
52
+ name: v.name,
53
+ message: v.message,
54
+ code: classified.code,
55
+ recoverable: classified.recoverable,
56
+ agentLabel: classified.agentLabel,
57
+ resetHint: classified.resetHint,
58
+ },
59
+ depth + 1,
60
+ ),
61
+ scriptThrowText(v) !== undefined,
62
+ ];
63
+ }
64
+ return ["o", id, Object.entries(v).map(([k, x]) => [k, visit(x, depth + 1)])];
65
+ };
66
+ return visit(value, 0);
67
+ }
68
+
69
+ /** Validate every helper value before it can reach native runtime bindings. */
70
+ function decode(wire: unknown, callback: (id: number) => Callback): unknown {
71
+ const refs = new Map<number, unknown>();
72
+ let nodes = 0;
73
+ const visit = (w: unknown, depth: number): unknown => {
74
+ if (++nodes > 100000 || depth > 64 || !Array.isArray(w)) throw fail("invalid workflow IPC value");
75
+ const [tag, id, data, flags] = w;
76
+ if (tag === "u" && w.length === 1) return undefined;
77
+ if (tag === "v" && w.length === 2 && (id === null || typeof id === "string" || typeof id === "boolean")) return id;
78
+ if (
79
+ tag === "n" &&
80
+ typeof id === "string" &&
81
+ /^(?:NaN|[-+]?Infinity|[-+]?(?:\d+\.?\d*|\.\d+)(?:e[-+]?\d+)?)$/i.test(id)
82
+ )
83
+ return Number(id);
84
+ if (tag === "b" && typeof id === "string" && /^-?\d{1,10000}$/.test(id)) return BigInt(id);
85
+ if (!Number.isSafeInteger(id) || id < 0 || id > 100000) throw fail("invalid workflow IPC identity");
86
+ if (tag === "f" && id > 0 && id <= MAX_PENDING) return callback(id);
87
+ if (tag === "r" && refs.has(id)) return refs.get(id);
88
+ if (refs.has(id)) throw fail("duplicate workflow IPC identity");
89
+ let value: unknown;
90
+ if (tag === "a") value = [];
91
+ else if (tag === "m") value = new Map();
92
+ else if (tag === "s") value = new Set();
93
+ else if (tag === "d" && typeof data === "string") value = new Date(data);
94
+ else if (tag === "x" && typeof data === "string" && typeof flags === "string") value = new RegExp(data, flags);
95
+ else if (tag === "e") value = new Error();
96
+ else if (tag === "o") value = {};
97
+ else throw fail("invalid workflow IPC tag");
98
+ refs.set(id, value);
99
+ if (["a", "m", "s", "o"].includes(tag) && !Array.isArray(data)) throw fail("invalid workflow IPC collection");
100
+ if (tag === "a")
101
+ for (const x of data) {
102
+ if (Array.isArray(x) && x.length === 1 && x[0] === "h") (value as unknown[]).length++;
103
+ else (value as unknown[]).push(visit(x, depth + 1));
104
+ }
105
+ if (tag === "s") for (const x of data) (value as Set<unknown>).add(visit(x, depth + 1));
106
+ if (tag === "m" || tag === "o")
107
+ for (const pair of data) {
108
+ if (!Array.isArray(pair) || pair.length !== 2) throw fail("invalid workflow IPC entry");
109
+ if (tag === "m") (value as Map<unknown, unknown>).set(visit(pair[0], depth + 1), visit(pair[1], depth + 1));
110
+ else {
111
+ if (typeof pair[0] !== "string") throw fail("invalid workflow IPC key");
112
+ Object.defineProperty(value, pair[0], {
113
+ value: visit(pair[1], depth + 1),
114
+ enumerable: true,
115
+ writable: true,
116
+ configurable: true,
117
+ });
118
+ }
119
+ }
120
+ if (tag === "e") {
121
+ const fields = visit(data, depth + 1) as Partial<WorkflowError>;
122
+ if (
123
+ !fields ||
124
+ typeof fields.message !== "string" ||
125
+ typeof fields.name !== "string" ||
126
+ typeof flags !== "boolean"
127
+ )
128
+ throw fail("invalid workflow IPC error");
129
+ if (!flags && fields.code && Object.values(WorkflowErrorCode).includes(fields.code)) {
130
+ value = new WorkflowError(fields.message, fields.code, {
131
+ recoverable: fields.recoverable === true,
132
+ agentLabel: typeof fields.agentLabel === "string" ? fields.agentLabel : undefined,
133
+ resetHint: typeof fields.resetHint === "string" ? fields.resetHint : undefined,
134
+ });
135
+ } else {
136
+ const constructors: Record<string, { prototype: Error }> = {
137
+ Error,
138
+ TypeError,
139
+ RangeError,
140
+ SyntaxError,
141
+ ReferenceError,
142
+ URIError,
143
+ EvalError,
144
+ AggregateError,
145
+ };
146
+ Object.setPrototypeOf(
147
+ value,
148
+ (Object.hasOwn(constructors, fields.name) ? constructors[fields.name] : Error).prototype,
149
+ );
150
+ Object.assign(value as Error, fields);
151
+ }
152
+ if (flags) markScriptThrow(value, `${fields.name}: ${fields.message}`);
153
+ refs.set(id, value);
154
+ }
155
+ return value;
156
+ };
157
+ return visit(wire, 0);
158
+ }
159
+
160
+ interface VmOptions {
161
+ script: string;
162
+ filename: string;
163
+ args: unknown;
164
+ cwd: string;
165
+ bindings: Record<string, unknown>;
166
+ total: number | null;
167
+ spent: () => number;
168
+ signal: AbortSignal;
169
+ cancel: () => void;
170
+ }
171
+
172
+ /** Own the actual child handle through close. Never signal a restored/historical PID. */
173
+ export async function executeOwnedVm(options: VmOptions): Promise<unknown> {
174
+ if (options.signal.aborted)
175
+ throw new WorkflowError("workflow aborted", WorkflowErrorCode.WORKFLOW_ABORTED, { recoverable: true });
176
+ const nodeHost =
177
+ !process.versions.bun && process.release.name === "node" && /^(?:node|node.exe)$/i.test(basename(process.execPath));
178
+ const executable = nodeHost ? process.execPath : "node";
179
+ const child = spawn(executable, ["--max-old-space-size=128", "-e", VM_BOOTSTRAP], {
180
+ stdio: ["ignore", "ignore", "pipe", "ipc"],
181
+ serialization: "json",
182
+ // Inherit NODE_OPTIONS unchanged, including --inspect-brk. The ready deadline
183
+ // makes a paused debugger actionable rather than silently hanging Pi.
184
+ env: process.env,
185
+ });
186
+ let closed = false;
187
+ const close = new Promise<void>((resolve) =>
188
+ child.once("close", () => {
189
+ closed = true;
190
+ resolve();
191
+ }),
192
+ );
193
+ let diagnostic = "";
194
+ child.stderr?.on("data", (chunk: Buffer) => {
195
+ diagnostic = (diagnostic + chunk.toString()).slice(-4096);
196
+ });
197
+ const callbacks = new Map<number, Callback>();
198
+ const callbackIds = new WeakMap<object, number>();
199
+ const toWire = (value: unknown) => encode(value, (fn) => callbackIds.get(fn));
200
+ const requests = new Map<number, number>();
201
+ const operations = new Set<Promise<void>>();
202
+ let returned = false;
203
+ let returnedValue: unknown;
204
+ let retainedBytes = 0;
205
+ // Callback responses settle independently of their execution context. A
206
+ // descendant RPC may still resume in that same ALS context after the callback
207
+ // has returned. The helper tracks unresolved local promises until the root
208
+ // returns, then retains only accepted native descendant credits. Native
209
+ // operations also hold a credit through response publication.
210
+ const scopes = new Map<number, { resource: AsyncResource; active: number; released: boolean }>();
211
+ const callbackResponses = new Map<number, { resolve: (value: unknown) => void; reject: (error: unknown) => void }>();
212
+ let sequence = 0,
213
+ messages = 0,
214
+ queuedBytes = 0;
215
+ let finished = false,
216
+ ready = false,
217
+ lastHeartbeat = performance.now();
218
+ let settle!: (value: unknown) => void;
219
+ let reject!: (error: unknown) => void;
220
+ const result = new Promise<unknown>((resolve, failResult) => {
221
+ settle = resolve;
222
+ reject = failResult;
223
+ });
224
+ const completeIfDrained = () => {
225
+ if (!finished && returned && operations.size === 0 && scopes.size === 0) {
226
+ finished = true;
227
+ settle(returnedValue);
228
+ }
229
+ };
230
+ const retireScope = (id: number) => {
231
+ const scope = scopes.get(id);
232
+ if (!scope?.released || scope.active !== 0) return;
233
+ scopes.delete(id);
234
+ scope.resource.emitDestroy();
235
+ completeIfDrained();
236
+ };
237
+ const stop = (error: unknown) => {
238
+ if (finished) return;
239
+ finished = true;
240
+ options.cancel();
241
+ for (const response of callbackResponses.values()) response.reject(error);
242
+ callbackResponses.clear();
243
+ for (const scope of scopes.values()) scope.resource.emitDestroy();
244
+ scopes.clear();
245
+ reject(error);
246
+ };
247
+ const send = (message: Record<string, unknown>) => {
248
+ if (finished || !child.connected) throw fail("workflow helper is no longer connected");
249
+ const bytes = Buffer.byteLength(JSON.stringify(message));
250
+ if (bytes > MAX_BYTES || queuedBytes + bytes > 8 * MAX_BYTES)
251
+ throw fail("workflow helper IPC/backpressure limit exceeded");
252
+ queuedBytes += bytes;
253
+ child.send(message, (error) => {
254
+ queuedBytes -= bytes;
255
+ if (error) stop(error);
256
+ });
257
+ };
258
+ const callback = (id: number): Callback => {
259
+ const existing = callbacks.get(id);
260
+ if (existing) return existing;
261
+ const proxy: Callback = (...args) =>
262
+ new Promise((resolve, rejectCallback) => {
263
+ if (callbackResponses.size >= MAX_PENDING)
264
+ return rejectCallback(fail("workflow callback concurrency limit exceeded"));
265
+ if (scopes.size >= MAX_CONTEXTS) return rejectCallback(fail("workflow live execution context limit exceeded"));
266
+ const callId = ++sequence;
267
+ const resource = new AsyncResource("workflow-vm-callback");
268
+ scopes.set(callId, { resource, active: 0, released: false });
269
+ callbackResponses.set(callId, { resolve, reject: rejectCallback });
270
+ try {
271
+ send({ type: "callback", id: callId, callback: id, args: toWire(args) });
272
+ } catch (error) {
273
+ callbackResponses.delete(callId);
274
+ scopes.delete(callId);
275
+ resource.emitDestroy();
276
+ rejectCallback(error);
277
+ }
278
+ });
279
+ callbacks.set(id, proxy);
280
+ callbackIds.set(proxy, id);
281
+ return proxy;
282
+ };
283
+ const markThrown = (message: Record<string, unknown>, value: unknown) => {
284
+ if (
285
+ typeof message.scriptError !== "boolean" ||
286
+ typeof message.errorText !== "string" ||
287
+ message.errorText.length > 65536
288
+ )
289
+ throw fail("invalid workflow exception provenance/text");
290
+ if (message.scriptError) markScriptThrow(value, message.errorText);
291
+ };
292
+ const onAbort = () =>
293
+ stop(new WorkflowError("workflow aborted", WorkflowErrorCode.WORKFLOW_ABORTED, { recoverable: true }));
294
+ options.signal.addEventListener("abort", onAbort, { once: true });
295
+ const startup = setTimeout(
296
+ () =>
297
+ stop(
298
+ fail(
299
+ "workflow Node helper was not ready within 5s; check Node on PATH and NODE_OPTIONS/debugger (--inspect-brk)",
300
+ ),
301
+ ),
302
+ 5000,
303
+ );
304
+ let lastWatchdogTick = performance.now();
305
+ let heartbeatProgress = 0;
306
+ let graceProgress = -1;
307
+ let lagCheck: ReturnType<typeof setImmediate> | undefined;
308
+ const checkHeartbeat = () => {
309
+ if (ready && performance.now() - lastHeartbeat > 1500)
310
+ stop(fail("workflow JavaScript CPU stall: helper heartbeat timed out"));
311
+ };
312
+ const watchdog = setInterval(() => {
313
+ const now = performance.now();
314
+ const parentLag = now - lastWatchdogTick;
315
+ lastWatchdogTick = now;
316
+ if (parentLag > 500 && graceProgress !== heartbeatProgress) {
317
+ // One I/O turn for queued heartbeats, never a fabricated heartbeat.
318
+ // Only actual IPC progress can renew this bounded lag exemption.
319
+ graceProgress = heartbeatProgress;
320
+ if (lagCheck === undefined)
321
+ lagCheck = setImmediate(() => {
322
+ lagCheck = undefined;
323
+ checkHeartbeat();
324
+ });
325
+ return;
326
+ }
327
+ checkHeartbeat();
328
+ }, 100);
329
+ child.once("error", (error) =>
330
+ stop(fail(`Cannot start workflow Node helper; install Node on PATH: ${error.message}`)),
331
+ );
332
+ child.once("close", (code, signal) => {
333
+ if (!finished) stop(fail(`workflow Node helper exited (${code ?? signal}): ${diagnostic}`));
334
+ });
335
+ child.on("message", (raw: unknown) => {
336
+ if (finished) return;
337
+ try {
338
+ if (Buffer.byteLength(JSON.stringify(raw)) > MAX_BYTES || !raw || typeof raw !== "object" || Array.isArray(raw))
339
+ throw fail("invalid/bounded workflow IPC message");
340
+ const message = raw as Record<string, unknown>;
341
+ if (message.type !== "heartbeat" && ++messages > 100000) throw fail("workflow IPC message limit exceeded");
342
+ if (message.type === "ready" && !ready) {
343
+ ready = true;
344
+ lastHeartbeat = performance.now();
345
+ clearTimeout(startup);
346
+ send({
347
+ type: "start",
348
+ script: options.script,
349
+ filename: options.filename,
350
+ args: toWire(options.args),
351
+ cwd: options.cwd,
352
+ names: Object.keys(options.bindings).filter(
353
+ (key) => typeof options.bindings[key] === "function" && !["log", "phase"].includes(key),
354
+ ),
355
+ total: options.total,
356
+ spent: options.spent(),
357
+ });
358
+ } else if (message.type === "heartbeat" && ready) {
359
+ lastHeartbeat = performance.now();
360
+ heartbeatProgress++;
361
+ } else if (message.type === "result" && ready && typeof message.ok === "boolean") {
362
+ const value = decode(message.value, callback);
363
+ if (!message.ok) {
364
+ markThrown(message, value);
365
+ stop(value);
366
+ return;
367
+ }
368
+ if (returned) throw fail("duplicate workflow frame result");
369
+ returned = true;
370
+ returnedValue = value;
371
+ // Keep the helper alive: accepted native operations may still require
372
+ // VM callbacks and scoped RPC (including nested workflow frames).
373
+ completeIfDrained();
374
+ } else if (
375
+ message.type === "callbackResult" &&
376
+ typeof message.id === "number" &&
377
+ typeof message.ok === "boolean"
378
+ ) {
379
+ const response = callbackResponses.get(message.id);
380
+ if (!response) throw fail("unknown workflow callback response");
381
+ const value = decode(message.value, callback);
382
+ if (!message.ok) markThrown(message, value);
383
+ callbackResponses.delete(message.id);
384
+ if (message.ok) response.resolve(value);
385
+ else response.reject(value);
386
+ } else if (message.type === "scopeReleased" && ready) {
387
+ if (!Number.isSafeInteger(message.id)) throw fail("invalid workflow callback scope identity");
388
+ const scope = scopes.get(message.id as number);
389
+ if (!scope || scope.released || callbackResponses.has(message.id as number))
390
+ throw fail("unknown/unsettled workflow callback scope release");
391
+ scope.released = true;
392
+ retireScope(message.id as number);
393
+ } else if ((message.type === "call" || message.type === "notify") && ready) {
394
+ const notify = message.type === "notify";
395
+ if (returned && message.scope === undefined) throw fail("workflow frame has closed native call admission");
396
+ const name = message.name;
397
+ if (
398
+ typeof name !== "string" ||
399
+ !Object.hasOwn(options.bindings, name) ||
400
+ typeof options.bindings[name] !== "function" ||
401
+ notify !== ["log", "phase"].includes(name)
402
+ )
403
+ throw fail("invalid workflow runtime binding request");
404
+ if (
405
+ !notify &&
406
+ (!Number.isSafeInteger(message.id) ||
407
+ (message.id as number) <= 0 ||
408
+ requests.has(message.id as number) ||
409
+ requests.size >= MAX_PENDING)
410
+ )
411
+ throw fail("invalid/bounded workflow request identity");
412
+ if (!notify) {
413
+ const bytes = Buffer.byteLength(JSON.stringify(message.args));
414
+ if (retainedBytes + bytes > 8 * MAX_BYTES) throw fail("workflow pending IPC memory limit exceeded");
415
+ retainedBytes += bytes;
416
+ requests.set(message.id as number, bytes);
417
+ }
418
+ const args = decode(message.args, callback);
419
+ if (!Array.isArray(args)) throw fail("invalid workflow request arguments");
420
+ const scope = message.scope === undefined ? undefined : scopes.get(message.scope as number);
421
+ if (message.scope !== undefined && (!Number.isSafeInteger(message.scope) || !scope || scope.released))
422
+ throw fail("retired workflow callback scope");
423
+ const resource = scope?.resource;
424
+ if (!notify && scope) scope.active++;
425
+ const invoke = () => (options.bindings[name] as (...args: unknown[]) => unknown)(...args);
426
+ let answer: unknown;
427
+ try {
428
+ answer = resource ? resource.runInAsyncScope(invoke) : invoke();
429
+ } catch (error) {
430
+ if (notify) throw error;
431
+ answer = Promise.reject(error);
432
+ }
433
+ if (!notify) {
434
+ const operation = Promise.resolve(answer)
435
+ .then(
436
+ (value) => {
437
+ if (!finished)
438
+ send({ type: "response", id: message.id, ok: true, value: toWire(value), spent: options.spent() });
439
+ },
440
+ (error) => {
441
+ if (!finished)
442
+ send({
443
+ type: "response",
444
+ id: message.id,
445
+ ok: false,
446
+ value: toWire(error),
447
+ spent: options.spent(),
448
+ scriptError: scriptThrowText(error) !== undefined,
449
+ errorText: scriptThrowText(error) ?? (error instanceof Error ? error.message : String(error)),
450
+ });
451
+ },
452
+ )
453
+ .catch(stop)
454
+ .finally(() => {
455
+ retainedBytes -= requests.get(message.id as number) ?? 0;
456
+ requests.delete(message.id as number);
457
+ operations.delete(operation);
458
+ if (scope) {
459
+ scope.active--;
460
+ retireScope(message.scope as number);
461
+ }
462
+ completeIfDrained();
463
+ });
464
+ operations.add(operation);
465
+ }
466
+ } else throw fail("invalid workflow helper message");
467
+ } catch (error) {
468
+ stop(error);
469
+ }
470
+ });
471
+ try {
472
+ return await result;
473
+ } finally {
474
+ clearTimeout(startup);
475
+ clearInterval(watchdog);
476
+ if (lagCheck !== undefined) clearImmediate(lagCheck);
477
+ options.signal.removeEventListener("abort", onAbort);
478
+ if (!closed) {
479
+ child.kill("SIGTERM");
480
+ const escalation = setTimeout(() => {
481
+ if (!closed) child.kill("SIGKILL");
482
+ }, 250);
483
+ try {
484
+ await Promise.race([
485
+ close,
486
+ new Promise<never>((_resolve, rejectClose) => {
487
+ const timer = setTimeout(
488
+ () => rejectClose(fail("workflow Node helper did not close after TERM/KILL")),
489
+ 1500,
490
+ );
491
+ void close.then(() => clearTimeout(timer));
492
+ }),
493
+ ]);
494
+ } finally {
495
+ clearTimeout(escalation);
496
+ }
497
+ }
498
+ // Failure cancelled the frame and rejected VM callback waits. Confirm all
499
+ // accepted native operations (including nested helpers) before returning.
500
+ while (operations.size > 0) await Promise.allSettled([...operations]);
501
+ }
502
+ }