@vzn/vx 0.0.0 → 0.0.12

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 (90) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +332 -1
  3. package/launcher.mjs +84 -0
  4. package/package.json +44 -3
  5. package/src/bin.ts +25 -0
  6. package/src/cache/cache.ts +1832 -0
  7. package/src/cache/cas-backend.ts +97 -0
  8. package/src/cache/digest.ts +47 -0
  9. package/src/cache/index.ts +29 -0
  10. package/src/cache/inputs.ts +833 -0
  11. package/src/cache/layered-cache.ts +335 -0
  12. package/src/cache/remote-cache.ts +251 -0
  13. package/src/cache/tar.ts +343 -0
  14. package/src/cli/backend.ts +23 -0
  15. package/src/cli/cache.ts +101 -0
  16. package/src/cli/format.ts +15 -0
  17. package/src/cli/help.ts +123 -0
  18. package/src/cli/index.ts +85 -0
  19. package/src/cli/info.ts +83 -0
  20. package/src/cli/lock.ts +135 -0
  21. package/src/cli/mcp-rpc.ts +292 -0
  22. package/src/cli/mcp.ts +92 -0
  23. package/src/cli/migrate-nx.ts +450 -0
  24. package/src/cli/migrate-turbo.ts +364 -0
  25. package/src/cli/migrate.ts +219 -0
  26. package/src/cli/plan-format.ts +148 -0
  27. package/src/cli/run.ts +661 -0
  28. package/src/cli/show.ts +201 -0
  29. package/src/cli/upgrade.ts +103 -0
  30. package/src/cli/watch.ts +287 -0
  31. package/src/config.ts +421 -0
  32. package/src/exec/env.ts +74 -0
  33. package/src/exec/index.ts +13 -0
  34. package/src/exec/runner.ts +470 -0
  35. package/src/exec/sandbox-runtime.ts +578 -0
  36. package/src/graph/dependency-spec.ts +69 -0
  37. package/src/graph/index.ts +18 -0
  38. package/src/graph/scheduler.ts +482 -0
  39. package/src/graph/task-graph.ts +325 -0
  40. package/src/index.ts +197 -0
  41. package/src/orchestrator/colors.ts +63 -0
  42. package/src/orchestrator/devframe-surface.ts +74 -0
  43. package/src/orchestrator/events.ts +277 -0
  44. package/src/orchestrator/execute-task.ts +883 -0
  45. package/src/orchestrator/framed-output.ts +461 -0
  46. package/src/orchestrator/history.ts +148 -0
  47. package/src/orchestrator/index.ts +163 -0
  48. package/src/orchestrator/local-shortcircuit.ts +147 -0
  49. package/src/orchestrator/logger.ts +526 -0
  50. package/src/orchestrator/metrics.ts +1576 -0
  51. package/src/orchestrator/options.ts +176 -0
  52. package/src/orchestrator/plan.ts +129 -0
  53. package/src/orchestrator/plugin-host.ts +190 -0
  54. package/src/orchestrator/plugin.ts +283 -0
  55. package/src/orchestrator/predict.ts +106 -0
  56. package/src/orchestrator/prepare.ts +297 -0
  57. package/src/orchestrator/protocol.ts +142 -0
  58. package/src/orchestrator/remote-cache-setup.ts +60 -0
  59. package/src/orchestrator/remote-prefetch.ts +81 -0
  60. package/src/orchestrator/run-artifacts.ts +111 -0
  61. package/src/orchestrator/run-context.ts +205 -0
  62. package/src/orchestrator/run-report.ts +132 -0
  63. package/src/orchestrator/run-state.ts +145 -0
  64. package/src/orchestrator/run.ts +842 -0
  65. package/src/orchestrator/stable-keys.ts +177 -0
  66. package/src/orchestrator/status-line.ts +287 -0
  67. package/src/orchestrator/summary.ts +291 -0
  68. package/src/orchestrator/tally.ts +69 -0
  69. package/src/orchestrator/task-hash.ts +238 -0
  70. package/src/orchestrator/telemetry-host.ts +80 -0
  71. package/src/orchestrator/telemetry.ts +350 -0
  72. package/src/orchestrator/upstream.ts +93 -0
  73. package/src/orchestrator/verify.ts +141 -0
  74. package/src/orchestrator/wire-render.ts +92 -0
  75. package/src/orchestrator/wire.ts +212 -0
  76. package/src/util/errors.ts +11 -0
  77. package/src/util/hash.ts +25 -0
  78. package/src/util/index.ts +7 -0
  79. package/src/util/paths.ts +16 -0
  80. package/src/util/ulid.ts +15 -0
  81. package/src/version.ts +10 -0
  82. package/src/workspace/affected.ts +118 -0
  83. package/src/workspace/filter.ts +155 -0
  84. package/src/workspace/fingerprint.ts +41 -0
  85. package/src/workspace/index.ts +27 -0
  86. package/src/workspace/lockfile.ts +118 -0
  87. package/src/workspace/nested-dirs.ts +34 -0
  88. package/src/workspace/package-graph.ts +148 -0
  89. package/src/workspace/project-loader.ts +470 -0
  90. package/src/workspace/workspace.ts +176 -0
