@vzn/vx 0.0.18 → 0.0.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +1 -1
- package/README.md +99 -65
- package/package.json +10 -20
- package/src/bin.ts +12 -2
- package/src/cache/archive.ts +120 -18
- package/src/cache/cache.ts +313 -1324
- package/src/cache/chained-cache.ts +0 -8
- package/src/cache/config-evals.ts +89 -0
- package/src/cache/file-hashes.ts +262 -0
- package/src/cache/git-inputs.ts +637 -0
- package/src/cache/index.ts +16 -7
- package/src/cache/inputs.ts +246 -733
- package/src/cache/layer.ts +604 -0
- package/src/cache/layered-cache.ts +12 -17
- package/src/cache/output-index.ts +224 -0
- package/src/cache/policy.ts +84 -0
- package/src/cache/run-history.ts +147 -0
- package/src/cache/zstd.ts +160 -0
- package/src/cli/cache.ts +39 -11
- package/src/cli/completions.ts +104 -0
- package/src/cli/core-alias.ts +25 -0
- package/src/cli/help.ts +64 -36
- package/src/cli/index.ts +54 -15
- package/src/cli/info.ts +120 -91
- package/src/cli/init.ts +75 -0
- package/src/cli/last.ts +38 -6
- package/src/cli/lock.ts +2 -1
- package/src/cli/plan-format.ts +14 -3
- package/src/cli/plugin-commands.ts +15 -8
- package/src/cli/run.ts +84 -302
- package/src/cli/select.ts +332 -0
- package/src/cli/show.ts +144 -69
- package/src/cli/upgrade.ts +2 -1
- package/src/cli/watch.ts +647 -97
- package/src/cli/why.ts +44 -14
- package/src/cli/workspace-config.ts +126 -0
- package/src/config.ts +156 -136
- package/src/exec/env.ts +11 -0
- package/src/exec/executor.ts +11 -26
- package/src/exec/index.ts +6 -2
- package/src/{plugins/local-executor/index.ts → exec/local-executor.ts} +12 -16
- package/src/exec/runner.ts +57 -18
- package/src/exec/sandbox-binds.ts +182 -0
- package/src/exec/sandbox-paths.ts +50 -0
- package/src/exec/sandbox-runtime.ts +582 -370
- package/src/exec/sandbox-violations.ts +280 -0
- package/src/graph/index.ts +1 -9
- package/src/graph/scheduler.ts +158 -203
- package/src/graph/task-graph.ts +2 -2
- package/src/index.ts +97 -102
- package/src/orchestrator/admission.ts +137 -0
- package/src/orchestrator/deferred-outputs.ts +3 -0
- package/src/orchestrator/doctor.ts +255 -0
- package/src/orchestrator/events.ts +42 -8
- package/src/orchestrator/execute-task.ts +145 -554
- package/src/orchestrator/failure-mode.ts +169 -28
- package/src/orchestrator/framed-output.ts +41 -36
- package/src/orchestrator/history.ts +118 -98
- package/src/orchestrator/hit-restore.ts +221 -0
- package/src/orchestrator/index.ts +35 -4
- package/src/orchestrator/lockfile-claim.ts +281 -0
- package/src/orchestrator/logger.ts +13 -22
- package/src/orchestrator/metrics.ts +53 -20
- package/src/orchestrator/miss-save.ts +169 -0
- package/src/orchestrator/options.ts +34 -31
- package/src/orchestrator/persistent.ts +79 -0
- package/src/orchestrator/placement.ts +182 -0
- package/src/orchestrator/plugin-host.ts +195 -38
- package/src/orchestrator/plugin.ts +162 -9
- package/src/orchestrator/prepare.ts +77 -132
- package/src/orchestrator/projects.ts +259 -0
- package/src/orchestrator/remote-prefetch.ts +1 -1
- package/src/orchestrator/run-artifacts.ts +25 -3
- package/src/orchestrator/run-context.ts +1 -1
- package/src/orchestrator/run-records.ts +149 -0
- package/src/orchestrator/run-report.ts +2 -2
- package/src/orchestrator/run.ts +246 -597
- package/src/orchestrator/sandbox-request.ts +243 -0
- package/src/orchestrator/save-lane.ts +47 -0
- package/src/orchestrator/signals.ts +111 -0
- package/src/orchestrator/stable-keys.ts +60 -3
- package/src/orchestrator/status-line.ts +2 -1
- package/src/orchestrator/summary.ts +68 -25
- package/src/orchestrator/task-hash.ts +20 -18
- package/src/orchestrator/task-log-buffer.ts +8 -11
- package/src/orchestrator/telemetry-host.ts +11 -1
- package/src/orchestrator/telemetry.ts +3 -17
- package/src/util/cgroup.ts +160 -0
- package/src/util/edit-distance.ts +49 -0
- package/src/util/index.ts +11 -3
- package/src/util/paths.ts +23 -2
- package/src/util/settle.ts +19 -0
- package/src/util/verbs.ts +39 -0
- package/src/workspace/affected.ts +111 -11
- package/src/workspace/config-cache.ts +11 -2
- package/src/workspace/config-eval.ts +11 -4
- package/src/workspace/config-imports.ts +5 -4
- package/src/workspace/config-schema.ts +837 -0
- package/src/workspace/filter.ts +43 -6
- package/src/workspace/fingerprint.ts +37 -3
- package/src/workspace/index.ts +30 -3
- package/src/workspace/lockfile.ts +12 -7
- package/src/{cli → workspace}/migrate-scripts.ts +26 -19
- package/src/{cli/migrate.ts → workspace/migration.ts} +133 -124
- package/src/workspace/package-graph.ts +98 -28
- package/src/workspace/project-loader.ts +76 -746
- package/src/workspace/workspace.ts +98 -13
- package/plugins/local-cache/index.ts +0 -2
- package/plugins/local-executor/index.ts +0 -2
- package/plugins/schedule-history/index.ts +0 -2
- package/src/cache/cas-backend.ts +0 -115
- package/src/cache/digest.ts +0 -32
- package/src/cli/migrate-emit.ts +0 -21
- package/src/cli/migrate-nx.ts +0 -473
- package/src/cli/migrate-turbo.ts +0 -401
- package/src/cli/prune.ts +0 -291
- package/src/orchestrator/missing-plugin.ts +0 -9
- package/src/orchestrator/resources.ts +0 -45
- package/src/orchestrator/verify.ts +0 -174
- package/src/plugins/local-cache/index.ts +0 -11
- package/src/plugins/schedule-history/index.ts +0 -109
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { mkdir } from 'node:fs/promises'
|
|
2
1
|
import path from 'node:path'
|
|
3
2
|
import type { ExecConfig, TaskConfig, CacheConfig } from '../config.js'
|
|
4
3
|
import {
|
|
@@ -9,16 +8,15 @@ import {
|
|
|
9
8
|
cleanWorkspaceOutputs,
|
|
10
9
|
FULL_CACHE_POLICY,
|
|
11
10
|
type GitFilesCache,
|
|
12
|
-
resolveInputs,
|
|
13
|
-
resolveOutputs,
|
|
14
|
-
resolveWorkspaceOutputs,
|
|
15
|
-
WORKSPACE_OUTPUT_PREFIX,
|
|
16
11
|
} from '../cache/index.js'
|
|
17
12
|
import {
|
|
18
13
|
buildIsolatedEnv,
|
|
14
|
+
VX_RUN_TASK_ENV,
|
|
15
|
+
VX_RUN_WORKSPACE_ENV,
|
|
19
16
|
runPersistent,
|
|
20
|
-
resolveSandboxConfig,
|
|
21
17
|
shellQuote,
|
|
18
|
+
releaseBridges,
|
|
19
|
+
wrapSandboxedCommand,
|
|
22
20
|
signalExitCode,
|
|
23
21
|
type CaptureConfig,
|
|
24
22
|
type ExecuteRequest,
|
|
@@ -27,16 +25,13 @@ import {
|
|
|
27
25
|
type TaskExecutor,
|
|
28
26
|
type TaskInputs,
|
|
29
27
|
} from '../exec/index.js'
|
|
30
|
-
import { isGroupTask, type TaskNode, type TaskOutcome
|
|
31
|
-
import {
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
undeclaredInputPaths,
|
|
38
|
-
} from './verify.js'
|
|
39
|
-
import { span, staticPrefix, wholeSubtreePrefixes } from '../util/index.js'
|
|
28
|
+
import { isGroupTask, type TaskNode, type TaskOutcome } from '../graph/index.js'
|
|
29
|
+
import { span } from '../util/index.js'
|
|
30
|
+
import { sandboxRequestFor } from './sandbox-request.js'
|
|
31
|
+
import { saveMiss, type OutputDirSnapshot } from './miss-save.js'
|
|
32
|
+
import { restoreHit } from './hit-restore.js'
|
|
33
|
+
// The hit path's entry stays importable from here (tests).
|
|
34
|
+
export { restoreHit, type RestoreHitArgs } from './hit-restore.js'
|
|
40
35
|
import type { DeferredOutputs } from './deferred-outputs.js'
|
|
41
36
|
import type { Logger } from './logger.js'
|
|
42
37
|
import {
|
|
@@ -112,25 +107,6 @@ export interface ExecuteArgs {
|
|
|
112
107
|
* `exec.timeout` wins. Threaded as an option only — never hashed.
|
|
113
108
|
*/
|
|
114
109
|
timeout?: number
|
|
115
|
-
/**
|
|
116
|
-
* Cache-correctness verification (`vx run --verify`). When
|
|
117
|
-
* `determinism` is set, an executed + cacheable task is re-run after its
|
|
118
|
-
* save and its outputs are content-compared; a divergence flags the task
|
|
119
|
-
* non-hermetic. When `inputs` is set, an executed + cacheable task is
|
|
120
|
-
* forced through the declared-input baseline sandbox and any read outside
|
|
121
|
-
* those inputs flags the declared `cache.inputs` as incomplete. When
|
|
122
|
-
* `fingerprint` is set, the executed task's output tree is fingerprinted
|
|
123
|
-
* and attached to the outcome (no re-run — the cross-machine diff feed).
|
|
124
|
-
* `allow` exempts known-nondeterministic task ids from failing the run.
|
|
125
|
-
* Pure side-channel — the re-run never saves, nothing is hashed.
|
|
126
|
-
* Undefined = off.
|
|
127
|
-
*/
|
|
128
|
-
verify?: {
|
|
129
|
-
determinism: boolean
|
|
130
|
-
inputs: boolean
|
|
131
|
-
fingerprint: boolean
|
|
132
|
-
allow: ReadonlySet<string>
|
|
133
|
-
}
|
|
134
110
|
/** Per-run memo for `git ls-files` (one entry per project dir). */
|
|
135
111
|
gitFilesCache?: GitFilesCache
|
|
136
112
|
/** Per-run memo for derived hashes (package.json bytes + task config). */
|
|
@@ -147,6 +123,37 @@ export interface ExecuteArgs {
|
|
|
147
123
|
* Absent → probe lazily, exactly as today (unstable / unclassified).
|
|
148
124
|
*/
|
|
149
125
|
preProbed?: { hash: string; hit: CacheEntry | null }
|
|
126
|
+
/**
|
|
127
|
+
* Start the sandbox runtime, on the first task that executes inside one
|
|
128
|
+
* (run.ts, `prepareSandbox`). Absent when no task in the run declares a
|
|
129
|
+
* sandbox. A cache hit never calls it: a hit needs no sandbox.
|
|
130
|
+
*/
|
|
131
|
+
armSandbox?: () => Promise<void>
|
|
132
|
+
/**
|
|
133
|
+
* Run-scoped list the miss path appends its output-directory snapshot
|
|
134
|
+
* request to, taken at run end (run.ts) instead of right after the save:
|
|
135
|
+
* a directory written milliseconds ago is inside the snapshot's racy
|
|
136
|
+
* window and would be refused, and the next hit would walk the tree.
|
|
137
|
+
*/
|
|
138
|
+
outputDirSnapshots?: OutputDirSnapshot[]
|
|
139
|
+
/** The run's save lane: a miss's cache save runs off the execution slot (save-lane.ts). */
|
|
140
|
+
deferSave?: (save: () => Promise<void>) => Promise<void>
|
|
141
|
+
/**
|
|
142
|
+
* Where a deferred save's `landed` promise is parked by task id, for the
|
|
143
|
+
* in-flight join (admission.ts): a duplicate of this task in another run
|
|
144
|
+
* must not probe the cache before the entry is there.
|
|
145
|
+
*/
|
|
146
|
+
deferredSaves?: Map<string, Promise<void>>
|
|
147
|
+
/**
|
|
148
|
+
* `continueMode: 'always'` let this task run although an upstream —
|
|
149
|
+
* directly or through a chain of successes — failed or aborted. It still
|
|
150
|
+
* cleans its outputs and runs, and a cache HIT still restores (a hit is a
|
|
151
|
+
* healthy run's bytes), but its own result is never saved: the key it
|
|
152
|
+
* derives is the one a healthy run derives (pure-input hashing), while
|
|
153
|
+
* the bytes were built on a partial tree, so a save here is the next
|
|
154
|
+
* clean run's stale hit.
|
|
155
|
+
*/
|
|
156
|
+
taintedUpstream?: boolean
|
|
150
157
|
}
|
|
151
158
|
|
|
152
159
|
/**
|
|
@@ -211,13 +218,32 @@ async function executePersistentTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
211
218
|
// regex matcher sees the unmodified output. When it's absent the
|
|
212
219
|
// task is "ready on spawn" — we can safely append forwardArgs in
|
|
213
220
|
// the same way runCommand does.
|
|
221
|
+
const plainCommand =
|
|
222
|
+
step.persistent.readyWhen !== undefined
|
|
223
|
+
? step.command
|
|
224
|
+
: effectiveForwardArgs.length > 0
|
|
225
|
+
? step.command + ' ' + effectiveForwardArgs.map(shellQuote).join(' ')
|
|
226
|
+
: step.command
|
|
227
|
+
// A persistent task declaring `exec.sandbox` runs inside it like any
|
|
228
|
+
// other: the same grants, the same walls. What it cannot have is the
|
|
229
|
+
// violation REPORT — that reads the trace after the child exits, and a
|
|
230
|
+
// server exits when the run tears it down. Enforced, not reported.
|
|
231
|
+
// (Until 2026-09-09 the block was accepted and silently ignored.)
|
|
232
|
+
let command = plainCommand
|
|
233
|
+
let bridgeTag: string | undefined
|
|
234
|
+
if (step.sandbox !== undefined) {
|
|
235
|
+
await args.armSandbox?.()
|
|
236
|
+
const sb = await sandboxRequestFor(node, step.sandbox, args.workspaceRoot)
|
|
237
|
+
const wrapped = await wrapSandboxedCommand({
|
|
238
|
+
command: plainCommand,
|
|
239
|
+
cwd: node.projectDir,
|
|
240
|
+
...sb,
|
|
241
|
+
})
|
|
242
|
+
command = wrapped.wrapped
|
|
243
|
+
bridgeTag = wrapped.tag
|
|
244
|
+
}
|
|
214
245
|
const persistentOpts: Parameters<typeof runPersistent>[0] = {
|
|
215
|
-
command
|
|
216
|
-
step.persistent.readyWhen !== undefined
|
|
217
|
-
? step.command
|
|
218
|
-
: effectiveForwardArgs.length > 0
|
|
219
|
-
? step.command + ' ' + effectiveForwardArgs.map(shellQuote).join(' ')
|
|
220
|
-
: step.command,
|
|
246
|
+
command,
|
|
221
247
|
cwd: node.projectDir,
|
|
222
248
|
env,
|
|
223
249
|
onStdout: (chunk) => log.taskStdout(node, chunk),
|
|
@@ -235,14 +261,23 @@ async function executePersistentTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
235
261
|
}
|
|
236
262
|
|
|
237
263
|
const spawn = runPersistent(persistentOpts)
|
|
264
|
+
// The host side of a port bridge lives exactly as long as the server:
|
|
265
|
+
// released on the child's exit, whether the run tore it down or it died.
|
|
266
|
+
if (bridgeTag !== undefined) {
|
|
267
|
+
const tag = bridgeTag
|
|
268
|
+
void spawn.child?.exited?.then(
|
|
269
|
+
() => releaseBridges(tag),
|
|
270
|
+
() => releaseBridges(tag),
|
|
271
|
+
)
|
|
272
|
+
}
|
|
238
273
|
try {
|
|
239
274
|
await spawn.ready
|
|
240
275
|
} catch (err) {
|
|
241
276
|
const message = err instanceof Error ? err.message : String(err)
|
|
242
|
-
//
|
|
243
|
-
//
|
|
244
|
-
//
|
|
245
|
-
|
|
277
|
+
// The task's OWN stream, not the process's: the frame is where a
|
|
278
|
+
// reader looks for why a task failed, and a run with a custom logger
|
|
279
|
+
// (an embedder, the MCP server) never saw a bare stderr write at all.
|
|
280
|
+
log.taskStderr(node, `\n[vx] ${node.id}: persistent task failed to become ready: ${message}\n`)
|
|
246
281
|
return {
|
|
247
282
|
node,
|
|
248
283
|
status: 'failed',
|
|
@@ -288,6 +323,9 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
288
323
|
const remoteOnly = args.remoteOnly === true
|
|
289
324
|
const willRead = !remoteOnly && cfgCacheable && (policy.localRead || policy.remoteRead)
|
|
290
325
|
const willWrite = !remoteOnly && cfgCacheable && (policy.localWrite || policy.remoteWrite)
|
|
326
|
+
// `willWrite` governs output hygiene (clean before exec); the save itself
|
|
327
|
+
// is also withheld from a task downstream of a failure (see `taintedUpstream`).
|
|
328
|
+
const willSave = willWrite && args.taintedUpstream !== true
|
|
291
329
|
|
|
292
330
|
// Retain only what is read back. `cache.save` below is the single consumer
|
|
293
331
|
// of `result.stdout`, and it runs only when this task will WRITE an entry;
|
|
@@ -299,7 +337,7 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
299
337
|
// Deferral is decided at plan time and only ever set for a remote-placed,
|
|
300
338
|
// eligibility-cleared task; it suppresses this machine's clean AND save.
|
|
301
339
|
const deferralRequested = args.download === 'deferred'
|
|
302
|
-
const capture: CaptureConfig = { stdout:
|
|
340
|
+
const capture: CaptureConfig = { stdout: willSave, stderr: false }
|
|
303
341
|
|
|
304
342
|
const outputs = cacheCfg?.outputs.files ?? []
|
|
305
343
|
const wsOutputs = cacheCfg?.outputs.workspaceFiles ?? []
|
|
@@ -357,20 +395,12 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
357
395
|
// dependents fold it — and the machine's ambient state (node_modules as
|
|
358
396
|
// installed by the dev) serves dependents exactly as before the field.
|
|
359
397
|
if (args.remoteOnlyNoop === true) {
|
|
360
|
-
// Under a `--verify` proof the noop must be REPORTED, not silent: there
|
|
361
|
-
// is no execution to sandbox or re-run, so the proof cannot cover this
|
|
362
|
-
// task (the eleventh-wave residual — a silently unverified task reads
|
|
363
|
-
// as a green proof over ground the proof never touched).
|
|
364
|
-
const wantsProof =
|
|
365
|
-
args.verify !== undefined &&
|
|
366
|
-
(args.verify.inputs || args.verify.determinism || args.verify.fingerprint)
|
|
367
398
|
return {
|
|
368
399
|
node,
|
|
369
400
|
status: 'success',
|
|
370
401
|
exitCode: 0,
|
|
371
402
|
durationMs: 0,
|
|
372
403
|
hash,
|
|
373
|
-
...(wantsProof ? { verify: { kind: 'unverifiable-remote-only' as const } } : {}),
|
|
374
404
|
wallclockStartNs: taskStartNs,
|
|
375
405
|
wallclockEndNs: process.hrtime.bigint() - args.runStartHrTimeNs,
|
|
376
406
|
}
|
|
@@ -431,18 +461,26 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
431
461
|
})
|
|
432
462
|
: undefined
|
|
433
463
|
const inputs: TaskInputs | undefined = described?.inputs
|
|
464
|
+
// A declared input set that resolves to NOTHING is the quiet stale hit:
|
|
465
|
+
// the key stops moving with this project's source and every later run
|
|
466
|
+
// is a hit. Almost always a glob written against the wrong directory
|
|
467
|
+
// (`lib/**` in a package that builds `src/`). Said once per miss, on the
|
|
468
|
+
// run's status line — the task itself succeeds.
|
|
469
|
+
const declaredInputs = [
|
|
470
|
+
...(cacheCfg?.inputs.files ?? []),
|
|
471
|
+
...(cacheCfg?.inputs.workspaceFiles ?? []),
|
|
472
|
+
]
|
|
473
|
+
if (inputs !== undefined && declaredInputs.length > 0 && inputs.files.length === 0) {
|
|
474
|
+
log.status(
|
|
475
|
+
`[vx] ${node.id}: cache.inputs matched no files (${declaredInputs.join(', ')}) — ` +
|
|
476
|
+
`the key will not change when this project's source does`,
|
|
477
|
+
)
|
|
478
|
+
}
|
|
434
479
|
|
|
435
|
-
// Sandbox is opt-in per task via `sandbox: {}` (or `
|
|
436
|
-
//
|
|
437
|
-
//
|
|
438
|
-
|
|
439
|
-
// task to prove input-completeness (a read outside the declared inputs is a
|
|
440
|
-
// proof failure, not a task failure — so it flags the RUN via the verdict,
|
|
441
|
-
// it does not flip the task's own exit code the way a user-declared sandbox
|
|
442
|
-
// violation does).
|
|
443
|
-
const userSandbox = cfg.sandbox !== undefined
|
|
444
|
-
const verifyInputs = args.verify?.inputs === true && willWrite
|
|
445
|
-
const useSandbox = userSandbox || verifyInputs
|
|
480
|
+
// Sandbox is opt-in per task via `exec.sandbox: {}` (or `{...}`) in the
|
|
481
|
+
// task config. No CLI flag, no workspace inheritance — the task config is
|
|
482
|
+
// the single source of truth, and a violation fails the task (below).
|
|
483
|
+
const userSandbox = cfg.exec?.sandbox !== undefined
|
|
446
484
|
let violations: SandboxViolation[] = []
|
|
447
485
|
|
|
448
486
|
// Cache miss path (or caching disabled), up to `1 + retries` attempts.
|
|
@@ -458,8 +496,8 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
458
496
|
// attempt's partial outputs can't leak into the next; gated on WRITES so a
|
|
459
497
|
// `--no-cache` run leaves the user's tree alone), spawn, and classify the
|
|
460
498
|
// exit (a sandbox violation on a 0 exit → fail; a timeout SIGTERM → the
|
|
461
|
-
// streamed notice). Shared by
|
|
462
|
-
//
|
|
499
|
+
// streamed notice). Shared by every attempt of the retry loop, so they
|
|
500
|
+
// can never drift on the spawn/clean/classify path.
|
|
463
501
|
async function runAttempt(): Promise<{
|
|
464
502
|
result: ExecuteResult
|
|
465
503
|
exitCode: number
|
|
@@ -512,12 +550,16 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
512
550
|
// before the strace pass shipped, false since, and the branch below is
|
|
513
551
|
// live on Linux.) Violations surface via `TaskOutcome.sandboxViolationLines`.
|
|
514
552
|
let code = res.exitCode
|
|
515
|
-
//
|
|
516
|
-
//
|
|
517
|
-
//
|
|
518
|
-
//
|
|
519
|
-
//
|
|
553
|
+
// ANY violation fails the task (owner, 2026-09-05). A sandboxed task
|
|
554
|
+
// declares what it touches; a denial means it touched something else,
|
|
555
|
+
// and an artifact built while reading — or failing to read — something
|
|
556
|
+
// the cache key never folded is not a safe thing to replay. A task that
|
|
557
|
+
// survives the denial is the dangerous case, not the harmless one:
|
|
558
|
+
// that is precisely the run that succeeds and caches a wrong result.
|
|
520
559
|
if (userSandbox && violations.length > 0 && code === 0) code = 1
|
|
560
|
+
// A declared sandbox fails the task on any violation — that is its
|
|
561
|
+
// whole contract. `userSandbox` is the only way a task is sandboxed,
|
|
562
|
+
// so this reads as "sandboxed and it tripped".
|
|
521
563
|
// A child we SIGTERMed for exceeding the timeout is a genuine failure —
|
|
522
564
|
// stream a clear line so the 143 exit reads as a timeout.
|
|
523
565
|
if (res.timedOut) {
|
|
@@ -526,9 +568,8 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
526
568
|
// still exited 0 (`trap 'exit 0' TERM`, a common graceful-shutdown
|
|
527
569
|
// pattern). Without this a timed-out task is classified `success` and its
|
|
528
570
|
// PARTIAL outputs are cached + replayed forever — the run even reports
|
|
529
|
-
// green. The timeout is a real, retryable failure
|
|
530
|
-
//
|
|
531
|
-
// reports 143, so this only rewrites the trap-exit-0 case.
|
|
571
|
+
// green. The timeout is a real, retryable failure; a genuinely-killed
|
|
572
|
+
// child already reports 143, so this only rewrites the trap-exit-0 case.
|
|
532
573
|
if (code === 0) code = signalExitCode('SIGTERM')
|
|
533
574
|
}
|
|
534
575
|
return { result: res, exitCode: code }
|
|
@@ -612,63 +653,14 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
612
653
|
...(cfgCacheable && !(policy.localRead || policy.remoteRead) ? { refresh: true } : {}),
|
|
613
654
|
outputs: { files: outputs, workspaceFiles: wsOutputs },
|
|
614
655
|
}
|
|
615
|
-
if (!
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
// Baseline denyRead = the workspace root, so any read outside the
|
|
619
|
-
// project's declared inputs trips the deny boundary.
|
|
620
|
-
// The user's sandbox block extends each list with explicit additions.
|
|
621
|
-
const resolved = await resolveInputs({
|
|
622
|
-
projectDir: node.projectDir,
|
|
623
|
-
workspaceRoot: args.workspaceRoot,
|
|
624
|
-
envSource: process.env,
|
|
625
|
-
inputs: cacheCfg?.inputs,
|
|
626
|
-
ownOutputs: outputs,
|
|
627
|
-
ownWorkspaceOutputs: wsOutputs,
|
|
628
|
-
nestedProjectDirs: args.nestedProjectDirs,
|
|
629
|
-
...(args.gitFilesCache !== undefined ? { gitFilesCache: args.gitFilesCache } : {}),
|
|
630
|
-
...(args.hashCache !== undefined
|
|
631
|
-
? {
|
|
632
|
-
runtimeCache: args.hashCache.runtime,
|
|
633
|
-
workspaceRuntimeCache: args.hashCache.workspaceRuntime,
|
|
634
|
-
}
|
|
635
|
-
: {}),
|
|
636
|
-
})
|
|
637
|
-
const baseAllowWrite = [
|
|
638
|
-
...outputs.map((g) => path.join(node.projectDir, staticPrefix(g))),
|
|
639
|
-
// Workspace outputs anchor their write prefixes at the root.
|
|
640
|
-
...wsOutputs.map((g) => path.join(args.workspaceRoot, staticPrefix(g))),
|
|
641
|
-
]
|
|
642
|
-
// bwrap can't --bind a non-existent host path; the bind silently
|
|
643
|
-
// becomes a no-op (or a tmpfs that evaporates on exit), and writes
|
|
644
|
-
// to the path appear to succeed inside the sandbox but never land
|
|
645
|
-
// on the host. Pre-create every output path so the binds resolve
|
|
646
|
-
// to real fs entries: globbed outputs (`dist/**`) become empty
|
|
647
|
-
// dirs; literal outputs (`out.txt`) become empty files.
|
|
648
|
-
await prepareOutputsForBind(node.projectDir, outputs)
|
|
649
|
-
await prepareOutputsForBind(args.workspaceRoot, wsOutputs)
|
|
650
|
-
// Output paths are read+write — a task that declares `dist/**` as
|
|
651
|
-
// output expects to read what it just wrote (e.g. `touch dist/x`
|
|
652
|
-
// stats the file; `tsc --incremental` re-reads .tsbuildinfo).
|
|
653
|
-
return {
|
|
654
|
-
...base,
|
|
655
|
-
sandbox: {
|
|
656
|
-
baseAllowRead: [...resolved.files, ...baseAllowWrite],
|
|
657
|
-
baseAllowWrite,
|
|
658
|
-
baseDenyRead: [args.workspaceRoot],
|
|
659
|
-
config: resolveSandboxConfig(cfg.sandbox ?? {}, node.projectDir),
|
|
660
|
-
settleOnCleanExit: verifyInputs,
|
|
661
|
-
},
|
|
662
|
-
}
|
|
656
|
+
if (!userSandbox) return base
|
|
657
|
+
await args.armSandbox?.()
|
|
658
|
+
return { ...base, sandbox: await sandboxRequestFor(node, step.sandbox!, args.workspaceRoot) }
|
|
663
659
|
}
|
|
664
660
|
|
|
665
661
|
const wallclockEndNs = process.hrtime.bigint() - args.runStartHrTimeNs
|
|
666
662
|
|
|
667
|
-
|
|
668
|
-
// captured inside the save block while the tree is attempt-1's.
|
|
669
|
-
let verifyFp1: Map<string, string> | undefined
|
|
670
|
-
|
|
671
|
-
if (effectiveExitCode === 0 && willWrite && deferralRequested) {
|
|
663
|
+
if (effectiveExitCode === 0 && willSave && deferralRequested) {
|
|
672
664
|
// The outputs never landed here: no artifact, no rows. A partial local
|
|
673
665
|
// record (a row with no artifact) is exactly the corrupt-entry shape
|
|
674
666
|
// `restoreOutputs` refuses, so writing none is the only clean answer.
|
|
@@ -684,217 +676,36 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
684
676
|
command: step.command,
|
|
685
677
|
durationMs: result.durationMs,
|
|
686
678
|
stdout: result.stdout,
|
|
679
|
+
...(result.cpuMs !== undefined ? { cpuMs: result.cpuMs } : {}),
|
|
680
|
+
...(result.peakRssBytes !== undefined ? { peakRssBytes: result.peakRssBytes } : {}),
|
|
687
681
|
},
|
|
688
682
|
})
|
|
689
683
|
}
|
|
690
|
-
} else if (effectiveExitCode === 0 &&
|
|
691
|
-
const
|
|
692
|
-
|
|
693
|
-
projectDir: node.projectDir,
|
|
694
|
-
outputs,
|
|
695
|
-
nestedProjectDirs: args.nestedProjectDirs,
|
|
696
|
-
})
|
|
697
|
-
const wsOutputFiles = await resolveWorkspaceOutputs({
|
|
698
|
-
workspaceRoot: args.workspaceRoot,
|
|
699
|
-
outputs: wsOutputs,
|
|
700
|
-
})
|
|
701
|
-
endResolve()
|
|
702
|
-
const endSave = span('miss: save')
|
|
703
|
-
// Tier-3 input fingerprint: the digest rows captured by the pre-exec
|
|
704
|
-
// describe above, persisted with the entry inside `cache.save`'s
|
|
705
|
-
// transaction. Miss path only — the warm/hit path never reaches here.
|
|
706
|
-
await cache.save({
|
|
684
|
+
} else if (effectiveExitCode === 0 && willSave) {
|
|
685
|
+
const { landed } = await saveMiss({
|
|
686
|
+
node,
|
|
707
687
|
hash,
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
688
|
+
cache,
|
|
689
|
+
log,
|
|
690
|
+
workspaceRoot: args.workspaceRoot,
|
|
691
|
+
nestedProjectDirs: args.nestedProjectDirs,
|
|
692
|
+
gitFilesCache: args.gitFilesCache,
|
|
693
|
+
outputs,
|
|
694
|
+
wsOutputs,
|
|
695
|
+
captured,
|
|
696
|
+
command: step.command,
|
|
697
|
+
durationMs: result.durationMs,
|
|
698
|
+
stdout: result.stdout,
|
|
699
|
+
...(result.cpuMs !== undefined ? { cpuMs: result.cpuMs } : {}),
|
|
700
|
+
...(result.peakRssBytes !== undefined ? { peakRssBytes: result.peakRssBytes } : {}),
|
|
701
|
+
outputDirSnapshots: args.outputDirSnapshots,
|
|
702
|
+
deferSave: args.deferSave,
|
|
723
703
|
})
|
|
724
|
-
|
|
725
|
-
{
|
|
726
|
-
const savedDirPrefixes = wholeSubtreePrefixes(outputs)
|
|
727
|
-
if (savedDirPrefixes !== null) {
|
|
728
|
-
await cache.recordOutputDirs?.(hash, node.projectDir, savedDirPrefixes)
|
|
729
|
-
}
|
|
730
|
-
}
|
|
731
|
-
// --verify: fingerprint attempt 1's outputs by CONTENT (raw bytes, never
|
|
732
|
-
// the mtime+size memo). The determinism re-run below compares against it,
|
|
733
|
-
// and the fingerprinting modes (`--verify`/`=all`/`=fingerprint`) fold it
|
|
734
|
-
// into the shipped `outputFp` — the cross-machine diff feed.
|
|
735
|
-
//
|
|
736
|
-
// Gate on DECLARED outputs (`outputs`/`wsOutputs` globs), not the
|
|
737
|
-
// RESOLVED file count: a task that declares `dist/**` but produces zero
|
|
738
|
-
// files this run must still ship the EMPTY tree, or the cross-machine
|
|
739
|
-
// diff can't pair it against a platform where the same glob DID match —
|
|
740
|
-
// a platform-conditional glob producing nothing on one OS is exactly
|
|
741
|
-
// the divergence Phase 4 exists to catch. The empty fold is well-defined
|
|
742
|
-
// (a fixed sentinel tree), so two both-empty platforms stay silent.
|
|
743
|
-
if (
|
|
744
|
-
(args.verify?.fingerprint || args.verify?.determinism) &&
|
|
745
|
-
outputs.length + wsOutputs.length > 0
|
|
746
|
-
) {
|
|
747
|
-
verifyFp1 = await hashOutputTree(
|
|
748
|
-
outputRefs(node.projectDir, outputFiles, args.workspaceRoot, wsOutputFiles),
|
|
749
|
-
)
|
|
750
|
-
}
|
|
751
|
-
// This task just wrote outputs to the project's tree. Record the
|
|
752
|
-
// exact declared-output paths as changed (same as the cache-hit
|
|
753
|
-
// restore path) instead of dropping the whole snapshot: a downstream
|
|
754
|
-
// same-project task then re-spawns git ONLY when its input globs can
|
|
755
|
-
// actually see one of these paths. On a 1000-package cold run this
|
|
756
|
-
// removes ~one synchronous `git ls-files` spawn per project (the
|
|
757
|
-
// single largest cold-run cost — 22% of CPU in profiling). Contract:
|
|
758
|
-
// outputs must be declared — an executed task that writes files
|
|
759
|
-
// outside `cache.outputs.files` which a same-project downstream task
|
|
760
|
-
// reads is undeclared behavior (the restore path already assumes it).
|
|
761
|
-
if (outputFiles.length > 0) {
|
|
762
|
-
args.gitFilesCache?.markOutputsChanged(
|
|
763
|
-
node.projectDir,
|
|
764
|
-
outputFiles.map((p) => path.relative(node.projectDir, p).split(path.sep).join('/')),
|
|
765
|
-
)
|
|
766
|
-
}
|
|
767
|
-
// Declared workspace outputs may have landed inside OTHER
|
|
768
|
-
// projects' dirs (no-boundary escape hatch) — mark the exact
|
|
769
|
-
// paths against every partition that can see them.
|
|
770
|
-
if (wsOutputFiles.length > 0) {
|
|
771
|
-
args.gitFilesCache?.markWorkspaceOutputsChanged(
|
|
772
|
-
args.workspaceRoot,
|
|
773
|
-
wsOutputFiles.map((f) => path.relative(args.workspaceRoot, f).split(path.sep).join('/')),
|
|
774
|
-
)
|
|
775
|
-
}
|
|
776
|
-
// The workspace-wide partition (when one exists) spans this
|
|
777
|
-
// project's subtree, so it inherits the same "undeclared writes
|
|
778
|
-
// are only visible to git" rule as the project drop above.
|
|
779
|
-
if (outputFiles.length + wsOutputFiles.length > 0) {
|
|
780
|
-
args.gitFilesCache?.invalidateWorkspacePartition()
|
|
781
|
-
}
|
|
704
|
+
args.deferredSaves?.set(node.id, landed)
|
|
782
705
|
}
|
|
783
706
|
|
|
784
|
-
// Snapshot the WINNING attempt's sandbox violations before the verify re-run
|
|
785
|
-
// (which reuses `runAttempt` and would clobber `violations`); the outcome
|
|
786
|
-
// must report attempt 1's, not the re-run's.
|
|
787
707
|
const finalViolations = violations
|
|
788
708
|
|
|
789
|
-
let verify: VerifyVerdict | undefined
|
|
790
|
-
|
|
791
|
-
// Input-completeness verification (`vx run --verify=inputs` / `=all`, Phase 2).
|
|
792
|
-
// The task ran under the forced declared-input baseline sandbox. A read of a
|
|
793
|
-
// workspace path outside the declared inputs proves `cache.inputs` is
|
|
794
|
-
// incomplete — a future hit could serve stale bytes when that undeclared file
|
|
795
|
-
// changes. This reds the run via the verdict (see run.ts `ok`), not the task's
|
|
796
|
-
// own exit code. Checked FIRST: if the inputs are wrong, there's no point
|
|
797
|
-
// re-running for determinism — fix the inputs (which changes the key) first.
|
|
798
|
-
if (verifyInputs) {
|
|
799
|
-
if (finalViolations.length > 0) {
|
|
800
|
-
verify = {
|
|
801
|
-
kind: 'undeclared-inputs',
|
|
802
|
-
paths: undeclaredInputPaths(finalViolations, args.workspaceRoot),
|
|
803
|
-
}
|
|
804
|
-
} else if (effectiveExitCode === 0 && !args.verify?.determinism) {
|
|
805
|
-
// Inputs-only run, inputs complete. (`=all` falls through to the stronger
|
|
806
|
-
// determinism verdict below.)
|
|
807
|
-
verify = { kind: 'proven-complete' }
|
|
808
|
-
}
|
|
809
|
-
}
|
|
810
|
-
|
|
811
|
-
// Determinism verification (`vx run --verify` / `=all`). The task executed and
|
|
812
|
-
// saved; re-run it fresh and content-compare its outputs against attempt 1. A
|
|
813
|
-
// divergence proves the task is non-hermetic — its cache entry would replay
|
|
814
|
-
// arbitrary past bytes. The re-run NEVER saves; the canonical (attempt-1)
|
|
815
|
-
// bytes are restored afterward so disk == the cache regardless of verdict.
|
|
816
|
-
if (
|
|
817
|
-
verify === undefined &&
|
|
818
|
-
args.verify?.determinism &&
|
|
819
|
-
effectiveExitCode === 0 &&
|
|
820
|
-
willWrite &&
|
|
821
|
-
!result.timedOut
|
|
822
|
-
) {
|
|
823
|
-
// Determinism verdict is keyed on RESOLVED outputs (unchanged): a task
|
|
824
|
-
// that declares outputs but produced none this run has nothing to
|
|
825
|
-
// content-compare, so it stays `no-outputs` with no re-run — even though
|
|
826
|
-
// `verifyFp1` is now the (empty) declared-outputs fingerprint that
|
|
827
|
-
// `--verify=fingerprint` ships for the cross-machine diff. `verifyFp1`
|
|
828
|
-
// is keyed by resolved output file, so an empty map means "declared but
|
|
829
|
-
// produced nothing" — the in-scope stand-in for the resolved-zero count.
|
|
830
|
-
if (verifyFp1 === undefined || verifyFp1.size === 0) {
|
|
831
|
-
verify = { kind: 'no-outputs' } // cacheable but nothing to replay (e.g. lint)
|
|
832
|
-
} else {
|
|
833
|
-
const rerun = await runAttempt()
|
|
834
|
-
if (rerun.exitCode !== 0 || rerun.result.timedOut) {
|
|
835
|
-
verify = { kind: 'rerun-failed', exitCode: rerun.exitCode }
|
|
836
|
-
} else {
|
|
837
|
-
const fp2 = await hashOutputTree(
|
|
838
|
-
outputRefs(
|
|
839
|
-
node.projectDir,
|
|
840
|
-
await resolveOutputs({
|
|
841
|
-
projectDir: node.projectDir,
|
|
842
|
-
outputs,
|
|
843
|
-
nestedProjectDirs: args.nestedProjectDirs,
|
|
844
|
-
}),
|
|
845
|
-
args.workspaceRoot,
|
|
846
|
-
await resolveWorkspaceOutputs({
|
|
847
|
-
workspaceRoot: args.workspaceRoot,
|
|
848
|
-
outputs: wsOutputs,
|
|
849
|
-
}),
|
|
850
|
-
),
|
|
851
|
-
)
|
|
852
|
-
verify = classifyDeterminism(
|
|
853
|
-
diffOutputTrees(verifyFp1, fp2),
|
|
854
|
-
args.verify.allow.has(node.id),
|
|
855
|
-
)
|
|
856
|
-
}
|
|
857
|
-
// Put attempt 1's saved bytes back so disk == the cached artifact
|
|
858
|
-
// REGARDLESS of the verdict. Mirror the restoreHit sequence exactly:
|
|
859
|
-
// clean the declared globs FIRST (a nondeterministic re-run may have
|
|
860
|
-
// written a stray filename the restore would never overwrite), then
|
|
861
|
-
// restore, then record the exact changed paths so a downstream
|
|
862
|
-
// same-project task's git snapshot can't go stale. fp1's keys ARE
|
|
863
|
-
// attempt 1's saved rels (bare project rels + workspace-outputs/-
|
|
864
|
-
// prefixed ws rels — the artifact namespace).
|
|
865
|
-
let cleanedRels: string[] = []
|
|
866
|
-
let cleanedWsRels: string[] = []
|
|
867
|
-
if (outputs.length > 0) cleanedRels = await cleanOutputs(cleanArgs)
|
|
868
|
-
if (wsOutputs.length > 0) cleanedWsRels = await cleanWorkspaceOutputs(wsCleanArgs)
|
|
869
|
-
await cache.restoreOutputs(
|
|
870
|
-
hash,
|
|
871
|
-
node.projectDir,
|
|
872
|
-
wsOutputs.length > 0 ? args.workspaceRoot : undefined,
|
|
873
|
-
)
|
|
874
|
-
const savedKeys = [...verifyFp1.keys()]
|
|
875
|
-
if (outputs.length > 0) {
|
|
876
|
-
args.gitFilesCache?.markOutputsChanged(node.projectDir, [
|
|
877
|
-
...cleanedRels,
|
|
878
|
-
...savedKeys.filter((k) => !k.startsWith(WORKSPACE_OUTPUT_PREFIX)),
|
|
879
|
-
])
|
|
880
|
-
}
|
|
881
|
-
if (wsOutputs.length > 0) {
|
|
882
|
-
args.gitFilesCache?.markWorkspaceOutputsChanged(args.workspaceRoot, [
|
|
883
|
-
...cleanedWsRels,
|
|
884
|
-
...savedKeys
|
|
885
|
-
.filter((k) => k.startsWith(WORKSPACE_OUTPUT_PREFIX))
|
|
886
|
-
.map((k) => k.slice(WORKSPACE_OUTPUT_PREFIX.length)),
|
|
887
|
-
])
|
|
888
|
-
}
|
|
889
|
-
}
|
|
890
|
-
}
|
|
891
|
-
|
|
892
|
-
// Cross-machine diff feed: ship attempt 1's fingerprint (the SAVED bytes —
|
|
893
|
-
// the determinism re-run above never clobbers verifyFp1) so a connected
|
|
894
|
-
// serve can pair it with other platforms' reports for the same cache key.
|
|
895
|
-
const outputFp =
|
|
896
|
-
args.verify?.fingerprint && verifyFp1 !== undefined ? foldFingerprint(verifyFp1) : undefined
|
|
897
|
-
|
|
898
709
|
return {
|
|
899
710
|
node,
|
|
900
711
|
status: effectiveExitCode === 0 ? 'success' : 'failed',
|
|
@@ -910,8 +721,6 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
910
721
|
: {}),
|
|
911
722
|
wallclockStartNs,
|
|
912
723
|
wallclockEndNs,
|
|
913
|
-
...(verify !== undefined ? { verify } : {}),
|
|
914
|
-
...(outputFp !== undefined ? { outputFp } : {}),
|
|
915
724
|
...(finalViolations.length > 0
|
|
916
725
|
? {
|
|
917
726
|
sandboxViolations: finalViolations.length,
|
|
@@ -921,227 +730,6 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
921
730
|
}
|
|
922
731
|
}
|
|
923
732
|
|
|
924
|
-
export interface RestoreHitArgs {
|
|
925
|
-
args: ExecuteArgs
|
|
926
|
-
hash: string
|
|
927
|
-
hit: CacheEntry
|
|
928
|
-
/** `performance.now()` when the cache op (probe) started — the
|
|
929
|
-
* user-perceived restore duration is measured from here. */
|
|
930
|
-
cacheOpStart: number
|
|
931
|
-
/** ns offset from run start for the outcome's wallclock window. */
|
|
932
|
-
taskStartNs: bigint
|
|
933
|
-
}
|
|
934
|
-
|
|
935
|
-
/**
|
|
936
|
-
* Materialize a confirmed cache hit: decide skip-restore, clean +
|
|
937
|
-
* restore declared outputs, mark the exact changed paths so downstream
|
|
938
|
-
* same-project tasks needn't re-spawn git, replay stored stdout, and
|
|
939
|
-
* build the cache-hit `TaskOutcome`. Extracted from `executeCachedTask`
|
|
940
|
-
* so the local short-circuit can restore a stable-key hit ahead of the
|
|
941
|
-
* schedule using the IDENTICAL logic — there is one restore path, not
|
|
942
|
-
* two that could drift.
|
|
943
|
-
*/
|
|
944
|
-
export async function restoreHit(restore: RestoreHitArgs): Promise<TaskOutcome> {
|
|
945
|
-
const { args, hash, hit, cacheOpStart, taskStartNs } = restore
|
|
946
|
-
const { node, log } = args
|
|
947
|
-
const cacheCfg: CacheConfig | undefined = node.config.cache
|
|
948
|
-
const outputs = cacheCfg?.outputs.files ?? []
|
|
949
|
-
const wsOutputs = cacheCfg?.outputs.workspaceFiles ?? []
|
|
950
|
-
const anyOutputs = outputs.length > 0 || wsOutputs.length > 0
|
|
951
|
-
const dirPrefixes = wholeSubtreePrefixes(outputs)
|
|
952
|
-
const cleanArgs = {
|
|
953
|
-
projectDir: node.projectDir,
|
|
954
|
-
outputs,
|
|
955
|
-
nestedProjectDirs: args.nestedProjectDirs,
|
|
956
|
-
}
|
|
957
|
-
const wsCleanArgs = { workspaceRoot: args.workspaceRoot, outputs: wsOutputs }
|
|
958
|
-
|
|
959
|
-
// "Tree is already current" short-circuit — skip cleanOutputs
|
|
960
|
-
// + restoreOutputs when the on-disk state matches what this
|
|
961
|
-
// entry recorded at save time. Integrity-preserving: we
|
|
962
|
-
// require BOTH that the output-glob walk yields exactly the
|
|
963
|
-
// expected paths (no strays, no missing) AND that every file's
|
|
964
|
-
// (size, mode, mtime) matches the stored fingerprint. Any
|
|
965
|
-
// divergence falls through to a real clean + restore.
|
|
966
|
-
//
|
|
967
|
-
// The output-file fingerprints can't be batch-loaded at
|
|
968
|
-
// prepareRun time — non-leaf task hashes depend on upstream
|
|
969
|
-
// outputs that haven't been written yet, so hashes are
|
|
970
|
-
// necessarily computed mid-run. We do one extra SELECT per
|
|
971
|
-
// cache hit here. Still beats reading the manifest from the
|
|
972
|
-
// tar (decompress + parse) at the same point.
|
|
973
|
-
let skipRestore = false
|
|
974
|
-
if (anyOutputs) {
|
|
975
|
-
const endRows = span('output rows')
|
|
976
|
-
const expected = hit.outputRows ?? args.cache.loadOutputFilesBatch([hash]).get(hash) ?? []
|
|
977
|
-
endRows()
|
|
978
|
-
if (expected.length > 0) {
|
|
979
|
-
// Two namespaces in the rows: bare rels are project outputs,
|
|
980
|
-
// `workspace-outputs/<rel>` rows anchor at the workspace root.
|
|
981
|
-
const projExpected = expected.filter((e) => !e.path.startsWith(WORKSPACE_OUTPUT_PREFIX))
|
|
982
|
-
const wsExpected = expected
|
|
983
|
-
.filter((e) => e.path.startsWith(WORKSPACE_OUTPUT_PREFIX))
|
|
984
|
-
.map((e) => ({ ...e, path: e.path.slice(WORKSPACE_OUTPUT_PREFIX.length) }))
|
|
985
|
-
// The directory short-circuit: for whole-subtree globs, unchanged
|
|
986
|
-
// mtimes on every directory recorded at the last save/restore prove
|
|
987
|
-
// the output SET is unchanged (a file added or removed anywhere the
|
|
988
|
-
// glob could see bumps a recorded directory), so the walk that cost
|
|
989
|
-
// 0.36 ms per warm hit is replaced by a few stats. The per-file
|
|
990
|
-
// fingerprint check below still runs; only the enumeration is skipped.
|
|
991
|
-
let setKnown = false
|
|
992
|
-
if (dirPrefixes !== null && wsOutputs.length === 0 && args.cache.outputDirsCurrent) {
|
|
993
|
-
const endDirs = span('output dirs')
|
|
994
|
-
// Loaded with the entry (batched by getMany, or with the lazy get).
|
|
995
|
-
const dirRows = hit.outputDirRows ?? []
|
|
996
|
-
const covers = dirPrefixes.every((pre) => dirRows.some((r) => r.path === pre))
|
|
997
|
-
setKnown = covers && (await args.cache.outputDirsCurrent!(node.projectDir, dirRows))
|
|
998
|
-
endDirs()
|
|
999
|
-
}
|
|
1000
|
-
let actualAbs: string[]
|
|
1001
|
-
let actualWsAbs: string[]
|
|
1002
|
-
if (setKnown) {
|
|
1003
|
-
actualAbs = projExpected.map((e) => path.join(node.projectDir, e.path))
|
|
1004
|
-
actualWsAbs = []
|
|
1005
|
-
} else {
|
|
1006
|
-
const endGlob = span('output glob')
|
|
1007
|
-
actualAbs = await resolveOutputs({
|
|
1008
|
-
projectDir: node.projectDir,
|
|
1009
|
-
outputs,
|
|
1010
|
-
nestedProjectDirs: args.nestedProjectDirs,
|
|
1011
|
-
})
|
|
1012
|
-
actualWsAbs = await resolveWorkspaceOutputs({
|
|
1013
|
-
workspaceRoot: args.workspaceRoot,
|
|
1014
|
-
outputs: wsOutputs,
|
|
1015
|
-
})
|
|
1016
|
-
endGlob()
|
|
1017
|
-
}
|
|
1018
|
-
const setsMatch = (
|
|
1019
|
-
actual: readonly string[],
|
|
1020
|
-
exp: ReadonlyArray<{ path: string }>,
|
|
1021
|
-
): boolean => {
|
|
1022
|
-
const expSet = new Set(exp.map((e) => e.path))
|
|
1023
|
-
return actual.length === expSet.size && actual.every((r) => expSet.has(r))
|
|
1024
|
-
}
|
|
1025
|
-
const actualRels = actualAbs.map((p) =>
|
|
1026
|
-
path.relative(node.projectDir, p).split(path.sep).join('/'),
|
|
1027
|
-
)
|
|
1028
|
-
const actualWsRels = actualWsAbs.map((p) =>
|
|
1029
|
-
path.relative(args.workspaceRoot, p).split(path.sep).join('/'),
|
|
1030
|
-
)
|
|
1031
|
-
if (setsMatch(actualRels, projExpected) && setsMatch(actualWsRels, wsExpected)) {
|
|
1032
|
-
const endStat = span('output stat')
|
|
1033
|
-
skipRestore =
|
|
1034
|
-
(await args.cache.isOutputsCurrent(node.projectDir, projExpected)) &&
|
|
1035
|
-
(await args.cache.isOutputsCurrent(args.workspaceRoot, wsExpected))
|
|
1036
|
-
endStat()
|
|
1037
|
-
// The walk ran and proved the tree current (rows absent, or a
|
|
1038
|
-
// directory had moved — a benign touch): snapshot the directories
|
|
1039
|
-
// so the next hit skips the walk.
|
|
1040
|
-
if (skipRestore && !setKnown && dirPrefixes !== null) {
|
|
1041
|
-
await args.cache.recordOutputDirs?.(hash, node.projectDir, dirPrefixes)
|
|
1042
|
-
}
|
|
1043
|
-
}
|
|
1044
|
-
}
|
|
1045
|
-
}
|
|
1046
|
-
if (!skipRestore) {
|
|
1047
|
-
let cleanedRels: string[] = []
|
|
1048
|
-
let cleanedWsRels: string[] = []
|
|
1049
|
-
if (outputs.length > 0) cleanedRels = await cleanOutputs(cleanArgs)
|
|
1050
|
-
if (wsOutputs.length > 0) cleanedWsRels = await cleanWorkspaceOutputs(wsCleanArgs)
|
|
1051
|
-
await args.cache.restoreOutputs(hash, node.projectDir, args.workspaceRoot)
|
|
1052
|
-
if (dirPrefixes !== null) {
|
|
1053
|
-
await args.cache.recordOutputDirs?.(hash, node.projectDir, dirPrefixes)
|
|
1054
|
-
}
|
|
1055
|
-
// Restored outputs changed the project's tree — but on this
|
|
1056
|
-
// path we know the EXACT changed paths (wiped declared
|
|
1057
|
-
// outputs + the artifact's files). Record them instead of
|
|
1058
|
-
// dropping the snapshot; downstream same-project tasks only
|
|
1059
|
-
// re-spawn git when their input globs can actually see one of
|
|
1060
|
-
// these paths. The cache-miss save path keeps the
|
|
1061
|
-
// unconditional drop (an executed task may write undeclared
|
|
1062
|
-
// files only git can see).
|
|
1063
|
-
if (outputs.length > 0) {
|
|
1064
|
-
args.gitFilesCache?.markOutputsChanged(node.projectDir, [
|
|
1065
|
-
...cleanedRels,
|
|
1066
|
-
...hit.outputFiles.filter((p) => !p.startsWith(WORKSPACE_OUTPUT_PREFIX)),
|
|
1067
|
-
])
|
|
1068
|
-
}
|
|
1069
|
-
if (wsOutputs.length > 0) {
|
|
1070
|
-
args.gitFilesCache?.markWorkspaceOutputsChanged(args.workspaceRoot, [
|
|
1071
|
-
...cleanedWsRels,
|
|
1072
|
-
...hit.outputFiles
|
|
1073
|
-
.filter((p) => p.startsWith(WORKSPACE_OUTPUT_PREFIX))
|
|
1074
|
-
.map((p) => p.slice(WORKSPACE_OUTPUT_PREFIX.length)),
|
|
1075
|
-
])
|
|
1076
|
-
}
|
|
1077
|
-
}
|
|
1078
|
-
if (hit.stdout) log.taskStdout(node, hit.stdout)
|
|
1079
|
-
const status =
|
|
1080
|
-
hit.exitCode !== 0 ? 'failed' : hit.source === 'remote' ? 'cache-hit-remote' : 'cache-hit'
|
|
1081
|
-
// `restored` distinguishes "we just wrote files to disk" from
|
|
1082
|
-
// "disk already matched the cached snapshot". Drives the
|
|
1083
|
-
// "up-to-date" vs "local-cache" / "remote-cache" label in the
|
|
1084
|
-
// framed block. Only meaningful when at least one output was
|
|
1085
|
-
// declared — no-outputs tasks never materialize anything, so
|
|
1086
|
-
// they're vacuously up-to-date.
|
|
1087
|
-
const restored = !skipRestore && anyOutputs
|
|
1088
|
-
return {
|
|
1089
|
-
node,
|
|
1090
|
-
status,
|
|
1091
|
-
exitCode: hit.exitCode,
|
|
1092
|
-
// What THIS run spent: probe + restore. NOT the stored exec time — see
|
|
1093
|
-
// `storedDurationMs`, which is what the hit skipped.
|
|
1094
|
-
durationMs: Math.round(performance.now() - cacheOpStart),
|
|
1095
|
-
storedDurationMs: hit.durationMs,
|
|
1096
|
-
hash,
|
|
1097
|
-
restored,
|
|
1098
|
-
// Under `--verify`, a cache hit didn't execute this run — so it wasn't
|
|
1099
|
-
// proven. Flag it `not-verified` (use `--force` to re-execute + verify).
|
|
1100
|
-
...(args.verify?.determinism || args.verify?.inputs
|
|
1101
|
-
? { verify: { kind: 'not-verified' as const } }
|
|
1102
|
-
: {}),
|
|
1103
|
-
wallclockStartNs: taskStartNs,
|
|
1104
|
-
wallclockEndNs: process.hrtime.bigint() - args.runStartHrTimeNs,
|
|
1105
|
-
}
|
|
1106
|
-
}
|
|
1107
|
-
|
|
1108
|
-
/**
|
|
1109
|
-
* Ensure each declared output path exists on the host as either an
|
|
1110
|
-
* empty file (for literal output specs) or a directory (for globbed
|
|
1111
|
-
* specs) so bwrap's --bind can find a real fs entry to mount. Without
|
|
1112
|
-
* this, writes inside the sandbox to a non-existent allowWrite path
|
|
1113
|
-
* silently disappear (bwrap creates a tmpfs that evaporates on exit).
|
|
1114
|
-
*
|
|
1115
|
-
* `cleanOutputs` ran just before this in the cache-enabled path, so
|
|
1116
|
-
* we know any stale content was wiped; what's left is to materialize
|
|
1117
|
-
* the empty skeleton.
|
|
1118
|
-
*/
|
|
1119
|
-
async function prepareOutputsForBind(
|
|
1120
|
-
projectDir: string,
|
|
1121
|
-
outputs: readonly string[],
|
|
1122
|
-
): Promise<void> {
|
|
1123
|
-
for (const g of outputs) {
|
|
1124
|
-
const hasWildcard = /[*?[\]]/.test(g)
|
|
1125
|
-
if (hasWildcard) {
|
|
1126
|
-
const abs = path.join(projectDir, staticPrefix(g))
|
|
1127
|
-
await mkdir(abs, { recursive: true })
|
|
1128
|
-
} else {
|
|
1129
|
-
const abs = path.join(projectDir, g)
|
|
1130
|
-
await mkdir(path.dirname(abs), { recursive: true })
|
|
1131
|
-
const f = Bun.file(abs)
|
|
1132
|
-
if (!(await f.exists())) await Bun.write(abs, '')
|
|
1133
|
-
}
|
|
1134
|
-
}
|
|
1135
|
-
}
|
|
1136
|
-
|
|
1137
|
-
/**
|
|
1138
|
-
* Return the longest prefix of a glob that contains no wildcards. Used
|
|
1139
|
-
* to derive an allowWrite path from each `cache.outputs.files` entry:
|
|
1140
|
-
* `dist/**` → `dist`, `build/output.js` → `build/output.js`, `**` →
|
|
1141
|
-
* `.` (the project dir itself). bwrap binds at the directory level so
|
|
1142
|
-
* a file path covers writes to that file; a dir path covers writes
|
|
1143
|
-
* anywhere underneath.
|
|
1144
|
-
*/
|
|
1145
733
|
/**
|
|
1146
734
|
* Build the child-process env for one task. Same arguments at every call site
|
|
1147
735
|
* (persistent + cached). Two `node_modules/.bin` directories are prepended to
|
|
@@ -1164,10 +752,13 @@ function taskEnv(node: TaskNode, step: ExecConfig, workspaceRoot: string): NodeJ
|
|
|
1164
752
|
// Identical when the root is itself a project — dedupe rather than list it
|
|
1165
753
|
// twice, so PATH reads the same either way.
|
|
1166
754
|
if (rootBin !== bins[0]) bins.push(rootBin)
|
|
1167
|
-
|
|
755
|
+
const env = buildIsolatedEnv({
|
|
1168
756
|
passThrough: step.env?.passThrough ?? [],
|
|
1169
757
|
define: step.env?.define ?? {},
|
|
1170
758
|
source: process.env,
|
|
1171
759
|
binPaths: bins,
|
|
1172
760
|
})
|
|
761
|
+
env[VX_RUN_WORKSPACE_ENV] = workspaceRoot
|
|
762
|
+
env[VX_RUN_TASK_ENV] = node.id
|
|
763
|
+
return env
|
|
1173
764
|
}
|