pi-crew 0.7.5 → 0.7.6

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.
Files changed (51) hide show
  1. package/CHANGELOG.md +51 -0
  2. package/README.md +11 -11
  3. package/docs/commands-reference.md +14 -10
  4. package/docs/troubleshooting.md +131 -0
  5. package/docs/usage.md +9 -4
  6. package/package.json +1 -1
  7. package/src/config/config.ts +11 -4
  8. package/src/extension/action-suggestions.ts +71 -0
  9. package/src/extension/context-status-injection.ts +32 -1
  10. package/src/extension/register.ts +71 -65
  11. package/src/extension/team-tool/api.ts +3 -2
  12. package/src/extension/team-tool/cancel.ts +5 -4
  13. package/src/extension/team-tool/explain.ts +2 -1
  14. package/src/extension/team-tool/failure-patterns.ts +124 -0
  15. package/src/extension/team-tool/inspect.ts +10 -6
  16. package/src/extension/team-tool/lifecycle-actions.ts +5 -4
  17. package/src/extension/team-tool/respond.ts +4 -3
  18. package/src/extension/team-tool/run-not-found.ts +54 -0
  19. package/src/extension/team-tool/run.ts +26 -4
  20. package/src/extension/team-tool/status.ts +58 -4
  21. package/src/extension/team-tool.ts +5 -3
  22. package/src/runtime/async-runner.ts +7 -0
  23. package/src/runtime/background-runner.ts +7 -1
  24. package/src/runtime/chain-parser.ts +13 -5
  25. package/src/runtime/checkpoint.ts +13 -1
  26. package/src/runtime/child-pi.ts +9 -1
  27. package/src/runtime/live-session-runtime.ts +15 -1
  28. package/src/runtime/parent-guard.ts +2 -2
  29. package/src/runtime/stale-reconciler.ts +8 -3
  30. package/src/runtime/task-runner.ts +10 -1
  31. package/src/runtime/team-runner.ts +19 -2
  32. package/src/runtime/verification-gates.ts +21 -1
  33. package/src/schema/team-tool-schema.ts +9 -0
  34. package/src/state/blob-store.ts +12 -10
  35. package/src/state/event-log-rotation.ts +114 -93
  36. package/src/state/event-log.ts +79 -20
  37. package/src/state/health-store.ts +6 -1
  38. package/src/state/locks.ts +66 -16
  39. package/src/state/state-store.ts +14 -1
  40. package/src/ui/card-colors.ts +7 -3
  41. package/src/ui/dashboard-panes/agents-pane.ts +15 -2
  42. package/src/ui/live-duration.ts +58 -0
  43. package/src/ui/tool-render.ts +7 -11
  44. package/src/ui/tool-renderers/index.ts +6 -3
  45. package/src/ui/widget/widget-formatters.ts +2 -13
  46. package/src/utils/fs-watch.ts +11 -60
  47. package/src/utils/run-watcher-registry.ts +164 -0
  48. package/src/workflows/discover-workflows.ts +2 -1
  49. package/src/workflows/workflow-config.ts +5 -0
  50. package/src/runtime/dynamic-script-runner.ts +0 -497
  51. package/src/runtime/sandbox.ts +0 -335
