@signalridge/pi-workflows 1.7.5 → 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/CHANGELOG.md +16 -0
- package/README.md +145 -10
- package/package.json +2 -1
- package/skills/workflow-authoring/SKILL.md +1 -1
- package/skills/workflow-authoring/references/capabilities.md +1 -1
- package/skills/workflow-authoring/references/capability-details.md +1 -1
- package/skills/workflow-patterns/SKILL.md +1 -1
- package/skills/workflow-review/SKILL.md +1 -1
- package/src/arming.ts +264 -3
- package/src/engine.ts +154 -14
- package/src/errors.ts +26 -0
- package/src/index.ts +376 -172
- package/src/journal.ts +5 -0
- package/src/runtime.ts +40 -20
- package/src/safe-text.ts +4 -35
- package/src/ui.ts +38 -14
- package/src/vm-bootstrap.ts +218 -0
- package/src/vm-helper.ts +502 -0
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
|
|
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
|
|
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 =
|
|
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:
|
|
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
|
|
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
|
|
2230
|
-
seen.add(key
|
|
2231
|
-
all.push(
|
|
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
|
|
2411
|
-
|
|
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
|
-
/**
|
|
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
|
|
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 {
|
|
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(
|
|
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>((
|
|
150
|
-
const
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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
|
-
|
|
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) =>
|
|
168
|
-
|
|
169
|
-
|
|
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
|
+
`;
|