@@ -0,0 +1,883 @@
1
+ import { mkdir } from 'node:fs/promises'
2
+ import path from 'node:path'
3
+ import type { ExecConfig, TaskConfig, CacheConfig } from '../config.js'
4
+ import {
5
+ type CacheEntry,
6
+ type CacheLayer,
7
+ type CachePolicy,
8
+ cleanOutputs,
9
+ cleanWorkspaceOutputs,
10
+ FULL_CACHE_POLICY,
11
+ type GitFilesCache,
12
+ resolveInputs,
13
+ resolveOutputs,
14
+ resolveWorkspaceOutputs,
15
+ WORKSPACE_OUTPUT_PREFIX,
16
+ } from '../cache/index.js'
17
+ import {
18
+ buildIsolatedEnv,
19
+ runCommand,
20
+ runPersistent,
21
+ runSandboxed,
22
+ resolveSandboxConfig,
23
+ type SandboxViolation,
24
+ } from '../exec/index.js'
25
+ import { isGroupTask, type TaskNode, type TaskOutcome, type VerifyVerdict } from '../graph/index.js'
26
+ import {
27
+ classifyDeterminism,
28
+ diffOutputTrees,
29
+ hashOutputTree,
30
+ outputRefs,
31
+ undeclaredInputPaths,
32
+ } from './verify.js'
33
+ import type { Logger } from './logger.js'
34
+ import {
35
+ computeGroupHash,
36
+ computeTaskHash,
37
+ type HashCache,
38
+ type TaskInputComponent,
39
+ } from './task-hash.js'
40
+
41
+ export interface ExecuteArgs {
42
+ node: TaskNode
43
+ upstream: TaskOutcome[]
44
+ workspaceRoot: string
45
+ workspaceFingerprint: string
46
+ cache: CacheLayer
47
+ /** Granular cache read/write policy. Undefined → everything on. */
48
+ cachePolicy?: CachePolicy
49
+ forwardArgs?: readonly string[] | undefined
50
+ log: Logger
51
+ nestedProjectDirs: string[]
52
+ /** Anchor for hrtime spans across all tasks in this run. */
53
+ runStartHrTimeNs: bigint
54
+ /**
55
+ * Registry the orchestrator owns. For each persistent task we
56
+ * spawn, we stash the subprocess handle here so the orchestrator
57
+ * can SIGTERM it once the rest of the graph finishes.
58
+ */
59
+ persistentRegistry?: Map<string, ReturnType<typeof Bun.spawn>>
60
+ /**
61
+ * Run-scoped set of in-flight subprocesses (one-shot and
62
+ * not-yet-ready persistent). The runner adds/removes children
63
+ * around each spawn; the orchestrator's SIGINT/SIGTERM handler
64
+ * SIGTERMs whatever is in here.
65
+ */
66
+ liveChildren?: Set<ReturnType<typeof Bun.spawn>>
67
+ /**
68
+ * Run-level retry default (`--retry <n>` / `RunOptions.retries`).
69
+ * Explicit `exec.retries` wins, including an explicit 0. Threaded as
70
+ * an option only — never folded into any hash, so cache keys are
71
+ * byte-identical with and without it.
72
+ */
73
+ retries?: number
74
+ /**
75
+ * Run-level default task timeout (ms) — the already-resolved
76
+ * `VX_TASK_TIMEOUT`/workspace/`--timeout` fallback. Per-task
77
+ * `exec.timeout` wins. Threaded as an option only — never hashed.
78
+ */
79
+ timeout?: number
80
+ /**
81
+ * Cache-correctness verification (`vx run --verify`). When
82
+ * `determinism` is set, an executed + cacheable task is re-run after its
83
+ * save and its outputs are content-compared; a divergence flags the task
84
+ * non-hermetic. When `inputs` is set, an executed + cacheable task is
85
+ * forced through the declared-input baseline sandbox and any read outside
86
+ * those inputs flags the declared `cache.inputs` as incomplete. `allow`
87
+ * exempts known-nondeterministic task ids from failing the run. Pure
88
+ * side-channel — the re-run never saves, nothing is hashed. Undefined = off.
89
+ */
90
+ verify?: {
91
+ determinism: boolean
92
+ inputs: boolean
93
+ allow: ReadonlySet<string>
94
+ }
95
+ /** Per-run memo for `git ls-files` (one entry per project dir). */
96
+ gitFilesCache?: GitFilesCache
97
+ /** Per-run memo for derived hashes (package.json bytes + task config). */
98
+ hashCache?: HashCache
99
+ /**
100
+ * Up-front probe result from the local short-circuit classify, when
101
+ * this task was stable + cacheable + local-read. Reused here so there
102
+ * is NO second `cache.get`:
103
+ * - `hit` present → restore that entry directly (restore-tier; may
104
+ * run before deps, so the up-front `hash` is used verbatim rather
105
+ * than recomputed against an incomplete upstream).
106
+ * - `hit` null → a confirmed stable miss; skip the probe, go to
107
+ * the run path.
108
+ * Absent → probe lazily, exactly as today (unstable / unclassified).
109
+ */
110
+ preProbed?: { hash: string; hit: CacheEntry | null }
111
+ }
112
+
113
+ /**
114
+ * Dispatch a single task to one of three execution paths. Each path
115
+ * owns its own outcome shape; the dispatcher just picks based on the
116
+ * task's config shape (group vs persistent vs cached). Sharing
117
+ * helpers (`taskEnv`, `effectiveForwardArgs`) live below.
118
+ */
119
+ export async function executeTask(args: ExecuteArgs): Promise<TaskOutcome> {
120
+ if (isGroupTask(args.node)) return executeGroupTask(args)
121
+ if (args.node.config.exec?.persistent !== undefined) return executePersistentTask(args)
122
+ return executeCachedTask(args)
123
+ }
124
+
125
+ /**
126
+ * Group task: no `exec`. The scheduler has already ensured every
127
+ * dependency completed; we just return success with a hash rolled up
128
+ * from upstream outcomes so downstream cache keys still cascade
129
+ * through us.
130
+ */
131
+ function executeGroupTask(args: ExecuteArgs): TaskOutcome {
132
+ const wallclockNs = process.hrtime.bigint() - args.runStartHrTimeNs
133
+ return {
134
+ node: args.node,
135
+ status: 'success',
136
+ exitCode: 0,
137
+ durationMs: 0,
138
+ hash: computeGroupHash(args.upstream),
139
+ wallclockStartNs: wallclockNs,
140
+ wallclockEndNs: wallclockNs,
141
+ }
142
+ }
143
+
144
+ /**
145
+ * Persistent task: dev server / file watcher / daemon. Spawn, wait
146
+ * for ready (regex match or immediate), stash the subprocess in the
147
+ * orchestrator-owned registry, return success. The orchestrator
148
+ * SIGTERMs the registry at end-of-run.
149
+ *
150
+ * Never reads or writes the cache — the project loader rejects
151
+ * `cache + persistent` at config-load time, so by the time we get
152
+ * here, `cache` is guaranteed undefined.
153
+ */
154
+ async function executePersistentTask(args: ExecuteArgs): Promise<TaskOutcome> {
155
+ const { node, log } = args
156
+ // Type narrowing: the dispatcher checks `exec.persistent` before
157
+ // calling us, so `exec` and `exec.persistent` are both present.
158
+ const step = node.config.exec as ExecConfig & {
159
+ persistent: NonNullable<ExecConfig['persistent']>
160
+ }
161
+ const effectiveForwardArgs = node.requested ? (args.forwardArgs ?? []) : []
162
+ const env = taskEnv(node, step)
163
+ const wallclockStartNs = process.hrtime.bigint() - args.runStartHrTimeNs
164
+
165
+ // When readyWhen is set we leave the command untouched so the
166
+ // regex matcher sees the unmodified output. When it's absent the
167
+ // task is "ready on spawn" — we can safely append forwardArgs in
168
+ // the same way runCommand does.
169
+ const persistentOpts: Parameters<typeof runPersistent>[0] = {
170
+ command:
171
+ step.persistent.readyWhen !== undefined
172
+ ? step.command
173
+ : effectiveForwardArgs.length > 0
174
+ ? step.command + ' ' + effectiveForwardArgs.map((s) => JSON.stringify(s)).join(' ')
175
+ : step.command,
176
+ cwd: node.projectDir,
177
+ env,
178
+ onStdout: (chunk) => log.taskStdout(node, chunk),
179
+ onStderr: (chunk) => log.taskStderr(node, chunk),
180
+ ...(args.liveChildren !== undefined ? { liveChildren: args.liveChildren } : {}),
181
+ }
182
+ if (step.persistent.readyWhen !== undefined) {
183
+ persistentOpts.readyWhen = step.persistent.readyWhen
184
+ }
185
+ // For a persistent task the timeout bounds the readiness wait. Per-task
186
+ // `exec.timeout` wins; else the run-level default (env/workspace/--timeout).
187
+ const effectiveTimeout = step.timeout ?? args.timeout
188
+ if (effectiveTimeout !== undefined) {
189
+ persistentOpts.timeoutMs = effectiveTimeout
190
+ }
191
+
192
+ const spawn = runPersistent(persistentOpts)
193
+ try {
194
+ await spawn.ready
195
+ } catch (err) {
196
+ const message = err instanceof Error ? err.message : String(err)
197
+ // Surface readiness failure on stderr so the user sees what went
198
+ // wrong; the buffered stdout/stderr already streamed live via the
199
+ // logger callbacks during spawn.ready.
200
+ process.stderr.write(`\n[vx] ${node.id}: persistent task failed to become ready: ${message}\n`)
201
+ return {
202
+ node,
203
+ status: 'failed',
204
+ exitCode: 1,
205
+ durationMs: spawn.readyMs(),
206
+ wallclockStartNs,
207
+ wallclockEndNs: process.hrtime.bigint() - args.runStartHrTimeNs,
208
+ }
209
+ }
210
+
211
+ args.persistentRegistry?.set(node.id, spawn.child)
212
+ return {
213
+ node,
214
+ status: 'success',
215
+ exitCode: 0,
216
+ durationMs: spawn.readyMs(),
217
+ wallclockStartNs,
218
+ wallclockEndNs: process.hrtime.bigint() - args.runStartHrTimeNs,
219
+ }
220
+ }
221
+
222
+ /**
223
+ * Cached task: the common case. Hash inputs, try cache.get; on hit,
224
+ * clean+restore+replay logs; on miss (or --no-cache), clean outputs,
225
+ * spawn the command, save on success.
226
+ */
227
+ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
228
+ const { node, upstream, cache, log } = args
229
+ const cfg: TaskConfig = node.config
230
+ const step = cfg.exec as ExecConfig // dispatcher guarantees exec is present
231
+ const cacheCfg: CacheConfig | undefined = cfg.cache
232
+ const policy = args.cachePolicy ?? FULL_CACHE_POLICY
233
+ const cfgCacheable = cacheCfg !== undefined
234
+ // We may READ when at least one read axis is on (the cache layer
235
+ // refines local vs remote), and WRITE when at least one write axis is
236
+ // on. Output management (clean before exec) keys off writes — so
237
+ // `--no-cache` (all off) leaves the user's tree alone, while `--force`
238
+ // (reads off, writes on) still wipes + repopulates a clean snapshot.
239
+ const willRead = cfgCacheable && (policy.localRead || policy.remoteRead)
240
+ const willWrite = cfgCacheable && (policy.localWrite || policy.remoteWrite)
241
+
242
+ const outputs = cacheCfg?.outputs.files ?? []
243
+ const wsOutputs = cacheCfg?.outputs.workspaceFiles ?? []
244
+ const effectiveForwardArgs = node.requested ? (args.forwardArgs ?? []) : []
245
+ // Timeout precedence: per-task `exec.timeout` → run-level default
246
+ // (`--timeout`/`RunOptions.timeout` → `VX_TASK_TIMEOUT` → workspace
247
+ // `timeout`, already collapsed into `args.timeout` by run.ts).
248
+ const effectiveTimeout = step.timeout ?? args.timeout
249
+
250
+ // When the task started, as a ns offset from run start — captured for
251
+ // EVERY outcome (hits included) so the run-detail timeline reflects when
252
+ // each task actually ran, not a fabricated `runEnd - duration` window.
253
+ const taskStartNs = process.hrtime.bigint() - args.runStartHrTimeNs
254
+
255
+ // Hash is computed mid-run, not at prepareRun time. Tasks whose
256
+ // `cache.inputs.files` matches sibling outputs (e.g. `'**/*'` after
257
+ // a `codegen` step has written `generated.txt`) need the upstream
258
+ // outputs ALREADY on disk when their hash is computed — so we
259
+ // can't lift this into prepareRun. Same model as Turbo / Nx.
260
+ //
261
+ // The PROBE hash is computed WITHOUT capture — a warm all-cache-hit
262
+ // run allocates no component array and pushes nothing (the warm path
263
+ // does zero extra Tier-3 work). The cache-key components for the
264
+ // Tier-3 input fingerprint are captured only on a MISS, right before
265
+ // `cache.save`, by a second `computeTaskHash` with `captureInto` set
266
+ // — the HashCache memos (package.json bytes, task config, runtime
267
+ // command output) plus the gitFilesCache OID map make that second
268
+ // pass a fold + array pushes, no re-stat / re-hash I/O. It runs on
269
+ // the miss path only, where the task is about to spawn a subprocess
270
+ // anyway, so its cost is in the noise.
271
+ //
272
+ // Local short-circuit reuse: when the classify phase already derived
273
+ // this task's stable key + probed it, reuse the up-front hash verbatim
274
+ // (no recompute — a restore-tier task may run before its deps finish,
275
+ // so its live `upstream` is incomplete; the up-front hash is the
276
+ // authoritative stable key) and skip the probe below.
277
+ const preProbed = args.preProbed
278
+ const hash =
279
+ preProbed !== undefined
280
+ ? preProbed.hash
281
+ : await computeTaskHash({
282
+ node,
283
+ upstream,
284
+ workspaceRoot: args.workspaceRoot,
285
+ workspaceFingerprint: args.workspaceFingerprint,
286
+ cache,
287
+ forwardArgs: args.forwardArgs,
288
+ nestedProjectDirs: args.nestedProjectDirs,
289
+ ...(args.gitFilesCache !== undefined ? { gitFilesCache: args.gitFilesCache } : {}),
290
+ ...(args.hashCache !== undefined ? { hashCache: args.hashCache } : {}),
291
+ })
292
+
293
+ const cleanArgs = {
294
+ projectDir: node.projectDir,
295
+ outputs,
296
+ nestedProjectDirs: args.nestedProjectDirs,
297
+ }
298
+ const wsCleanArgs = { workspaceRoot: args.workspaceRoot, outputs: wsOutputs }
299
+
300
+ // Cache lookup. On hit, time the user-perceived restore op
301
+ // (clean+restore+log-replay) — that's what the framed-block footer
302
+ // shows, not the original exec time stored in the entry. The
303
+ // short-circuit's up-front probe is reused: a `preProbed` entry means
304
+ // the probe already ran — restore its hit, or (null hit) fall straight
305
+ // to the run path as a known stable miss. No second cache.get.
306
+ if (willRead) {
307
+ const cacheOpStart = performance.now()
308
+ if (preProbed !== undefined) {
309
+ if (preProbed.hit !== null) {
310
+ return restoreHit({ args, hash, hit: preProbed.hit, cacheOpStart, taskStartNs })
311
+ }
312
+ // Confirmed stable miss — skip the probe, fall through to run.
313
+ } else {
314
+ const hit = await cache.get(hash, { taskId: node.id, command: step.command })
315
+ if (hit) {
316
+ return restoreHit({ args, hash, hit, cacheOpStart, taskStartNs })
317
+ }
318
+ }
319
+ }
320
+
321
+ const env = taskEnv(node, step)
322
+ const wallclockStartNs = process.hrtime.bigint() - args.runStartHrTimeNs
323
+
324
+ // Sandbox is opt-in per task via `sandbox: {}` (or `sandbox: {...}`)
325
+ // in the task config. No CLI flag, no workspace inheritance — the
326
+ // task config is the single source of truth. EXCEPTION: `--verify=inputs`
327
+ // forces the declared-input baseline sandbox onto every executed, cacheable
328
+ // task to prove input-completeness (a read outside the declared inputs is a
329
+ // proof failure, not a task failure — so it flags the RUN via the verdict,
330
+ // it does not flip the task's own exit code the way a user-declared sandbox
331
+ // violation does).
332
+ const userSandbox = cfg.sandbox !== undefined
333
+ const verifyInputs = args.verify?.inputs === true && willWrite
334
+ const useSandbox = userSandbox || verifyInputs
335
+ let violations: SandboxViolation[] = []
336
+
337
+ // Cache miss path (or caching disabled), up to `1 + retries` attempts.
338
+ // Explicit config wins over the run-level `--retry` default, including
339
+ // an explicit `retries: 0`.
340
+ const maxAttempts = 1 + (step.retries ?? args.retries ?? 0)
341
+ let attempt = 0
342
+ let result: Awaited<ReturnType<typeof runCommand>>
343
+ let effectiveExitCode: number
344
+
345
+ // One task attempt: clean the declared outputs (before EVERY attempt — so a
346
+ // stale prior-build artifact can't survive into a fresh run, and a failed
347
+ // attempt's partial outputs can't leak into the next; gated on WRITES so a
348
+ // `--no-cache` run leaves the user's tree alone), spawn, and classify the
349
+ // exit (a sandbox violation on a 0 exit → fail; a timeout SIGTERM → the
350
+ // streamed notice). Shared by the retry loop AND the `--verify` re-run so
351
+ // the two can never drift on the spawn/clean/classify path.
352
+ async function runAttempt(): Promise<{
353
+ result: Awaited<ReturnType<typeof runCommand>>
354
+ exitCode: number
355
+ }> {
356
+ if (willWrite && outputs.length > 0) await cleanOutputs(cleanArgs)
357
+ if (willWrite && wsOutputs.length > 0) {
358
+ // Root-anchored deletions can land in other projects' dirs; mark
359
+ // them so stale per-project git snapshots can't survive the wipe.
360
+ const cleanedWsRels = await cleanWorkspaceOutputs(wsCleanArgs)
361
+ args.gitFilesCache?.markWorkspaceOutputsChanged(args.workspaceRoot, cleanedWsRels)
362
+ }
363
+ violations = []
364
+ const res = useSandbox ? await runSandboxedTask() : await runUnsandboxedTask()
365
+ // Fail-on-violation. macOS's structured violation store lets us turn a
366
+ // passing exit code into a failure when the task tripped the boundary;
367
+ // Linux relies on the child failing naturally on ENOENT (violations is
368
+ // always 0 there, but the task is already exit != 0 if it needed the
369
+ // missing file). Violations surface via `TaskOutcome.sandboxViolationLines`.
370
+ let code = res.exitCode
371
+ // A USER-declared sandbox fails the task on any violation (that's its
372
+ // fail-on-violation contract). A sandbox forced on ONLY by `--verify=inputs`
373
+ // does NOT — the task ran fine; the incompleteness is surfaced as the
374
+ // `undeclared-inputs` verdict (which reds the run) so the retry loop
375
+ // doesn't pointlessly re-run and the task isn't mislabeled failed.
376
+ if (userSandbox && violations.length > 0 && code === 0) code = 1
377
+ // A child we SIGTERMed for exceeding the timeout is a genuine failure —
378
+ // stream a clear line so the 143 exit reads as a timeout.
379
+ if (res.timedOut) {
380
+ log.taskStderr(node, `\n[vx] timed out after ${effectiveTimeout}ms — killed (SIGTERM)\n`)
381
+ }
382
+ return { result: res, exitCode: code }
383
+ }
384
+
385
+ for (;;) {
386
+ attempt++
387
+ const a = await runAttempt()
388
+ result = a.result
389
+ effectiveExitCode = a.exitCode
390
+
391
+ // A child killed by a shutdown signal (Ctrl-C / SIGTERM teardown)
392
+ // never finished on its own terms — revert it to aborted so it's
393
+ // neither cached, counted, shown, nor RETRIED (the run is tearing
394
+ // down). SIGKILL (OOM, forced) stays a real failure. A timeout also
395
+ // SIGTERMs, but `timedOut` marks it as our own deadline, not a
396
+ // shutdown — so it stays a real (retryable) failure.
397
+ if ((result.signal === 'SIGINT' || result.signal === 'SIGTERM') && !result.timedOut) {
398
+ return {
399
+ node,
400
+ status: 'aborted',
401
+ exitCode: effectiveExitCode,
402
+ durationMs: result.durationMs,
403
+ hash,
404
+ wallclockStartNs,
405
+ wallclockEndNs: process.hrtime.bigint() - args.runStartHrTimeNs,
406
+ }
407
+ }
408
+
409
+ if (effectiveExitCode === 0 || attempt >= maxAttempts) break
410
+ log.taskStderr(
411
+ node,
412
+ `vx: retrying ${node.id} (attempt ${attempt + 1}/${maxAttempts}) after exit ${effectiveExitCode}\n`,
413
+ )
414
+ }
415
+
416
+ async function runUnsandboxedTask(): ReturnType<typeof runCommand> {
417
+ return runCommand({
418
+ command: step.command,
419
+ cwd: node.projectDir,
420
+ env,
421
+ forwardArgs: effectiveForwardArgs,
422
+ onStdout: (chunk) => log.taskStdout(node, chunk),
423
+ onStderr: (chunk) => log.taskStderr(node, chunk),
424
+ ...(args.liveChildren !== undefined ? { liveChildren: args.liveChildren } : {}),
425
+ ...(effectiveTimeout !== undefined ? { timeoutMs: effectiveTimeout } : {}),
426
+ })
427
+ }
428
+
429
+ async function runSandboxedTask(): ReturnType<typeof runCommand> {
430
+ // Baseline allowRead = resolved cache.inputs.files (absolute paths)
431
+ // Baseline allowWrite = static prefix of every cache.outputs.files glob
432
+ // Baseline denyRead = the workspace root, so any read outside the
433
+ // project's declared inputs trips the deny boundary.
434
+ // The user's sandbox block extends each list with explicit additions.
435
+ const resolved = await resolveInputs({
436
+ projectDir: node.projectDir,
437
+ workspaceRoot: args.workspaceRoot,
438
+ envSource: process.env,
439
+ inputs: cacheCfg?.inputs,
440
+ ownOutputs: outputs,
441
+ ownWorkspaceOutputs: wsOutputs,
442
+ nestedProjectDirs: args.nestedProjectDirs,
443
+ ...(args.gitFilesCache !== undefined ? { gitFilesCache: args.gitFilesCache } : {}),
444
+ ...(args.hashCache !== undefined
445
+ ? {
446
+ runtimeCache: args.hashCache.runtime,
447
+ workspaceRuntimeCache: args.hashCache.workspaceRuntime,
448
+ }
449
+ : {}),
450
+ })
451
+ const baseAllowWrite = [
452
+ ...outputs.map((g) => path.join(node.projectDir, staticPrefix(g))),
453
+ // Workspace outputs anchor their write prefixes at the root.
454
+ ...wsOutputs.map((g) => path.join(args.workspaceRoot, staticPrefix(g))),
455
+ ]
456
+ // bwrap can't --bind a non-existent host path; the bind silently
457
+ // becomes a no-op (or a tmpfs that evaporates on exit), and writes
458
+ // to the path appear to succeed inside the sandbox but never land
459
+ // on the host. Pre-create every output path so the binds resolve
460
+ // to real fs entries: globbed outputs (`dist/**`) become empty
461
+ // dirs; literal outputs (`out.txt`) become empty files.
462
+ await prepareOutputsForBind(node.projectDir, outputs)
463
+ await prepareOutputsForBind(args.workspaceRoot, wsOutputs)
464
+ // Output paths are read+write — a task that declares `dist/**` as
465
+ // output expects to read what it just wrote (e.g. `touch dist/x`
466
+ // stats the file; `tsc --incremental` re-reads .tsbuildinfo). This
467
+ // isn't magic — the user already declared these paths; we're just
468
+ // honoring the natural read-write symmetry of an output directory.
469
+ const sandboxResult = await runSandboxed({
470
+ command: step.command,
471
+ cwd: node.projectDir,
472
+ env,
473
+ forwardArgs: effectiveForwardArgs,
474
+ onStdout: (chunk) => log.taskStdout(node, chunk),
475
+ onStderr: (chunk) => log.taskStderr(node, chunk),
476
+ ...(args.liveChildren !== undefined ? { liveChildren: args.liveChildren } : {}),
477
+ ...(effectiveTimeout !== undefined ? { timeoutMs: effectiveTimeout } : {}),
478
+ baseAllowRead: [...resolved.files, ...baseAllowWrite],
479
+ baseAllowWrite,
480
+ baseDenyRead: [args.workspaceRoot],
481
+ config: resolveSandboxConfig(cfg.sandbox ?? {}, node.projectDir),
482
+ })
483
+ violations = sandboxResult.violations
484
+ const { violations: _v, ...runResult } = sandboxResult
485
+ return runResult
486
+ }
487
+
488
+ const wallclockEndNs = process.hrtime.bigint() - args.runStartHrTimeNs
489
+
490
+ // Attempt-1 output fingerprint for `--verify` (content OIDs by output key),
491
+ // captured inside the save block while the tree is attempt-1's.
492
+ let verifyFp1: Map<string, string> | undefined
493
+
494
+ if (effectiveExitCode === 0 && willWrite) {
495
+ const outputFiles = await resolveOutputs({
496
+ projectDir: node.projectDir,
497
+ outputs,
498
+ nestedProjectDirs: args.nestedProjectDirs,
499
+ })
500
+ const wsOutputFiles = await resolveWorkspaceOutputs({
501
+ workspaceRoot: args.workspaceRoot,
502
+ outputs: wsOutputs,
503
+ })
504
+ // Tier-3 input fingerprint: capture the cache-key components for
505
+ // THIS entry, persisted with the entry inside `cache.save`'s
506
+ // transaction. Miss path only — the warm/hit path never reaches
507
+ // here, so it allocates no array and pushes nothing. The HashCache
508
+ // memos + gitFilesCache OID map make this second `computeTaskHash`
509
+ // a fold + array pushes (no re-stat / re-hash I/O), negligible next
510
+ // to the subprocess this task just ran.
511
+ const captured: TaskInputComponent[] = []
512
+ await computeTaskHash({
513
+ node,
514
+ upstream,
515
+ workspaceRoot: args.workspaceRoot,
516
+ workspaceFingerprint: args.workspaceFingerprint,
517
+ cache,
518
+ forwardArgs: args.forwardArgs,
519
+ nestedProjectDirs: args.nestedProjectDirs,
520
+ captureInto: captured,
521
+ ...(args.gitFilesCache !== undefined ? { gitFilesCache: args.gitFilesCache } : {}),
522
+ ...(args.hashCache !== undefined ? { hashCache: args.hashCache } : {}),
523
+ })
524
+ await cache.save({
525
+ hash,
526
+ projectDir: node.projectDir,
527
+ outputFiles,
528
+ ...(wsOutputFiles.length > 0
529
+ ? { workspaceOutputFiles: wsOutputFiles, workspaceRoot: args.workspaceRoot }
530
+ : {}),
531
+ inputComponents: captured.map((c) => ({ entryHash: hash, ...c })),
532
+ entry: {
533
+ taskId: node.id,
534
+ command: step.command,
535
+ exitCode: effectiveExitCode,
536
+ durationMs: result.durationMs,
537
+ stdout: result.stdout,
538
+ },
539
+ })
540
+ // --verify: fingerprint attempt 1's outputs by CONTENT (mtime-independent,
541
+ // reusing the input-hashing OID primitive) so the determinism re-run below
542
+ // can prove the cached bytes are a pure function of the declared inputs.
543
+ if (args.verify?.determinism && outputFiles.length + wsOutputFiles.length > 0) {
544
+ verifyFp1 = await hashOutputTree(
545
+ cache,
546
+ outputRefs(node.projectDir, outputFiles, args.workspaceRoot, wsOutputFiles),
547
+ )
548
+ }
549
+ // This task just wrote outputs to the project's tree. Record the
550
+ // exact declared-output paths as changed (same as the cache-hit
551
+ // restore path) instead of dropping the whole snapshot: a downstream
552
+ // same-project task then re-spawns git ONLY when its input globs can
553
+ // actually see one of these paths. On a 1000-package cold run this
554
+ // removes ~one synchronous `git ls-files` spawn per project (the
555
+ // single largest cold-run cost — 22% of CPU in profiling). Contract:
556
+ // outputs must be declared — an executed task that writes files
557
+ // outside `cache.outputs.files` which a same-project downstream task
558
+ // reads is undeclared behavior (the restore path already assumes it).
559
+ if (outputFiles.length > 0) {
560
+ args.gitFilesCache?.markOutputsChanged(
561
+ node.projectDir,
562
+ outputFiles.map((p) => path.relative(node.projectDir, p).split(path.sep).join('/')),
563
+ )
564
+ }
565
+ // Declared workspace outputs may have landed inside OTHER
566
+ // projects' dirs (no-boundary escape hatch) — mark the exact
567
+ // paths against every partition that can see them.
568
+ if (wsOutputFiles.length > 0) {
569
+ args.gitFilesCache?.markWorkspaceOutputsChanged(
570
+ args.workspaceRoot,
571
+ wsOutputFiles.map((f) => path.relative(args.workspaceRoot, f).split(path.sep).join('/')),
572
+ )
573
+ }
574
+ // The workspace-wide partition (when one exists) spans this
575
+ // project's subtree, so it inherits the same "undeclared writes
576
+ // are only visible to git" rule as the project drop above.
577
+ if (outputFiles.length + wsOutputFiles.length > 0) {
578
+ args.gitFilesCache?.invalidateWorkspacePartition()
579
+ }
580
+ }
581
+
582
+ // Snapshot the WINNING attempt's sandbox violations before the verify re-run
583
+ // (which reuses `runAttempt` and would clobber `violations`); the outcome
584
+ // must report attempt 1's, not the re-run's.
585
+ const finalViolations = violations
586
+
587
+ let verify: VerifyVerdict | undefined
588
+
589
+ // Input-completeness verification (`vx run --verify=inputs` / `=all`, Phase 2).
590
+ // The task ran under the forced declared-input baseline sandbox. A read of a
591
+ // workspace path outside the declared inputs proves `cache.inputs` is
592
+ // incomplete — a future hit could serve stale bytes when that undeclared file
593
+ // changes. This reds the run via the verdict (see run.ts `ok`), not the task's
594
+ // own exit code. Checked FIRST: if the inputs are wrong, there's no point
595
+ // re-running for determinism — fix the inputs (which changes the key) first.
596
+ if (verifyInputs) {
597
+ if (finalViolations.length > 0) {
598
+ verify = {
599
+ kind: 'undeclared-inputs',
600
+ paths: undeclaredInputPaths(finalViolations, args.workspaceRoot),
601
+ }
602
+ } else if (effectiveExitCode === 0 && !args.verify?.determinism) {
603
+ // Inputs-only run, inputs complete. (`=all` falls through to the stronger
604
+ // determinism verdict below.)
605
+ verify = { kind: 'proven-complete' }
606
+ }
607
+ }
608
+
609
+ // Determinism verification (`vx run --verify` / `=all`). The task executed and
610
+ // saved; re-run it fresh and content-compare its outputs against attempt 1. A
611
+ // divergence proves the task is non-hermetic — its cache entry would replay
612
+ // arbitrary past bytes. The re-run NEVER saves; the canonical (attempt-1)
613
+ // bytes are restored afterward so disk == the cache regardless of verdict.
614
+ if (
615
+ verify === undefined &&
616
+ args.verify?.determinism &&
617
+ effectiveExitCode === 0 &&
618
+ willWrite &&
619
+ !result.timedOut
620
+ ) {
621
+ if (verifyFp1 === undefined) {
622
+ verify = { kind: 'no-outputs' } // cacheable but nothing to replay (e.g. lint)
623
+ } else {
624
+ const rerun = await runAttempt()
625
+ if (rerun.exitCode !== 0 || rerun.result.timedOut) {
626
+ verify = { kind: 'rerun-failed', exitCode: rerun.exitCode }
627
+ } else {
628
+ const fp2 = await hashOutputTree(
629
+ cache,
630
+ outputRefs(
631
+ node.projectDir,
632
+ await resolveOutputs({
633
+ projectDir: node.projectDir,
634
+ outputs,
635
+ nestedProjectDirs: args.nestedProjectDirs,
636
+ }),
637
+ args.workspaceRoot,
638
+ await resolveWorkspaceOutputs({
639
+ workspaceRoot: args.workspaceRoot,
640
+ outputs: wsOutputs,
641
+ }),
642
+ ),
643
+ )
644
+ verify = classifyDeterminism(
645
+ diffOutputTrees(verifyFp1, fp2),
646
+ args.verify.allow.has(node.id),
647
+ )
648
+ }
649
+ await cache.restoreOutputs(
650
+ hash,
651
+ node.projectDir,
652
+ wsOutputs.length > 0 ? args.workspaceRoot : undefined,
653
+ )
654
+ }
655
+ }
656
+
657
+ return {
658
+ node,
659
+ status: effectiveExitCode === 0 ? 'success' : 'failed',
660
+ exitCode: effectiveExitCode,
661
+ durationMs: result.durationMs,
662
+ hash,
663
+ ...(attempt > 1 ? { attempts: attempt } : {}),
664
+ ...(result.cpuMs !== undefined ? { cpuMs: result.cpuMs } : {}),
665
+ ...(result.peakRssBytes !== undefined ? { peakRssBytes: result.peakRssBytes } : {}),
666
+ wallclockStartNs,
667
+ wallclockEndNs,
668
+ ...(verify !== undefined ? { verify } : {}),
669
+ ...(finalViolations.length > 0
670
+ ? {
671
+ sandboxViolations: finalViolations.length,
672
+ sandboxViolationLines: finalViolations.map((v) => v.line),
673
+ }
674
+ : {}),
675
+ }
676
+ }
677
+
678
+ export interface RestoreHitArgs {
679
+ args: ExecuteArgs
680
+ hash: string
681
+ hit: CacheEntry
682
+ /** `performance.now()` when the cache op (probe) started — the
683
+ * user-perceived restore duration is measured from here. */
684
+ cacheOpStart: number
685
+ /** ns offset from run start for the outcome's wallclock window. */
686
+ taskStartNs: bigint
687
+ }
688
+
689
+ /**
690
+ * Materialize a confirmed cache hit: decide skip-restore, clean +
691
+ * restore declared outputs, mark the exact changed paths so downstream
692
+ * same-project tasks needn't re-spawn git, replay stored stdout, and
693
+ * build the cache-hit `TaskOutcome`. Extracted from `executeCachedTask`
694
+ * so the local short-circuit can restore a stable-key hit ahead of the
695
+ * schedule using the IDENTICAL logic — there is one restore path, not
696
+ * two that could drift.
697
+ */
698
+ export async function restoreHit(restore: RestoreHitArgs): Promise<TaskOutcome> {
699
+ const { args, hash, hit, cacheOpStart, taskStartNs } = restore
700
+ const { node, log } = args
701
+ const cacheCfg: CacheConfig | undefined = node.config.cache
702
+ const outputs = cacheCfg?.outputs.files ?? []
703
+ const wsOutputs = cacheCfg?.outputs.workspaceFiles ?? []
704
+ const anyOutputs = outputs.length > 0 || wsOutputs.length > 0
705
+ const cleanArgs = {
706
+ projectDir: node.projectDir,
707
+ outputs,
708
+ nestedProjectDirs: args.nestedProjectDirs,
709
+ }
710
+ const wsCleanArgs = { workspaceRoot: args.workspaceRoot, outputs: wsOutputs }
711
+
712
+ // "Tree is already current" short-circuit — skip cleanOutputs
713
+ // + restoreOutputs when the on-disk state matches what this
714
+ // entry recorded at save time. Integrity-preserving: we
715
+ // require BOTH that the output-glob walk yields exactly the
716
+ // expected paths (no strays, no missing) AND that every file's
717
+ // (size, mode, mtime) matches the stored fingerprint. Any
718
+ // divergence falls through to a real clean + restore.
719
+ //
720
+ // The output-file fingerprints can't be batch-loaded at
721
+ // prepareRun time — non-leaf task hashes depend on upstream
722
+ // outputs that haven't been written yet, so hashes are
723
+ // necessarily computed mid-run. We do one extra SELECT per
724
+ // cache hit here. Still beats reading the manifest from the
725
+ // tar (decompress + parse) at the same point.
726
+ let skipRestore = false
727
+ if (anyOutputs) {
728
+ const expected = args.cache.loadOutputFilesBatch([hash]).get(hash) ?? []
729
+ if (expected.length > 0) {
730
+ // Two namespaces in the rows: bare rels are project outputs,
731
+ // `workspace-outputs/<rel>` rows anchor at the workspace root.
732
+ const projExpected = expected.filter((e) => !e.path.startsWith(WORKSPACE_OUTPUT_PREFIX))
733
+ const wsExpected = expected
734
+ .filter((e) => e.path.startsWith(WORKSPACE_OUTPUT_PREFIX))
735
+ .map((e) => ({ ...e, path: e.path.slice(WORKSPACE_OUTPUT_PREFIX.length) }))
736
+ const actualAbs = await resolveOutputs({
737
+ projectDir: node.projectDir,
738
+ outputs,
739
+ nestedProjectDirs: args.nestedProjectDirs,
740
+ })
741
+ const actualWsAbs = await resolveWorkspaceOutputs({
742
+ workspaceRoot: args.workspaceRoot,
743
+ outputs: wsOutputs,
744
+ })
745
+ const setsMatch = (
746
+ actual: readonly string[],
747
+ exp: ReadonlyArray<{ path: string }>,
748
+ ): boolean => {
749
+ const expSet = new Set(exp.map((e) => e.path))
750
+ return actual.length === expSet.size && actual.every((r) => expSet.has(r))
751
+ }
752
+ const actualRels = actualAbs.map((p) =>
753
+ path.relative(node.projectDir, p).split(path.sep).join('/'),
754
+ )
755
+ const actualWsRels = actualWsAbs.map((p) =>
756
+ path.relative(args.workspaceRoot, p).split(path.sep).join('/'),
757
+ )
758
+ if (setsMatch(actualRels, projExpected) && setsMatch(actualWsRels, wsExpected)) {
759
+ skipRestore =
760
+ (await args.cache.isOutputsCurrent(node.projectDir, projExpected)) &&
761
+ (await args.cache.isOutputsCurrent(args.workspaceRoot, wsExpected))
762
+ }
763
+ }
764
+ }
765
+ if (!skipRestore) {
766
+ let cleanedRels: string[] = []
767
+ let cleanedWsRels: string[] = []
768
+ if (outputs.length > 0) cleanedRels = await cleanOutputs(cleanArgs)
769
+ if (wsOutputs.length > 0) cleanedWsRels = await cleanWorkspaceOutputs(wsCleanArgs)
770
+ await args.cache.restoreOutputs(hash, node.projectDir, args.workspaceRoot)
771
+ // Restored outputs changed the project's tree — but on this
772
+ // path we know the EXACT changed paths (wiped declared
773
+ // outputs + the artifact's files). Record them instead of
774
+ // dropping the snapshot; downstream same-project tasks only
775
+ // re-spawn git when their input globs can actually see one of
776
+ // these paths. The cache-miss save path keeps the
777
+ // unconditional drop (an executed task may write undeclared
778
+ // files only git can see).
779
+ if (outputs.length > 0) {
780
+ args.gitFilesCache?.markOutputsChanged(node.projectDir, [
781
+ ...cleanedRels,
782
+ ...hit.outputFiles.filter((p) => !p.startsWith(WORKSPACE_OUTPUT_PREFIX)),
783
+ ])
784
+ }
785
+ if (wsOutputs.length > 0) {
786
+ args.gitFilesCache?.markWorkspaceOutputsChanged(args.workspaceRoot, [
787
+ ...cleanedWsRels,
788
+ ...hit.outputFiles
789
+ .filter((p) => p.startsWith(WORKSPACE_OUTPUT_PREFIX))
790
+ .map((p) => p.slice(WORKSPACE_OUTPUT_PREFIX.length)),
791
+ ])
792
+ }
793
+ }
794
+ if (hit.stdout) log.taskStdout(node, hit.stdout)
795
+ const status =
796
+ hit.exitCode !== 0 ? 'failed' : hit.source === 'remote' ? 'cache-hit-remote' : 'cache-hit'
797
+ // `restored` distinguishes "we just wrote files to disk" from
798
+ // "disk already matched the cached snapshot". Drives the
799
+ // "up-to-date" vs "local-cache" / "remote-cache" label in the
800
+ // framed block. Only meaningful when at least one output was
801
+ // declared — no-outputs tasks never materialize anything, so
802
+ // they're vacuously up-to-date.
803
+ const restored = !skipRestore && anyOutputs
804
+ return {
805
+ node,
806
+ status,
807
+ exitCode: hit.exitCode,
808
+ durationMs: Math.round(performance.now() - cacheOpStart),
809
+ hash,
810
+ restored,
811
+ // Under `--verify`, a cache hit didn't execute this run — so it wasn't
812
+ // proven. Flag it `not-verified` (use `--force` to re-execute + verify).
813
+ ...(args.verify?.determinism || args.verify?.inputs
814
+ ? { verify: { kind: 'not-verified' as const } }
815
+ : {}),
816
+ wallclockStartNs: taskStartNs,
817
+ wallclockEndNs: process.hrtime.bigint() - args.runStartHrTimeNs,
818
+ }
819
+ }
820
+
821
+ /**
822
+ * Ensure each declared output path exists on the host as either an
823
+ * empty file (for literal output specs) or a directory (for globbed
824
+ * specs) so bwrap's --bind can find a real fs entry to mount. Without
825
+ * this, writes inside the sandbox to a non-existent allowWrite path
826
+ * silently disappear (bwrap creates a tmpfs that evaporates on exit).
827
+ *
828
+ * `cleanOutputs` ran just before this in the cache-enabled path, so
829
+ * we know any stale content was wiped; what's left is to materialize
830
+ * the empty skeleton.
831
+ */
832
+ async function prepareOutputsForBind(
833
+ projectDir: string,
834
+ outputs: readonly string[],
835
+ ): Promise<void> {
836
+ for (const g of outputs) {
837
+ const hasWildcard = /[*?[\]]/.test(g)
838
+ if (hasWildcard) {
839
+ const abs = path.join(projectDir, staticPrefix(g))
840
+ await mkdir(abs, { recursive: true })
841
+ } else {
842
+ const abs = path.join(projectDir, g)
843
+ await mkdir(path.dirname(abs), { recursive: true })
844
+ const f = Bun.file(abs)
845
+ if (!(await f.exists())) await Bun.write(abs, '')
846
+ }
847
+ }
848
+ }
849
+
850
+ /**
851
+ * Return the longest prefix of a glob that contains no wildcards. Used
852
+ * to derive an allowWrite path from each `cache.outputs.files` entry:
853
+ * `dist/**` → `dist`, `build/output.js` → `build/output.js`, `**` →
854
+ * `.` (the project dir itself). bwrap binds at the directory level so
855
+ * a file path covers writes to that file; a dir path covers writes
856
+ * anywhere underneath.
857
+ */
858
+ function staticPrefix(glob: string): string {
859
+ const wildcardIdx = glob.search(/[*?[\]]/)
860
+ if (wildcardIdx === -1) return glob
861
+ // Trim back to the last separator before the wildcard so we keep
862
+ // only complete path components (e.g. `dist/sub-**` → `dist`, not
863
+ // `dist/sub-`).
864
+ const head = glob.slice(0, wildcardIdx)
865
+ const lastSep = head.lastIndexOf('/')
866
+ if (lastSep === -1) return '.'
867
+ return head.slice(0, lastSep) || '/'
868
+ }
869
+
870
+ /**
871
+ * Build the child-process env for one task. Same arguments at every
872
+ * call site (persistent + cached); the project's own
873
+ * `node_modules/.bin` is prepended to PATH — never the workspace
874
+ * root's, never sibling projects' (per the project-isolation rule).
875
+ */
876
+ function taskEnv(node: TaskNode, step: ExecConfig): NodeJS.ProcessEnv {
877
+ return buildIsolatedEnv({
878
+ passThrough: step.env?.passThrough ?? [],
879
+ define: step.env?.define ?? {},
880
+ source: process.env,
881
+ binPaths: [path.join(node.projectDir, 'node_modules', '.bin')],
882
+ })
883
+ }