@@ -0,0 +1,164 @@
1
+ /**
2
+ * RunWatcherRegistry — bounded per-run filesystem watcher registry.
3
+ *
4
+ * PROBLEM (pts/2 interactive-session hang, /home/bom/pts2-hang-investigation-2026-06-16.md):
5
+ * `watchCrewState` used `fs.watch(<crewRoot>/state, { recursive: true })`. On
6
+ * Linux, Node implements "recursive" by creating ONE inotify watch PER
7
+ * SUBDIRECTORY. With many historical runs under `.crew/state/runs/`, this
8
+ * ballooned to hundreds of watches (109→339 observed) — one per run dir ever —
9
+ * and the resulting event volume + render amplification produced a permanent
10
+ * busy-loop (71% CPU, 400KB/s read) even with no active work.
11
+ *
12
+ * FIX: instead of recursively watching the whole history, watch a SINGLE
13
+ * non-recursive watcher on the `runs/` root (to detect new run dirs appearing)
14
+ * PLUS one non-recursive watcher PER ACTIVE RUN. Total inotify cost is now
15
+ * O(active runs) — typically 1–5 — not O(total history). Completed runs stop
16
+ * being watched as soon as they leave the active set (reconciled by buildFrame,
17
+ * which reads manifest statuses each preload tick).
18
+ *
19
+ * The registry is intentionally small and directly unit-testable (a Map of
20
+ * watchers with add/remove/reconcile/close semantics).
21
+ */
22
+ import type { FSWatcher } from "node:fs";
23
+ import { closeWatcher, watchWithErrorHandler } from "./fs-watch.ts";
24
+
25
+ export interface ReconcileResult {
26
+ added: string[];
27
+ removed: string[];
28
+ }
29
+
30
+ export interface ActiveRun {
31
+ runId: string;
32
+ runDir: string;
33
+ }
34
+
35
+ export type RunChangeCallback = (runId: string) => void;
36
+ export type ErrorCallback = (error: unknown) => void;
37
+
38
+ export class RunWatcherRegistry {
39
+ private readonly runWatchers = new Map<string, FSWatcher>();
40
+ private rootWatcher: FSWatcher | undefined;
41
+ private closed = false;
42
+
43
+ /**
44
+ * Watch the `runs/` root directory (non-recursive) and invoke `onNewRun`
45
+ * whenever a new run subdirectory appears. This is the only way to detect a
46
+ * brand-new run, because `crew.run.created` is never emitted by the runtime
47
+ * (confirmed: only `crew.run.completed/failed/cancelled` are emitted).
48
+ */
49
+ setRootWatcher(
50
+ runsDir: string,
51
+ onNewRun: RunChangeCallback,
52
+ onError?: ErrorCallback,
53
+ ): void {
54
+ if (this.closed) return;
55
+ // Replace any prior root watcher.
56
+ closeWatcher(this.rootWatcher);
57
+ this.rootWatcher = watchWithErrorHandler(
58
+ runsDir,
59
+ (_eventType, filename) => {
60
+ if (typeof filename !== "string" || filename.length === 0) return;
61
+ // fs.watch reports directory entries as bare names (no slash on Linux).
62
+ // A new run dir appears as `runs/<runId>` → filename = "<runId>".
63
+ // Filter obviously-not-run-id noise (files, temp, etc.) defensively.
64
+ const candidate = filename.replace(/\\/g, "/").split("/")[0];
65
+ if (candidate.length === 0) return;
66
+ onNewRun(candidate);
67
+ },
68
+ (error) => {
69
+ if (onError) onError(error);
70
+ },
71
+ ) ?? undefined;
72
+ }
73
+
74
+ /**
75
+ * Add a NON-RECURSIVE watcher on a single run directory. Costs exactly ONE
76
+ * inotify watch. If a watcher for this runId already exists, close + replace.
77
+ * Returns true if a watcher is now active for this runId.
78
+ */
79
+ addRunWatcher(
80
+ runId: string,
81
+ runDir: string,
82
+ onChange: RunChangeCallback,
83
+ onError?: ErrorCallback,
84
+ ): boolean {
85
+ if (this.closed) return false;
86
+ const existing = this.runWatchers.get(runId);
87
+ if (existing) closeWatcher(existing);
88
+ const watcher = watchWithErrorHandler(
89
+ runDir,
90
+ () => onChange(runId),
91
+ (error) => {
92
+ if (onError) onError(error);
93
+ },
94
+ );
95
+ if (watcher) {
96
+ this.runWatchers.set(runId, watcher);
97
+ return true;
98
+ }
99
+ // watchWithErrorHandler returned null (fs.watch unsupported / dir missing).
100
+ // Remove any stale entry so hasWatcher() stays honest.
101
+ this.runWatchers.delete(runId);
102
+ return false;
103
+ }
104
+
105
+ /** Remove and close a specific run's watcher. No-op if not watched. */
106
+ removeRunWatcher(runId: string): void {
107
+ const watcher = this.runWatchers.get(runId);
108
+ if (watcher) {
109
+ closeWatcher(watcher);
110
+ this.runWatchers.delete(runId);
111
+ }
112
+ }
113
+
114
+ /** Is a run currently being watched? */
115
+ hasWatcher(runId: string): boolean {
116
+ return this.runWatchers.has(runId);
117
+ }
118
+
119
+ /**
120
+ * Reconcile against the current active-run set: add watchers for active runs
121
+ * not yet watched, remove watchers for runs that left the active set. Returns
122
+ * which runIds were added / removed (useful for logging + tests).
123
+ */
124
+ reconcile(
125
+ activeRuns: ActiveRun[],
126
+ onChange: RunChangeCallback,
127
+ onError?: ErrorCallback,
128
+ ): ReconcileResult {
129
+ if (this.closed) return { added: [], removed: [] };
130
+ const activeIds = new Set(activeRuns.map((r) => r.runId));
131
+ const added: string[] = [];
132
+ const removed: string[] = [];
133
+ // Remove watchers for runs no longer active.
134
+ for (const runId of [...this.runWatchers.keys()]) {
135
+ if (!activeIds.has(runId)) {
136
+ this.removeRunWatcher(runId);
137
+ removed.push(runId);
138
+ }
139
+ }
140
+ // Add watchers for newly-active runs.
141
+ for (const { runId, runDir } of activeRuns) {
142
+ if (!this.runWatchers.has(runId)) {
143
+ if (this.addRunWatcher(runId, runDir, onChange, onError)) {
144
+ added.push(runId);
145
+ }
146
+ }
147
+ }
148
+ return { added, removed };
149
+ }
150
+
151
+ /** Close ALL watchers (per-run + root). Safe to call multiple times. */
152
+ closeAll(): void {
153
+ this.closed = true;
154
+ for (const watcher of this.runWatchers.values()) closeWatcher(watcher);
155
+ this.runWatchers.clear();
156
+ closeWatcher(this.rootWatcher);
157
+ this.rootWatcher = undefined;
158
+ }
159
+
160
+ /** Number of active PER-RUN watchers (excludes the root watcher). */
161
+ get size(): number {
162
+ return this.runWatchers.size;
163
+ }
164
+ }
@@ -11,7 +11,7 @@ export interface WorkflowDiscoveryResult {
11
11
  project: WorkflowConfig[];
12
12
  }
