@vzn/vx 0.0.0 → 0.0.14

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