@signalridge/pi-workflows 1.7.4 → 1.8.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/src/journal.ts CHANGED
@@ -537,6 +537,8 @@ function parseTerminalResult(raw: unknown, runId: string): RecoveryTerminalResul
537
537
  }
538
538
 
539
539
  function parseRunRecovery(raw: Record<string, unknown>, run: ScriptRun, timestamp: number): RunRecoveryEvent {
540
+ if (run.status === "completed" || run.nonResumable || run.terminalIntent === "stop")
541
+ throw new Error("cannot recover a completed or non-resumable workflow run");
540
542
  if (raw.status !== "interrupted") throw new Error("run recovery status is invalid");
541
543
  if (typeof raw.branchGeneration !== "number" || !Number.isInteger(raw.branchGeneration) || raw.branchGeneration < 0) {
542
544
  throw new Error("run recovery branchGeneration is invalid");
@@ -1140,6 +1142,8 @@ export function applyRecoveryEvent(run: ScriptRun, event: RunRecoveryEvent | Att
1140
1142
  if (event.kind === "run_recovery") {
1141
1143
  run.status = "interrupted";
1142
1144
  run.terminalIntent = undefined;
1145
+ run.error = undefined;
1146
+ run.finalResult = undefined;
1143
1147
  for (const rotation of event.rotations) applyRecoveryRotation(run, rotation);
1144
1148
  applyRecoveryTerminalResults(run, event.terminalResults ?? []);
1145
1149
  } else {
@@ -1218,6 +1222,7 @@ function applyJournalEvent(runs: Map<string, ScriptRun>, event: JournalEvent): v
1218
1222
  break;
1219
1223
  case "workflow_transition":
1220
1224
  run.status = event.status;
1225
+ if (event.status === "running") run.error = undefined;
1221
1226
  if (event.error !== undefined) run.error = event.error;
1222
1227
  if (event.finalResult !== undefined) run.finalResult = structuredClone(event.finalResult);
1223
1228
  if (event.terminalIntent !== undefined) run.terminalIntent = event.terminalIntent;
package/src/runtime.ts CHANGED
@@ -3,7 +3,8 @@
3
3
  *
4
4
  * A workflow is a raw JavaScript module. The runtime parses it under an acorn
5
5
  * gate (deterministic literal `export const meta` first, no nondeterministic
6
- * builtins), then executes the body inside a `node:vm` realm with a bounded set
6
+ * builtins), then executes the body/callbacks in an owned Node helper's vm realm
7
+ * with validated IPC proxies to the parent-owned bounded set
7
8
  * of orchestration globals (`agent`, `parallel`, `pipeline`, `workflow`,
8
9
  * quality/control helpers, `phase`, `log`, `args`, `cwd`, restricted
9
10
  * `process`, `budget`). The vm is a determinism guardrail, not a security
@@ -20,7 +21,6 @@
20
21
 
21
22
  import { AsyncLocalStorage } from "node:async_hooks";
22
23
  import { createHash } from "node:crypto";
23
- import vm from "node:vm";
24
24
  import type { ManagedRoutingPolicy } from "@signalridge/pi-subagents-protocol";
25
25
  import { agentTierPolicyIdentity, isManagedAgentTier } from "@signalridge/pi-subagents-protocol";
26
26
  import { parse } from "acorn";
@@ -40,6 +40,7 @@ import {
40
40
  type WorkflowStrength,
41
41
  type WorkflowStrengthTable,
42
42
  } from "./strengths.js";
43
+ import { executeOwnedVm } from "./vm-helper.js";
43
44
 
44
45
  // ── Meta contract ────────────────────────────────────────────────────────────
45
46
 
@@ -895,7 +896,8 @@ export function normalizeMaxAgents(value: unknown): number {
895
896
  export function normalizeAgentTimeout(value: unknown): number | null {
896
897
  if (value === null) return null;
897
898
  if (value === undefined) return DEFAULT_AGENT_TIMEOUT_MS;
898
- if (typeof value !== "number" || !Number.isFinite(value) || value < 1) return DEFAULT_AGENT_TIMEOUT_MS;
899
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 1 || value > 2_147_483_647)
900
+ return DEFAULT_AGENT_TIMEOUT_MS;
899
901
  return Math.floor(value);
900
902
  }
901
903
 
@@ -1237,6 +1239,12 @@ export async function runWorkflow<T = unknown>(
1237
1239
  options: WorkflowRunOptions = {},
1238
1240
  ): Promise<WorkflowRunResult<T>> {
1239
1241
  const started = Date.now();
1242
+ // Each frame owns cancellation, even when a parent catches a nested failure.
1243
+ const frameController = new AbortController();
1244
+ options = {
1245
+ ...options,
1246
+ signal: options.signal ? AbortSignal.any([options.signal, frameController.signal]) : frameController.signal,
1247
+ };
1240
1248
  const { meta, body } = parseWorkflowScript(script);
1241
1249
  const maxAgents = normalizeMaxAgents(options.maxAgents ?? MAX_AGENTS_PER_RUN);
1242
1250
  const agentTimeoutMs = normalizeAgentTimeout(options.agentTimeoutMs);
@@ -1616,7 +1624,8 @@ export async function runWorkflow<T = unknown>(
1616
1624
  throwIfAborted();
1617
1625
  if (batch?.cancelled) throw agentLimitError();
1618
1626
 
1619
- const timeout = agentOptions.timeoutMs !== undefined ? agentOptions.timeoutMs : agentTimeoutMs;
1627
+ const timeout =
1628
+ agentOptions.timeoutMs !== undefined ? normalizeAgentTimeout(agentOptions.timeoutMs) : agentTimeoutMs;
1620
1629
  const retryAttempts = normalizeAgentRetries(agentOptions.retries ?? options.agentRetries ?? 0);
1621
1630
  const maxAttempts = retryAttempts + 1;
1622
1631
 
@@ -1709,7 +1718,10 @@ export async function runWorkflow<T = unknown>(
1709
1718
  agentType: agentOptions.agentType,
1710
1719
  isolation: resolvedIsolation,
1711
1720
  toolset: options.toolset ?? agentOptions.toolset,
1712
- excludeTools: options.excludeTools ?? agentOptions.excludeTools,
1721
+ excludeTools:
1722
+ options.excludeTools === undefined && agentOptions.excludeTools === undefined
1723
+ ? undefined
1724
+ : [...new Set([...(options.excludeTools ?? []), ...(agentOptions.excludeTools ?? [])])],
1713
1725
  runId,
1714
1726
  callIndex,
1715
1727
  callHash,
@@ -2220,15 +2232,20 @@ export async function runWorkflow<T = unknown>(
2220
2232
  if (code === WorkflowErrorCode.TOKEN_BUDGET_EXHAUSTED || code === WorkflowErrorCode.AGENT_LIMIT_EXCEEDED) break;
2221
2233
  throw error;
2222
2234
  }
2223
- const fresh = (Array.isArray(items) ? items : []).filter((x) => x != null && !seen.has(key(x)));
2235
+ const fresh: Array<{ item: unknown; key: string }> = [];
2236
+ for (const item of Array.isArray(items) ? items : []) {
2237
+ if (item == null) continue;
2238
+ const identity = await key(item);
2239
+ if (!seen.has(identity)) fresh.push({ item, key: identity });
2240
+ }
2224
2241
  if (!fresh.length) {
2225
2242
  dry++;
2226
2243
  continue;
2227
2244
  }
2228
2245
  dry = 0;
2229
- for (const x of fresh) {
2230
- seen.add(key(x));
2231
- all.push(x);
2246
+ for (const entry of fresh) {
2247
+ seen.add(entry.key);
2248
+ all.push(entry.item);
2232
2249
  }
2233
2250
  }
2234
2251
  return all;
@@ -2265,7 +2282,7 @@ export async function runWorkflow<T = unknown>(
2265
2282
  for (let i = 0; i < attempts; i++) {
2266
2283
  throwIfAborted();
2267
2284
  last = await thunk(i);
2268
- const accepted = !opts.until || opts.until(last);
2285
+ const accepted = !opts.until || (await opts.until(last));
2269
2286
  emitRuntimeEvent({ type: "control-attempt", helper: "retry", attempt: i + 1, accepted });
2270
2287
  if (accepted) return last;
2271
2288
  }
@@ -2397,18 +2414,20 @@ export async function runWorkflow<T = unknown>(
2397
2414
  );
2398
2415
  }
2399
2416
 
2400
- const context = vm.createContext({
2401
- ...runtimeBindings,
2402
- // Object/Array/JSON/Math/Date/Promise/Set/Map/etc. come from the vm realm
2403
- // itself — we deliberately do NOT inject host built-ins, whose
2404
- // .constructor would be the host Function (a determinism-guard bypass).
2405
- // Math/Date are neutered in-realm by DETERMINISM_PRELUDE below.
2406
- });
2407
-
2408
2417
  const wrapped = `${DETERMINISM_PRELUDE}\n(async () => {\n${body}\n})()`;
2409
2418
  try {
2410
- const result = await new vm.Script(wrapped, { filename: `${meta.name || "workflow"}.js` }).runInContext(context, {
2411
- timeout: MAX_SCRIPT_SYNC_MS,
2419
+ const result = await executeOwnedVm({
2420
+ script: wrapped,
2421
+ filename: `${meta.name || "workflow"}.js`,
2422
+ args: options.args,
2423
+ cwd: baseCwd,
2424
+ bindings: { ...runtimeBindings },
2425
+ total: budget.total,
2426
+ spent: budget.spent,
2427
+ cancel: () => frameController.abort(),
2428
+ signal: options.signal
2429
+ ? AbortSignal.any([options.signal, shared.runFatalController.signal])
2430
+ : shared.runFatalController.signal,
2412
2431
  });
2413
2432
  try {
2414
2433
  const serialized = JSON.stringify(result);
@@ -2439,6 +2458,7 @@ export async function runWorkflow<T = unknown>(
2439
2458
  // reaching here does NOT seal anything (the parent may still catch
2440
2459
  // workflow()'s rejection). Idempotent: aborting an already-aborted
2441
2460
  // controller is a no-op.
2461
+ frameController.abort();
2442
2462
  if (isTopLevelRun) shared.runFatalController.abort();
2443
2463
  throw error;
2444
2464
  } finally {
package/src/safe-text.ts CHANGED
@@ -1,42 +1,11 @@
1
- /**
2
- * Terminal-safety for untrusted text rendered by the `/workflows` navigator.
3
- *
4
- * Task results, task errors, and run errors originate in child agent sessions, so they are
5
- * attacker-influenced from this package's point of view. Duplicated rather than imported:
6
- * extensions must not depend on another extension's source (see docs/package-boundaries.md).
7
- */
1
+ import { sanitizeDisplayText as encodeDisplayText } from "@signalridge/pi-ui";
8
2
 
9
- /** Control, escape-introducing, and bidi-override code points a terminal would act on. */
10
- function isUnsafeTerminalCodePoint(codePoint: number): boolean {
11
- return (
12
- codePoint <= 0x1f ||
13
- (codePoint >= 0x7f && codePoint <= 0x9f) ||
14
- codePoint === 0x061c ||
15
- codePoint === 0x200e ||
16
- codePoint === 0x200f ||
17
- (codePoint >= 0x202a && codePoint <= 0x202e) ||
18
- (codePoint >= 0x2066 && codePoint <= 0x2069)
19
- );
20
- }
21
-
22
- /**
23
- * Replace every code point a terminal could interpret as control input with a space.
24
- * Neutralizing the introducer (ESC, and the 8-bit C1 forms such as `0x9b` CSI) is what makes
25
- * the remainder of a sequence inert payload text rather than a command.
26
- */
3
+ /** Display-only encoding. Never apply it to stored workflow values or journals. */
27
4
  export function sanitizeTerminalText(value: string): string {
28
- return [...value]
29
- .map((character) => (isUnsafeTerminalCodePoint(character.codePointAt(0) ?? 0) ? " " : character))
30
- .join("");
5
+ return encodeDisplayText(value);
31
6
  }
32
7
 
33
- /**
34
- * Single-line, bounded rendering of untrusted text.
35
- *
36
- * Sanitizing strictly before truncating is load-bearing: slicing first can cut through an
37
- * escape sequence and leave a dangling introducer that swallows whatever the terminal draws
38
- * next. Slicing by code point additionally avoids splitting a surrogate pair.
39
- */
8
+ /** Retained public helper: single-line, code-point-bounded display copy. */
40
9
  export function sanitizeDisplayText(value: string, maximumLength: number): string {
41
10
  if (maximumLength < 1) return "";
42
11
  const safe = sanitizeTerminalText(value).replace(/\s+/gu, " ").trim();
package/src/ui.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import type { ExtensionCommandContext, Theme } from "@earendil-works/pi-coding-agent";
2
2
  import { DynamicBorder } from "@earendil-works/pi-coding-agent";
3
- import { Container, type SelectItem, SelectList, Text } from "@earendil-works/pi-tui";
3
+ import { Key, matchesKey, type SelectItem, SelectList, Text } from "@earendil-works/pi-tui";
4
4
  import type { WorkflowEngine } from "./engine.js";
5
5
  import type { ScriptRun } from "./journal.js";
6
6
  import { sanitizeDisplayText } from "./safe-text.js";
@@ -62,6 +62,15 @@ export function formatWorkflowRunLabel(run: ScriptRun): string {
62
62
  return `${name} · ${run.status}${phases} · ${calls} calls · ${elapsed(Math.max(0, (isTerminal(run.status) ? run.updatedAt : Date.now()) - run.startedAt))}`;
63
63
  }
64
64
 
65
+ function resultText(value: unknown): string {
66
+ if (typeof value === "string") return value;
67
+ try {
68
+ return JSON.stringify(value) ?? "(no preview)";
69
+ } catch {
70
+ return "(unavailable preview)";
71
+ }
72
+ }
73
+
65
74
  export function formatWorkflowDetail(engine: WorkflowEngine, runId: string): string {
66
75
  const state = engine.getState(runId);
67
76
  if (!state) return "Workflow run is no longer available.";
@@ -89,7 +98,7 @@ export function formatWorkflowDetail(engine: WorkflowEngine, runId: string): str
89
98
  const parts = [` ${status.padEnd(11)} call ${sanitizeDisplayText(nodeId, MAX_ID_CHARS)}${agent}${compact}`];
90
99
  if (result?.error) parts.push(` error: ${sanitizeDisplayText(result.error, MAX_RESULT_CHARS)}`);
91
100
  if (result?.result !== undefined && result.status === "completed") {
92
- parts.push(` result: ${sanitizeDisplayText(String(result.result), MAX_RESULT_CHARS)}`);
101
+ parts.push(` result: ${sanitizeDisplayText(resultText(result.result), MAX_RESULT_CHARS)}`);
93
102
  }
94
103
  return parts.join("\n");
95
104
  });
@@ -145,12 +154,13 @@ async function selectList<T>(
145
154
  items: SelectItem[],
146
155
  renderExtra: (theme: Pick<Theme, "fg">) => string,
147
156
  ): Promise<T | undefined> {
148
- if (ctx.mode !== "tui") return undefined;
149
- return ctx.ui.custom<T | undefined>((_tui, theme, _keybindings, done) => {
150
- const container = new Container();
151
- container.addChild(new DynamicBorder((s: string) => theme.fg("borderAccent", s)));
152
- container.addChild(new Text(theme.fg("accent", theme.bold(title)), 1, 0));
153
- container.addChild(new Text(theme.fg("dim", renderExtra(theme)), 1, 0));
157
+ if (ctx.mode !== "tui" || !ctx.hasUI) return undefined;
158
+ return ctx.ui.custom<T | undefined>((tui, theme, _keybindings, done) => {
159
+ const border = new DynamicBorder((s: string) => theme.fg("borderAccent", s));
160
+ const heading = new Text(theme.fg("accent", theme.bold(title)), 1, 0);
161
+ const detail = new Text(theme.fg("dim", renderExtra(theme)), 1, 0);
162
+ let offset = 0;
163
+ let pageSize = 1;
154
164
  const list = new SelectList(items, Math.min(12, Math.max(1, items.length)), {
155
165
  selectedPrefix: (text) => theme.fg("accent", text),
156
166
  selectedText: (text) => theme.fg("accent", text),
@@ -160,13 +170,27 @@ async function selectList<T>(
160
170
  });
161
171
  list.onSelect = (item) => done(item.value as T);
162
172
  list.onCancel = () => done(undefined);
163
- container.addChild(list);
164
- container.addChild(new Text(theme.fg("dim", "↑↓ select · enter confirm · esc back"), 1, 0));
165
- container.addChild(new DynamicBorder((s: string) => theme.fg("borderAccent", s)));
173
+ const hint = new Text(theme.fg("dim", "↑↓ select · enter confirm · esc back · PgUp/PgDn details"), 1, 0);
166
174
  return {
167
- render: (width) => container.render(width),
168
- invalidate: () => container.invalidate(),
169
- handleInput: (data) => list.handleInput(data),
175
+ render: (width) => {
176
+ const top = [...border.render(width), ...heading.render(width).slice(0, 2)];
177
+ const bottom = [...list.render(width), ...hint.render(width), ...border.render(width)];
178
+ pageSize = Math.max(1, tui.terminal.rows - top.length - bottom.length - 1);
179
+ const lines = detail.render(width);
180
+ offset = Math.max(0, Math.min(offset, lines.length - pageSize));
181
+ return [...top, ...lines.slice(offset, offset + pageSize), ...bottom];
182
+ },
183
+ invalidate: () => {
184
+ heading.invalidate();
185
+ detail.invalidate();
186
+ list.invalidate();
187
+ },
188
+ handleInput: (data) => {
189
+ if (matchesKey(data, Key.pageDown)) offset += pageSize;
190
+ else if (matchesKey(data, Key.pageUp)) offset = Math.max(0, offset - pageSize);
191
+ else list.handleInput(data);
192
+ tui.requestRender();
193
+ },
170
194
  };
171
195
  });
172
196
  }
@@ -0,0 +1,218 @@
1
+ /** Plain JavaScript bootstrap: no loader, package imports or SDK in the helper. */
2
+ export const VM_BOOTSTRAP = `
3
+ 'use strict';
4
+ const vm = require('node:vm');
5
+ const { AsyncLocalStorage, createHook } = require('node:async_hooks');
6
+ const scope = new AsyncLocalStorage();
7
+ const contexts = new Map();
8
+ const localPromises = new Map();
9
+ let rootReturned = false;
10
+ // Only unresolved promises hold local continuation authority. Never retain the
11
+ // promise object itself (GC may destroy an abandoned promise), timers, or a
12
+ // historical async-id pool. Caps are separate from pending callback/RPC limits.
13
+ createHook({
14
+ init(id,type) {
15
+ const context = scope.getStore();
16
+ if (type !== 'PROMISE' || !context || context.released) return;
17
+ if (localPromises.size >= 65536) throw new Error('workflow local promise limit exceeded');
18
+ localPromises.set(id,context); context.promises.add(id);
19
+ },
20
+ promiseResolve: settlePromise,
21
+ destroy: settlePromise,
22
+ }).enable();
23
+ function settlePromise(id) {
24
+ const context = localPromises.get(id);
25
+ if (!context) return;
26
+ localPromises.delete(id); context.promises.delete(id);
27
+ releaseScope(context);
28
+ }
29
+ const callbacks = new Map();
30
+ const callbackIds = new WeakMap();
31
+ const waiting = new Map();
32
+ const scriptErrors = new WeakSet();
33
+ const nativeErrors = new WeakSet();
34
+ let realm;
35
+ function isScriptThrow(value) { return scriptErrors.has(value) || (!(value instanceof Error) && !nativeErrors.has(value)); }
36
+ function thrown(value) { return {value:encode(value),scriptError:isScriptThrow(value),errorText:String(value).slice(0,65536)}; }
37
+ function invalidResult(message) { const error = new Error(message); error.name='WorkflowError'; error.code='SCRIPT_VALIDATION_ERROR'; error.recoverable=false; return error; }
38
+ let sequence = 0, callbackSequence = 0, sent = 0, queuedBytes = 0, logBytes = 0;
39
+ let spent = 0, total = null;
40
+ const MAX_BYTES = 4 * 1024 * 1024;
41
+ function send(message) {
42
+ const bytes = Buffer.byteLength(JSON.stringify(message));
43
+ if (bytes > MAX_BYTES || (message.type !== 'heartbeat' && ++sent > 100000) || queuedBytes + bytes > 8 * MAX_BYTES)
44
+ throw new Error('workflow helper IPC/backpressure limit exceeded');
45
+ queuedBytes += bytes;
46
+ process.send(message, error => { queuedBytes -= bytes; if (error) process.exit(1); });
47
+ }
48
+ function encode(value) {
49
+ const seen = new Map(); let nodes = 0;
50
+ function visit(v, depth) {
51
+ if (++nodes > 100000 || depth > 64) throw new Error('workflow intermediate value exceeds IPC limits');
52
+ if (v === undefined) return ['u'];
53
+ if (v === null || typeof v === 'string' || typeof v === 'boolean') return ['v', v];
54
+ if (typeof v === 'number') return ['n', String(v)];
55
+ if (typeof v === 'bigint') return ['b', String(v)];
56
+ if (typeof v === 'function') {
57
+ let id = callbackIds.get(v);
58
+ if (!id) { if (callbacks.size >= 8192) throw new Error('workflow callback limit exceeded');
59
+ id = ++callbackSequence; callbackIds.set(v, id); callbacks.set(id, v); }
60
+ return ['f', id];
61
+ }
62
+ if (typeof v !== 'object') throw new Error('unsupported workflow intermediate value');
63
+ if (seen.has(v)) return ['r', seen.get(v)];
64
+ const id = seen.size; seen.set(v, id);
65
+ const tag = Object.prototype.toString.call(v);
66
+ if (tag === '[object Date]') return ['d', id, v.toISOString()];
67
+ if (tag === '[object RegExp]') return ['x', id, v.source, v.flags];
68
+ if (tag === '[object Map]') return ['m', id, [...v].map(([k, x]) => [visit(k, depth + 1), visit(x, depth + 1)])];
69
+ if (tag === '[object Set]') return ['s', id, [...v].map(x => visit(x, depth + 1))];
70
+ if (Array.isArray(v)) return ['a', id, Array.from({length:v.length},(_,i)=>Object.hasOwn(v,i) ? visit(v[i],depth+1) : ['h'])];
71
+ if (tag.endsWith('Error]')) return ['e', id, visit({name:String(v.name), message:String(v.message), code:v.code,
72
+ recoverable:v.recoverable, agentLabel:v.agentLabel, resetHint:v.resetHint}, depth + 1),isScriptThrow(v)];
73
+ return ['o', id, Object.keys(v).map(k => [k, visit(v[k], depth + 1)])];
74
+ }
75
+ return visit(value, 0);
76
+ }
77
+ function decode(wire) {
78
+ const refs = new Map();
79
+ function visit(w) {
80
+ const [tag, id, data, flags] = w;
81
+ if (tag === 'u') return undefined;
82
+ if (tag === 'v') return id;
83
+ if (tag === 'n') return Number(id);
84
+ if (tag === 'b') return BigInt(id);
85
+ if (tag === 'r') return refs.get(id);
86
+ if (tag === 'f') { const fn = callbacks.get(id); if (!fn) throw new Error('invalid returned workflow callback identity'); return fn; }
87
+ let value;
88
+ if (tag === 'a') value = new realm.Array();
89
+ else if (tag === 'm') value = new realm.Map();
90
+ else if (tag === 's') value = new realm.Set();
91
+ else if (tag === 'd') value = new realm.Date(data);
92
+ else if (tag === 'x') value = new realm.RegExp(data, flags);
93
+ else if (tag === 'e') value = new realm.Error();
94
+ else if (tag === 'o') value = new realm.Object();
95
+ else throw new Error('invalid parent workflow value');
96
+ refs.set(id, value);
97
+ if (tag === 'a') for (const x of data) { if (Array.isArray(x) && x.length === 1 && x[0] === 'h') value.length++; else value.push(visit(x)); }
98
+ if (tag === 'm') for (const [k,x] of data) value.set(visit(k),visit(x));
99
+ if (tag === 's') for (const x of data) value.add(visit(x));
100
+ if (tag === 'o') for (const [k,x] of data) Object.defineProperty(value,k,{value:visit(x),writable:true,enumerable:true,configurable:true});
101
+ if (tag === 'e') {
102
+ const fields = visit(data);
103
+ const ctor = Object.hasOwn(realm.errors,fields.name) ? realm.errors[fields.name] : realm.Error;
104
+ Object.setPrototypeOf(value, ctor.prototype);
105
+ Object.assign(value, fields);
106
+ if (flags) scriptErrors.add(value); else nativeErrors.add(value);
107
+ }
108
+ return value;
109
+ }
110
+ return visit(wire);
111
+ }
112
+ function retireScope(context) {
113
+ if (context.released) return;
114
+ context.released = true;
115
+ contexts.delete(context.id);
116
+ for (const id of context.promises) localPromises.delete(id);
117
+ context.promises.clear();
118
+ send({type:'scopeReleased',id:context.id});
119
+ }
120
+ function releaseScope(context) {
121
+ if (!context || !context.settled || context.pending || context.releasing || context.released) return;
122
+ if (!rootReturned && context.promises.size) return;
123
+ context.releasing = true;
124
+ // Promise settlement precedes its reactions. Let those reactions post their
125
+ // RPCs, then recheck real credits. A local unresolved await is credited for
126
+ // the entire running root, not guessed to have resumed after one I/O turn.
127
+ setImmediate(() => {
128
+ context.releasing = false;
129
+ if (context.pending || context.released || (!rootReturned && context.promises.size)) return;
130
+ retireScope(context);
131
+ });
132
+ }
133
+ function closeRootAdmission() {
134
+ rootReturned = true;
135
+ // At the root boundary only accepted native work (or its unsettled callback)
136
+ // carries descendant authority. Ignored, purely local promises cannot keep
137
+ // the helper alive or mint post-root admission. Release BEFORE root result.
138
+ for (const context of contexts.values()) {
139
+ if (context.settled && !context.pending) retireScope(context);
140
+ }
141
+ }
142
+ function rpc(name, args) {
143
+ if (waiting.size >= 8192) throw new Error('workflow IPC request limit exceeded');
144
+ const id = ++sequence;
145
+ const context = scope.getStore();
146
+ const promise = new Promise((resolve,reject) => {
147
+ if (context) context.pending++;
148
+ waiting.set(id,{resolve,reject,context});
149
+ try { send({type:'call',id,name,args:encode(args),scope:context?.id}); }
150
+ catch (error) {
151
+ waiting.delete(id);
152
+ if (context) { context.pending--; releaseScope(context); }
153
+ reject(error);
154
+ }
155
+ });
156
+ // Native helpers already own ignored-call dispositions. Observe the original
157
+ // RPC promise without changing what author await/catch sees (strict Node).
158
+ void promise.catch(() => {});
159
+ return promise;
160
+ }
161
+ function notify(name,args) {
162
+ const context = scope.getStore();
163
+ send({type:'notify',name,args:encode(args),scope:context?.id});
164
+ }
165
+ process.on('disconnect', () => process.exit(0));
166
+ process.on('message', message => {
167
+ if (message.type === 'response') {
168
+ spent = message.spent;
169
+ const waiter = waiting.get(message.id); if (!waiter) return;
170
+ waiting.delete(message.id);
171
+ try { const value = decode(message.value); if (!message.ok && message.scriptError && value && (typeof value === 'object' || typeof value === 'function')) scriptErrors.add(value); message.ok ? waiter.resolve(value) : waiter.reject(value); }
172
+ catch (error) { waiter.reject(error); }
173
+ finally {
174
+ if (waiter.context) { waiter.context.pending--; releaseScope(waiter.context); }
175
+ }
176
+ } else if (message.type === 'callback') {
177
+ const callback = callbacks.get(message.callback);
178
+ if (!callback) return process.exit(1);
179
+ if (contexts.size >= 16384) throw new Error('workflow live execution context limit exceeded');
180
+ const context = {id:message.id,pending:0,promises:new Set(),settled:false,releasing:false,released:false};
181
+ contexts.set(context.id,context);
182
+ scope.run(context, async () => {
183
+ try { send({type:'callbackResult',id:message.id,ok:true,value:encode(await callback(...decode(message.args)))}); }
184
+ catch (error) { send({type:'callbackResult',id:message.id,ok:false,...thrown(error)}); }
185
+ finally { context.settled = true; releaseScope(context); }
186
+ });
187
+ } else if (message.type === 'start') {
188
+ total = message.total; spent = message.spent;
189
+ const globals = {};
190
+ for (const name of message.names) globals[name] = (...args) => rpc(name,args);
191
+ globals.log = value => { const text = String(value); logBytes += Buffer.byteLength(text); if (text.length > 65536 || logBytes > 1024 * 1024) throw new Error('workflow log limit exceeded (64KiB per entry, 1MiB per frame)'); notify('log',[text]); };
192
+ globals.phase = (title,options) => {
193
+ if (typeof title !== 'string' || title.length > 4096) throw new TypeError('phase title must be a string of at most 4096 characters');
194
+ if (options !== undefined && (options === null || typeof options !== 'object' || Array.isArray(options))) throw new TypeError('phase options must be an object');
195
+ notify('phase',[title,options]);
196
+ };
197
+ globals.console = {log:globals.log,info:globals.log,warn:v=>globals.log('[warn] '+String(v)),error:v=>globals.log('[error] '+String(v))};
198
+ globals.cwd = message.cwd;
199
+ globals.process = Object.freeze({cwd:()=>message.cwd});
200
+ globals.budget = Object.freeze({total,spent:()=>spent,remaining:()=>total === null ? Infinity : Math.max(0,total-spent)});
201
+ const context = vm.createContext(globals);
202
+ realm = vm.runInContext('({Array,Object,Map,Set,Date,RegExp,Error,errors:{Error,TypeError,RangeError,SyntaxError,ReferenceError,URIError,EvalError,AggregateError}})',context);
203
+ globals.args = decode(message.args);
204
+ Promise.resolve().then(() => new vm.Script(message.script,{filename:message.filename}).runInContext(context,{timeout:1000}))
205
+ .then(result => {
206
+ try {
207
+ const serialized = JSON.stringify(result);
208
+ if (result !== undefined && serialized === undefined) throw new Error('workflow result is not JSON-serializable');
209
+ const value = encode(serialized === undefined ? undefined : JSON.parse(serialized));
210
+ closeRootAdmission();
211
+ send({type:'result',ok:true,value});
212
+ } catch (error) { throw invalidResult('workflow result is not JSON-serializable: '+error.message); }
213
+ }).catch(error => send({type:'result',ok:false,...thrown(error)}));
214
+ }
215
+ });
216
+ setInterval(() => send({type:'heartbeat'}),100).unref();
217
+ send({type:'ready'});
218
+ `;