13
13
 
14
- const STEP_CONFIG_KEYS = new Set(["role", "dependsOn", "parallelGroup", "output", "reads", "model", "skills", "progress", "worktree", "verify", "task", "seedPaths", "preStepScript", "preStepArgs", "preStepTimeout"]);
14
+ const STEP_CONFIG_KEYS = new Set(["role", "dependsOn", "parallelGroup", "output", "reads", "model", "skills", "progress", "worktree", "verify", "task", "seedPaths", "preStepScript", "preStepArgs", "preStepTimeout", "preStepOptional"]);
15
15
 
16
16
  function parseStepSection(id: string, body: string): WorkflowStep | undefined {
17
17
  const lines = body.trim().split("\n");
@@ -54,6 +54,7 @@ function parseStepSection(id: string, body: string): WorkflowStep | undefined {
54
54
  preStepScript: config.preStepScript || undefined,
55
55
  preStepArgs: parseCsv(config.preStepArgs) || undefined,
56
56
  preStepTimeout: parseOptionalInteger(config.preStepTimeout) ?? undefined,
57
+ preStepOptional: config.preStepOptional === "true" || config.preStepOptional === "1",
57
58
  };
58
59
  }
59
60
 
@@ -25,6 +25,11 @@ export interface WorkflowStep {
25
25
  preStepArgs?: string[];
26
26
  /** Timeout in ms for preStepScript. Default: 30000. */
27
27
  preStepTimeout?: number;
28
+ /** Round 21 (E4): if true, a failing preStepScript does NOT abort the task.
29
+ * The failure is logged as a warning and the task proceeds without the
30
+ * pre-step output. Use for advisory hooks (e.g. optional test runs) whose
31
+ * failure shouldn't block the workflow. Default: false (fail-fast). */
32
+ preStepOptional?: boolean;
28
33
  }
29
34
 
