@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.
package/src/ui.ts CHANGED
@@ -1,252 +1,54 @@
1
- import type { ExtensionCommandContext, Theme } from "@earendil-works/pi-coding-agent";
2
- import { DynamicBorder } from "@earendil-works/pi-coding-agent";
3
- import { Container, type SelectItem, SelectList, Text } from "@earendil-works/pi-tui";
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
4
2
  import type { WorkflowEngine } from "./engine.js";
5
- import type { ScriptRun } from "./journal.js";
6
3
  import { sanitizeDisplayText } from "./safe-text.js";
7
-
8
- /** Bounds, in code points, for untrusted strings rendered into the navigator. */
9
- const MAX_NAME_CHARS = 200;
10
- const MAX_ID_CHARS = 128;
11
- const MAX_RESULT_CHARS = 300;
12
- const MAX_RUN_ERROR_CHARS = 500;
13
- const MAX_DESCRIPTION_CHARS = 120;
14
-
15
- /**
16
- * Pure snapshot render layer shared by the navigator, the live widget, and any
17
- * inline render site. All child-agent output passes through sanitizeDisplayText
18
- * BEFORE truncation so a live escape introducer is never cut mid-sequence.
19
- */
20
- export function snapshotRunLine(run: ScriptRun): string {
21
- return formatWorkflowRunLabel(run);
22
- }
23
-
24
- /** Live-widget lines for all non-terminal runs (A10). */
25
- export function liveWidgetLines(
26
- runs: readonly ScriptRun[],
27
- mode: "compact" | "detailed" = "compact",
28
- maxAgentsShown = 6,
29
- ): string[] {
30
- const active = runs.filter((run) => !isTerminal(run.status));
31
- if (active.length === 0) return [];
32
- const boundedMax = Math.max(1, Math.min(32, Math.floor(maxAgentsShown)));
33
- const lines = ["Workflows"];
34
- for (const run of active.slice(0, boundedMax)) {
35
- lines.push(` ${snapshotRunLine(run)}`);
36
- if (mode === "detailed") {
37
- for (const [nodeId, status] of Object.entries(run.callStatus).slice(0, boundedMax)) {
38
- lines.push(` ${nodeId}: ${status}`);
39
- }
40
- }
41
- }
42
- return lines.slice(0, 1 + boundedMax * (mode === "detailed" ? boundedMax + 1 : 1));
43
- }
44
-
45
- export type RunStatus = ScriptRun["status"];
46
-
47
- function elapsed(ms: number): string {
48
- const seconds = Math.floor(ms / 1_000);
49
- if (seconds < 60) return `${seconds}s`;
50
- const minutes = Math.floor(seconds / 60);
51
- return `${minutes}m ${seconds % 60}s`;
52
- }
53
-
54
- function isTerminal(status: RunStatus): boolean {
55
- return status === "completed" || status === "failed" || status === "stopped";
56
- }
57
-
58
- export function formatWorkflowRunLabel(run: ScriptRun): string {
59
- const phases = run.meta.phases && run.meta.phases.length > 0 ? ` · phases ${run.meta.phases.length}` : "";
60
- const calls = Object.keys(run.callResults).length;
61
- const name = sanitizeDisplayText(run.meta.name, MAX_NAME_CHARS);
62
- return `${name} · ${run.status}${phases} · ${calls} calls · ${elapsed(Math.max(0, (isTerminal(run.status) ? run.updatedAt : Date.now()) - run.startedAt))}`;
63
- }
64
-
65
- export function formatWorkflowDetail(engine: WorkflowEngine, runId: string): string {
66
- const state = engine.getState(runId);
67
- if (!state) return "Workflow run is no longer available.";
68
- const run = state.run;
69
- const tokenValues = Object.values(run.callResults)
70
- .map((result) => result.tokenCount)
71
- .filter((value): value is number => value !== undefined);
72
- const tokenCount = tokenValues.length > 0 ? tokenValues.reduce((total, value) => total + value, 0) : undefined;
73
- const lines = [
74
- `${sanitizeDisplayText(run.meta.name, MAX_NAME_CHARS)} [${run.status}]`,
75
- `run: ${sanitizeDisplayText(run.runId, MAX_ID_CHARS)}`,
76
- `elapsed: ${elapsed(Math.max(0, (isTerminal(run.status) ? run.updatedAt : Date.now()) - run.startedAt))}`,
77
- "",
78
- ...(tokenCount === undefined ? [] : [`tokens: ${tokenCount}`]),
79
- ];
80
- for (const phase of run.meta.phases ?? []) {
81
- lines.push(`Phase: ${sanitizeDisplayText(phase.title, MAX_NAME_CHARS)}`);
82
- }
83
- const calls = Object.entries(run.callStatus)
84
- .sort(([a], [b]) => Number(a) - Number(b))
85
- .map(([nodeId, status]) => {
86
- const result = run.callResults[nodeId];
87
- const agent = run.agentIds[nodeId] ? ` agent=${sanitizeDisplayText(run.agentIds[nodeId], MAX_ID_CHARS)}` : "";
88
- const compact = run.compactions[nodeId] ? ` compactions=${run.compactions[nodeId]}` : "";
89
- const parts = [` ${status.padEnd(11)} call ${sanitizeDisplayText(nodeId, MAX_ID_CHARS)}${agent}${compact}`];
90
- if (result?.error) parts.push(` error: ${sanitizeDisplayText(result.error, MAX_RESULT_CHARS)}`);
91
- if (result?.result !== undefined && result.status === "completed") {
92
- parts.push(` result: ${sanitizeDisplayText(String(result.result), MAX_RESULT_CHARS)}`);
93
- }
94
- return parts.join("\n");
95
- });
96
- if (calls.length > 0) {
97
- lines.push("Calls:");
98
- lines.push(...calls);
99
- }
100
- lines.push("", "Live agent conversations remain available through pi-subagents FleetView.");
101
- if (run.error) lines.push(`Error: ${sanitizeDisplayText(run.error, MAX_RUN_ERROR_CHARS)}`);
102
- return lines.join("\n");
103
- }
104
-
105
- /** Actions the `/workflows` detail menu can offer, in display order. */
106
- export type WorkflowAction = "refresh" | "pause" | "resume" | "stop" | "back";
107
-
108
- export interface WorkflowActionItem {
109
- value: WorkflowAction;
110
- label: string;
111
- }
112
-
113
- const REFRESH: WorkflowActionItem = { value: "refresh", label: "Refresh" };
114
- const PAUSE: WorkflowActionItem = { value: "pause", label: "Pause" };
115
- const RESUME: WorkflowActionItem = { value: "resume", label: "Resume" };
116
- const STOP: WorkflowActionItem = { value: "stop", label: "Stop" };
117
- const BACK: WorkflowActionItem = { value: "back", label: "Back" };
118
-
119
- /**
120
- * Menu actions legal for a run in `status`. Exhaustive by construction.
121
- */
122
- export function workflowActionsFor(status: RunStatus): readonly WorkflowActionItem[] {
123
- switch (status) {
124
- case "pending":
125
- case "pausing":
126
- case "stopping":
127
- return [REFRESH, STOP, BACK];
128
- case "running":
129
- return [REFRESH, PAUSE, STOP, BACK];
130
- case "paused":
131
- case "interrupted":
132
- return [REFRESH, RESUME, STOP, BACK];
133
- case "completed":
134
- case "failed":
135
- case "stopped":
136
- return [REFRESH, BACK];
137
- }
138
- const unreachable: never = status;
139
- return unreachable;
140
- }
141
-
142
- async function selectList<T>(
143
- ctx: ExtensionCommandContext,
144
- title: string,
145
- items: SelectItem[],
146
- renderExtra: (theme: Pick<Theme, "fg">) => string,
147
- ): 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));
154
- const list = new SelectList(items, Math.min(12, Math.max(1, items.length)), {
155
- selectedPrefix: (text) => theme.fg("accent", text),
156
- selectedText: (text) => theme.fg("accent", text),
157
- description: (text) => theme.fg("muted", text),
158
- scrollInfo: (text) => theme.fg("dim", text),
159
- noMatch: (text) => theme.fg("warning", text),
160
- });
161
- list.onSelect = (item) => done(item.value as T);
162
- 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)));
166
- return {
167
- render: (width) => container.render(width),
168
- invalidate: () => container.invalidate(),
169
- handleInput: (data) => list.handleInput(data),
170
- };
171
- });
172
- }
173
-
174
- /** Interactive `/workflows` navigator. TUI-only by design. */
4
+ import { WorkflowCommandCenter, type WorkflowNavigatorOptions } from "./ui-command-center.js";
5
+
6
+ export type { WorkflowNavigatorOptions } from "./ui-command-center.js";
7
+ export {
8
+ formatWorkflowDetail,
9
+ formatWorkflowRunLabel,
10
+ liveWidgetLines,
11
+ type RunStatus,
12
+ snapshotRunLine,
13
+ type WorkflowAction,
14
+ type WorkflowActionItem,
15
+ workflowActionsFor,
16
+ } from "./ui-presentation.js";
17
+
18
+ /** Live, explicitly opened `/workflows` command center. No headless TUI mounts. */
175
19
  export async function showWorkflowNavigator(
176
- ctx: ExtensionCommandContext,
20
+ ctx: ExtensionContext,
177
21
  engineSource: WorkflowEngine | (() => WorkflowEngine),
22
+ options: WorkflowNavigatorOptions = {},
178
23
  ): Promise<void> {
179
- if (ctx.mode !== "tui") {
24
+ if (ctx.mode !== "tui" || !ctx.hasUI) {
180
25
  ctx.ui.notify("/workflows requires TUI mode; use workflow_control for machine-readable access.", "warning");
181
26
  return;
182
27
  }
183
- const resolveEngine = typeof engineSource === "function" ? engineSource : () => engineSource;
184
- let inactiveNotified = false;
185
- const currentEngine = (): WorkflowEngine | undefined => {
186
- try {
187
- return resolveEngine();
188
- } catch (error: unknown) {
189
- if (!inactiveNotified) {
190
- inactiveNotified = true;
191
- ctx.ui.notify(error instanceof Error ? error.message : String(error), "warning");
192
- }
193
- return undefined;
194
- }
195
- };
196
- while (true) {
197
- const engine = currentEngine();
198
- if (!engine) return;
199
- const states = engine
200
- .list()
201
- .map((summary) => engine.getRun(String(summary.runId)))
202
- .filter((run): run is ScriptRun => run !== undefined)
203
- .sort((a, b) => b.startedAt - a.startedAt);
204
- const selected = await selectList<string>(
205
- ctx,
206
- "Workflow runs",
207
- states.map((run) => ({
208
- value: run.runId,
209
- label: formatWorkflowRunLabel(run),
210
- description: run.error === undefined ? undefined : sanitizeDisplayText(run.error, MAX_DESCRIPTION_CHARS),
211
- })),
212
- (theme) =>
213
- states.length > 0
214
- ? theme.fg("dim", "Select a run to inspect.")
215
- : theme.fg("muted", "No workflow runs in this session."),
28
+ const source = typeof engineSource === "function" ? engineSource : () => engineSource;
29
+ let component: WorkflowCommandCenter | undefined;
30
+ try {
31
+ const sessionId = ctx.sessionManager.getSessionId();
32
+ await ctx.ui.custom<void>(
33
+ (tui, theme, keys, done) => {
34
+ component = new WorkflowCommandCenter(
35
+ tui,
36
+ theme,
37
+ keys,
38
+ source,
39
+ () => ctx.sessionManager.getSessionId() === sessionId,
40
+ done,
41
+ (message) => ctx.ui.notify(message, "error"),
42
+ options,
43
+ );
44
+ return component;
45
+ },
46
+ { overlay: true, overlayOptions: { anchor: "center", width: "100%", maxHeight: "100%", margin: 0 } },
216
47
  );
217
- if (!selected) return;
218
- while (true) {
219
- const latest = currentEngine();
220
- if (!latest) return;
221
- const run = latest.getRun(selected);
222
- if (!run) break;
223
- const name = sanitizeDisplayText(run.meta.name, MAX_NAME_CHARS);
224
- const action = await selectList<WorkflowAction>(
225
- ctx,
226
- `${name} · ${run.status}`,
227
- workflowActionsFor(run.status).map((item) => ({
228
- value: item.value,
229
- label: item.label,
230
- })),
231
- () => {
232
- const current = currentEngine();
233
- return current ? formatWorkflowDetail(current, selected) : "Workflow surface is no longer active.";
234
- },
235
- );
236
- if (action === undefined || action === "back") break;
237
- if (action === "refresh") continue;
238
- if (action === "stop") {
239
- const confirmed = await ctx.ui.confirm("Stop workflow", `Stop ${name}? The run will be non-resumable.`);
240
- if (!confirmed) continue;
241
- }
242
- try {
243
- const current = currentEngine();
244
- if (!current) return;
245
- await current.control(action, selected);
246
- ctx.ui.notify(`Workflow ${action} requested.`, "info");
247
- } catch (error: unknown) {
248
- ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
249
- }
250
- }
48
+ } catch (error: unknown) {
49
+ ctx.ui.notify(sanitizeDisplayText(error instanceof Error ? error.message : String(error), 500), "error");
50
+ } finally {
51
+ // Also covers external cancellation or custom() rejection before TUI disposal.
52
+ component?.dispose();
251
53
  }
252
54
  }
@@ -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
+ `;