30
35
  export interface WorkflowConfig {
@@ -1,497 +0,0 @@
1
- import * as vm from "node:vm";
2
- import * as acorn from "acorn";
3
- import { WorkflowSandbox, type SandboxOptions } from "./sandbox.ts";
4
-
5
- /**
6
- * Forbidden globals that could compromise sandbox security or cause side effects.
7
- * These are checked during AST validation before execution.
8
- */
9
- export const FORBIDDEN_GLOBALS = [
10
- "Math.random",
11
- "require",
12
- "import",
13
- "module",
14
- "exports",
15
- "__dirname",
16
- "__filename",
17
- "process.exit",
18
- "process.kill",
19
- "process.hrtime",
20
- "process.memoryUsage",
21
- "process.cpuUsage",
22
- "process.binding",
23
- "process.dlopen",
24
- "process._tickCallback",
25
- "eval",
26
- "Function",
27
- "AsyncFunction",
28
- "GeneratorFunction",
29
- "Proxy",
30
- "Reflect",
31
- "WebAssembly",
32
- "global",
33
- "globalThis",
34
- "window",
35
- "document",
36
- "XMLHttpRequest",
37
- "fetch",
38
- "WebSocket",
39
- "Worker",
40
- "SharedArrayBuffer",
41
- "Atomics",
42
- ] as const;
43
-
44
- // Freeze the array at runtime to ensure it's truly immutable
45
- Object.freeze(FORBIDDEN_GLOBALS);
46
-
47
- export type ForbiddenGlobal = (typeof FORBIDDEN_GLOBALS)[number];
48
-
49
- export interface ScriptValidationResult {
50
- valid: boolean;
51
- errors: ScriptValidationError[];
52
- warnings: ScriptValidationWarning[];
53
- }
54
-
55
- export interface ScriptValidationError {
56
- type: "forbidden_global" | "forbidden_syntax" | "parse_error";
57
- message: string;
58
- location?: { line: number; column: number };
59
- }
60
-
61
- export interface ScriptValidationWarning {
62
- type: "deprecated_api" | "potentially_unsafe";
63
- message: string;
64
- location?: { line: number; column: number };
65
- }
66
-
67
- export interface DynamicScriptOptions {
68
- timeout?: number;
69
- maxTokens?: number;
70
- allowAwait?: boolean;
71
- allowAsync?: boolean;
72
- strictMode?: boolean;
73
- /** Enable strict AST whitelist mode (C2) - reject dynamic property access, call expressions */
74
- strictAstWhitelist?: boolean;
75
- }
76
-
77
- export interface ScriptExecutionResult {
78
- success: boolean;
79
- value?: unknown;
80
- error?: string;
81
- executionTime: number;
82
- validation: ScriptValidationResult;
83
- }
84
-
85
- /**
86
- * DynamicScriptRunner executes JavaScript in a VM sandbox with AST validation
87
- * and forbidden pattern detection.
88
- *
89
- * Note: AST parsing is simplified without acorn. For full AST validation,
90
- * add acorn as a dependency.
91
- */
92
- export class DynamicScriptRunner {
93
- private sandbox: WorkflowSandbox;
94
- private defaultTimeout: number;
95
- private options: DynamicScriptOptions;
96
-
97
- constructor(options: DynamicScriptOptions = {}) {
98
- this.defaultTimeout = options.timeout ?? 30000;
99
- this.options = options;
100
- this.sandbox = new WorkflowSandbox({
101
- timeout: this.defaultTimeout,
102
- });
103
- }
104
-
105
- /**
106
- * Validate script before execution using full AST parsing (C1).
107
- * Uses acorn for comprehensive syntax tree analysis.
108
- */
109
- validate(code: string): ScriptValidationResult {
110
- const errors: ScriptValidationError[] = [];
111
- const warnings: ScriptValidationWarning[] = [];
112
-
113
- // C1: Full AST parsing with acorn for complete validation
114
- let ast: acorn.Node | null = null;
115
- try {
116
- ast = acorn.parse(code, {
117
- ecmaVersion: "latest",
118
- sourceType: "script",
119
- allowReturnOutsideFunction: true,
120
- allowAwaitOutsideFunction: this.options.allowAwait ?? false,
121
- });
122
- } catch (parseError) {
123
- errors.push({
124
- type: "parse_error",
125
- message: `Parse error: ${parseError instanceof Error ? parseError.message : String(parseError)}`,
126
- });
127
- return { valid: false, errors, warnings };
128
- }
129
-
130
- // C2: Strict AST whitelist mode - reject dynamic property access and call expressions
131
- if (this.options.strictAstWhitelist) {
132
- this.validateAstWhitelist(ast, errors);
133
- }
134
-
135
- // C4: Bytecode compilation verification - verify script compiles correctly
136
- this.verifyCompilation(code, errors);
137
-
138
- // Check for forbidden globals using regex patterns (fallback)
139
- this.checkForForbiddenGlobals(code, errors);
140
-
141
- // Check for forbidden syntax patterns
142
- this.checkForForbiddenSyntax(code, errors, warnings);
143
-
144
- // Check for potentially unsafe patterns
145
- this.checkForPotentiallyUnsafePatterns(code, warnings);
146
-
147
- return {
148
- valid: errors.length === 0,
149
- errors,
150
- warnings,
151
- };
152
- }
153
-
154
- /**
155
- * C2: Validate AST against strict whitelist - reject dynamic property access,
156
- * call expressions, and other potentially dangerous patterns.
157
- */
158
- private validateAstWhitelist(
159
- ast: acorn.Node,
160
- errors: ScriptValidationError[],
161
- ): void {
162
- const walkNode = (node: acorn.Node): void => {
163
- const n = node as acorn.Node & { type: string };
164
-
165
- // Reject MemberExpression (e.g., obj.prop, obj[key])
166
- if (n.type === "MemberExpression") {
167
- errors.push({
168
- type: "forbidden_syntax",
169
- message: "Dynamic property access is not allowed in strict whitelist mode",
170
- });
171
- }
172
-
173
- // Reject CallExpression (e.g., fn(), obj.method())
174
- if (n.type === "CallExpression") {
175
- errors.push({
176
- type: "forbidden_syntax",
177
- message: "Call expressions are not allowed in strict whitelist mode",
178
- });
179
- }
180
-
181
- // Reject NewExpression (e.g., new Foo())
182
- if (n.type === "NewExpression") {
183
- errors.push({
184
- type: "forbidden_syntax",
185
- message: "Constructor calls are not allowed in strict whitelist mode",
186
- });
187
- }
188
-
189
- // Reject UpdateExpression (++a, a--, etc.)
190
- if (n.type === "UpdateExpression") {
191
- errors.push({
192
- type: "forbidden_syntax",
193
- message: "Update expressions are not allowed in strict whitelist mode",
194
- });
195
- }
196
-
197
- // Reject AssignmentExpression (a = b, a += b, etc.)
198
- if (n.type === "AssignmentExpression") {
199
- errors.push({
200
- type: "forbidden_syntax",
201
- message: "Assignment expressions are not allowed in strict whitelist mode",
202
- });
203
- }
204
-
205
- // Reject ForInStatement and ForOfStatement
206
- if (n.type === "ForInStatement" || n.type === "ForOfStatement") {
207
- errors.push({
208
- type: "forbidden_syntax",
209
- message: "For-in/for-of loops are not allowed in strict whitelist mode",
210
- });
211
- }
212
-
213
- // Recurse into children
214
- for (const key of Object.keys(n as object)) {
215
- const value = (n as unknown as Record<string, unknown>)[key];
216
- if (value && typeof value === "object") {
217
- if (Array.isArray(value)) {
218
- for (const child of value) {
219
- if (child && typeof child === "object" && "type" in child) {
220
- walkNode(child as acorn.Node);
221
- }
222
- }
223
- } else if ("type" in value) {
224
- walkNode(value as acorn.Node);
225
- }
226
- }
227
- }
228
- };
229
-
230
- // Cast to access body property (Program node)
231
- const programNode = ast as unknown as { body: acorn.Node[] };
232
- for (const node of programNode.body) {
233
- walkNode(node);
234
- }
235
- }
236
-
237
- /**
238
- * C4: Verify script compiles to bytecode correctly. If compilation fails,
239
- * the script cannot be executed safely.
240
- */
241
- private verifyCompilation(code: string, errors: ScriptValidationError[]): void {
242
- try {
243
- const wrappedCode = `(function(){ ${code} })()`;
244
- new vm.Script(wrappedCode, {
245
- filename: "compile-check.js",
246
- });
247
- } catch (compileError) {
248
- errors.push({
249
- type: "parse_error",
250
- message: `Compilation verification failed: ${compileError instanceof Error ? compileError.message : String(compileError)}`,
251
- });
252
- }
253
- }
254
-
255
- private checkForForbiddenGlobals(code: string, errors: ScriptValidationError[]): void {
256
- // Check each forbidden global pattern
257
- for (const forbidden of FORBIDDEN_GLOBALS) {
258
- if (forbidden.includes(".")) {
259
- // Check for member expressions like Math.random, process.exit
260
- const [obj, prop] = forbidden.split(".");
261
- const pattern = new RegExp(`\\b${obj}\\s*\\.\\s*${prop}\\b`);
262
- if (pattern.test(code)) {
263
- errors.push({
264
- type: "forbidden_global",
265
- message: `Forbidden global access: '${forbidden}'`,
266
- });
267
- }
268
- } else {
269
- // Check for simple identifiers
270
- // But avoid false positives like "myDate" matching "Date"
271
- const pattern = new RegExp(`\\b${forbidden}\\b`);
272
- if (pattern.test(code)) {
273
- errors.push({
274
- type: "forbidden_global",
275
- message: `Forbidden global: '${forbidden}'`,
276
- });
277
- }
278
- }
279
- }
280
- }
281
-
282
- private checkForForbiddenSyntax(
283
- code: string,
284
- errors: ScriptValidationError[],
285
- warnings: ScriptValidationWarning[],
286
- ): void {
287
- // Check for eval()
288
- if (/\beval\s*\(/.test(code)) {
289
- errors.push({
290
- type: "forbidden_syntax",
291
- message: "eval() is not allowed",
292
- });
293
- }
294
-
295
- // Check for Function constructor
296
- if (/\bnew\s+Function\s*\(/.test(code) || /\bFunction\s*\(\s*['"`]/.test(code)) {
297
- errors.push({
298
- type: "forbidden_syntax",
299
- message: "Function constructor is not allowed",
300
- });
301
- }
302
-
303
- // Check for AsyncFunction constructor
304
- if (/\bnew\s+AsyncFunction\s*\(/.test(code) || /\bAsyncFunction\s*\(\s*['"`]/.test(code)) {
305
- errors.push({
306
- type: "forbidden_syntax",
307
- message: "AsyncFunction constructor is not allowed",
308
- });
309
- }
310
-
311
- // Check for GeneratorFunction constructor
312
- if (/\bnew\s+GeneratorFunction\s*\(/.test(code)) {
313
- errors.push({
314
- type: "forbidden_syntax",
315
- message: "GeneratorFunction constructor is not allowed",
316
- });
317
- }
318
-
319
- // Check for Promise constructor - warn but don't block
320
- if (/\bnew\s+Promise\s*\(/.test(code)) {
321
- warnings.push({
322
- type: "potentially_unsafe",
323
- message: "Direct Promise constructor usage - consider using async/await instead",
324
- });
325
- }
326
- }
327
-
328
- private checkForPotentiallyUnsafePatterns(code: string, warnings: ScriptValidationWarning[]): void {
329
- // Check for try-catch with broad catch - warn
330
- if (/\bcatch\s*\(\s*\)\s*\{/.test(code)) {
331
- warnings.push({
332
- type: "potentially_unsafe",
333
- message: "Broad catch clause - consider catching specific error types",
334
- });
335
- }
336
-
337
- // Check for nested function declarations - warn about potential complexity
338
- if (/function\s+\w+\s*\([^)]*\)\s*\{[^}]*function\s+/.test(code)) {
339
- warnings.push({
340
- type: "potentially_unsafe",
341
- message: "Nested function declaration - consider extracting to module level",
342
- });
343
- }
344
-
345
- // Check for with statement - deprecated and potentially unsafe
346
- if (/\bwith\s*\(/.test(code)) {
347
- warnings.push({
348
- type: "potentially_unsafe",
349
- message: "with statement is deprecated and potentially unsafe",
350
- });
351
- }
352
- }
353
-
354
- /**
355
- * Execute a script with validation.
356
- * @param code - The JavaScript code to execute
357
- * @param options - Execution options
358
- * @returns The execution result
359
- */
360
- execute(code: string, options?: DynamicScriptOptions): ScriptExecutionResult {
361
- const startTime = Date.now();
362
- const timeout = options?.timeout ?? this.defaultTimeout;
363
-
364
- // Validate first
365
- const validation = this.validate(code);
366
- if (!validation.valid) {
367
- return {
368
- success: false,
369
- error: validation.errors.map((e) => e.message).join("; "),
370
- executionTime: Date.now() - startTime,
371
- validation,
372
- };
373
- }
374
-
375
- try {
376
- const value = this.sandbox.execute(code, timeout);
377
- return {
378
- success: true,
379
- value,
380
- executionTime: Date.now() - startTime,
381
- validation,
382
- };
383
- } catch (error) {
384
- return {
385
- success: false,
386
- error: error instanceof Error ? error.message : String(error),
387
- executionTime: Date.now() - startTime,
388
- validation,
389
- };
390
- }
391
- }
392
-
393
- /**
394
- * Execute an async script with validation.
395
- * @param code - The JavaScript code to execute (must be async or return Promise)
396
- * @param options - Execution options
397
- * @returns Promise resolving to the execution result
398
- */
399
- async executeAsync(code: string, options?: DynamicScriptOptions): Promise<ScriptExecutionResult> {
400
- const startTime = Date.now();
401
- const timeout = options?.timeout ?? this.defaultTimeout;
402
-
403
- // Wrap in async IIFE for async/await support
404
- const asyncCode = `(async () => { ${code} })()`;
405
-
406
- // Validate the wrapped code (not the original code)
407
- const validation = this.validate(asyncCode);
408
- if (!validation.valid) {
409
- return {
410
- success: false,
411
- error: validation.errors.map((e) => e.message).join("; "),
412
- executionTime: Date.now() - startTime,
413
- validation,
414
- };
415
- }
416
-
417
- try {
418
- // Execute using vm directly for async support
419
- const script = new vm.Script(asyncCode, {
420
- filename: "workflow.js",
421
- });
422
-
423
- const result = await script.runInContext(this.sandbox.getContext(), {
424
- timeout,
425
- displayErrors: true,
426
- });
427
- return {
428
- success: true,
429
- value: result,
430
- executionTime: Date.now() - startTime,
431
- validation,
432
- };
433
- } catch (error) {
434
- return {
435
- success: false,
436
- error: error instanceof Error ? error.message : String(error),
437
- executionTime: Date.now() - startTime,
438
- validation,
439
- };
440
- }
441
- }
442
-
443
- /**
444
- * Execute a script without validation (assumes pre-validated).
445
- * Use with caution - prefer execute() for untrusted scripts.
446
- * @internal TEST ONLY — do not use in production code
447
- */
448
- private executeUnchecked(code: string, timeout?: number): ScriptExecutionResult {
449
- const startTime = Date.now();
450
-
451
- try {
452
- const value = this.sandbox.execute(code, timeout ?? this.defaultTimeout);
453
- return {
454
- success: true,
455
- value,
456
- executionTime: Date.now() - startTime,
457
- validation: { valid: true, errors: [], warnings: [] },
458
- };
459
- } catch (error) {
460
- return {
461
- success: false,
462
- error: error instanceof Error ? error.message : String(error),
463
- executionTime: Date.now() - startTime,
464
- validation: { valid: true, errors: [], warnings: [] },
465
- };
466
- }
467
- }
468
-
469
- /**
470
- * Get the list of forbidden globals for documentation.
471
- */
472
- getForbiddenGlobals(): readonly string[] {
473
- return FORBIDDEN_GLOBALS;
474
- }
475
- }
476
-
477
- /**
478
- * Create a pre-configured script runner for workflow execution.
479
- */
480
- export function createScriptRunner(options?: DynamicScriptOptions): DynamicScriptRunner {
481
- return new DynamicScriptRunner(options);
482
- }
483
-
484
- /**
485
- * @internal TEST ONLY — do not use in production code.
486
- * Exposes DynamicScriptRunner.executeUnchecked for unit testing.
487
- * NOTE: This function is exported unconditionally. Previous versions gated
488
- * on NODE_ENV=test || PI_CREW_TEST=1, but Node's test runner doesn't set
489
- * NODE_ENV=test and PI_CREW_TEST has no consumer to set it. The gate was
490
- * effectively dead code that blocked legitimate tests. The `__test_` prefix
491
- * and JSDoc warning are the only protection. Real production code should
492
- * never import this; a more robust solution (tree-shaking, separate builds)
493
- * is out of scope here.
494
- */
495
- export const __test_executeUnchecked = (runner: DynamicScriptRunner, code: string, timeout?: number): ScriptExecutionResult => {
496
- return (runner as unknown as { executeUnchecked: (code: string, timeout?: number) => ScriptExecutionResult }).executeUnchecked(code, timeout);
497
- };