@vzn/vx 0.0.16 → 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 +135 -124
- package/index.ts +10 -0
- package/launcher.mjs +10 -11
- package/package.json +11 -7
- package/src/bin.ts +14 -4
- package/src/cache/archive.ts +659 -0
- package/src/cache/cache.ts +769 -1101
- package/src/cache/chained-cache.ts +194 -0
- 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 +24 -5
- package/src/cache/inputs.ts +373 -456
- package/src/cache/layer.ts +604 -0
- package/src/cache/layered-cache.ts +96 -43
- 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/tar-stream.ts +358 -0
- package/src/cache/zstd.ts +160 -0
- package/src/cli/cache.ts +75 -21
- package/src/cli/completions.ts +104 -0
- package/src/cli/core-alias.ts +25 -0
- package/src/cli/help.ts +195 -128
- package/src/cli/index.ts +116 -46
- package/src/cli/info.ts +122 -63
- package/src/cli/init.ts +75 -0
- package/src/cli/last.ts +172 -0
- package/src/cli/lock.ts +2 -1
- package/src/cli/plan-format.ts +72 -6
- package/src/cli/plugin-commands.ts +103 -0
- package/src/cli/run.ts +245 -271
- package/src/cli/select.ts +332 -0
- package/src/cli/show.ts +144 -69
- package/src/cli/upgrade.ts +9 -7
- package/src/cli/watch.ts +858 -110
- package/src/cli/why.ts +223 -0
- package/src/cli/workspace-config.ts +126 -0
- package/src/config.ts +187 -150
- package/src/exec/env.ts +18 -1
- package/src/exec/executor.ts +224 -0
- package/src/exec/index.ts +24 -2
- package/src/exec/local-executor.ts +41 -0
- package/src/exec/runner.ts +166 -40
- package/src/exec/sandbox-binds.ts +182 -0
- package/src/exec/sandbox-paths.ts +50 -0
- package/src/exec/sandbox-runtime.ts +642 -260
- package/src/exec/sandbox-violations.ts +280 -0
- package/src/graph/index.ts +4 -10
- package/src/graph/scheduler.ts +232 -203
- package/src/graph/task-graph.ts +222 -8
- package/src/index.ts +146 -153
- package/src/orchestrator/admission.ts +137 -0
- package/src/orchestrator/deferred-outputs.ts +147 -0
- package/src/orchestrator/doctor.ts +255 -0
- package/src/orchestrator/download-policy.ts +171 -0
- package/src/orchestrator/events.ts +72 -8
- package/src/orchestrator/execute-task.ts +374 -549
- package/src/orchestrator/failure-mode.ts +202 -0
- package/src/orchestrator/framed-output.ts +126 -69
- package/src/orchestrator/history.ts +126 -59
- package/src/orchestrator/hit-restore.ts +221 -0
- package/src/orchestrator/index.ts +77 -93
- package/src/orchestrator/local-shortcircuit.ts +22 -0
- package/src/orchestrator/lockfile-claim.ts +281 -0
- package/src/orchestrator/logger.ts +208 -35
- package/src/orchestrator/metrics.ts +102 -1388
- package/src/orchestrator/miss-save.ts +169 -0
- package/src/orchestrator/options.ts +55 -34
- package/src/orchestrator/persistent.ts +79 -0
- package/src/orchestrator/placement.ts +182 -0
- package/src/orchestrator/plan.ts +144 -1
- package/src/orchestrator/plugin-host.ts +357 -120
- package/src/orchestrator/plugin.ts +298 -58
- package/src/orchestrator/prepare.ts +202 -106
- package/src/orchestrator/projects.ts +259 -0
- package/src/orchestrator/remote-prefetch.ts +12 -8
- package/src/orchestrator/run-artifacts.ts +63 -16
- package/src/orchestrator/run-context.ts +167 -29
- package/src/orchestrator/run-records.ts +149 -0
- package/src/orchestrator/run-report.ts +74 -47
- package/src/orchestrator/run.ts +534 -417
- 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 +129 -31
- package/src/orchestrator/status-line.ts +49 -9
- package/src/orchestrator/summary.ts +90 -26
- package/src/orchestrator/tally.ts +78 -35
- package/src/orchestrator/task-hash.ts +111 -15
- package/src/orchestrator/task-log-buffer.ts +288 -0
- package/src/orchestrator/telemetry-host.ts +34 -6
- package/src/orchestrator/telemetry.ts +182 -36
- package/src/orchestrator/upstream.ts +72 -31
- package/src/util/cgroup.ts +160 -0
- package/src/util/edit-distance.ts +70 -0
- package/src/util/errors.ts +15 -1
- package/src/util/index.ts +15 -2
- package/src/util/num.ts +37 -0
- package/src/util/paths.ts +64 -0
- package/src/util/settle.ts +67 -0
- package/src/util/size.ts +5 -1
- package/src/util/tail.ts +66 -0
- package/src/util/timing.ts +63 -0
- package/src/util/verbs.ts +39 -0
- package/src/workspace/affected.ts +250 -23
- package/src/workspace/config-cache.ts +313 -0
- package/src/workspace/config-eval.ts +217 -0
- package/src/workspace/config-imports.ts +162 -0
- package/src/workspace/config-schema.ts +837 -0
- package/src/workspace/filter.ts +65 -10
- package/src/workspace/fingerprint.ts +53 -4
- package/src/workspace/index.ts +42 -3
- package/src/workspace/lockfile.ts +25 -9
- package/src/workspace/migrate-scripts.ts +134 -0
- package/src/workspace/migration.ts +314 -0
- package/src/workspace/nested-dirs.ts +21 -7
- package/src/workspace/package-graph.ts +114 -41
- package/src/workspace/project-loader.ts +224 -450
- package/src/workspace/workspace.ts +298 -72
- package/src/cache/cas-backend.ts +0 -97
- package/src/cache/digest.ts +0 -47
- package/src/cache/tar.ts +0 -343
- package/src/cli/backend.ts +0 -23
- package/src/cli/mcp-rpc.ts +0 -292
- package/src/cli/mcp.ts +0 -92
- package/src/cli/migrate-nx.ts +0 -450
- package/src/cli/migrate-turbo.ts +0 -364
- package/src/cli/migrate.ts +0 -219
- package/src/orchestrator/devframe-surface.ts +0 -74
- package/src/orchestrator/predict.ts +0 -106
- package/src/orchestrator/protocol.ts +0 -156
- package/src/orchestrator/resources.ts +0 -59
- package/src/orchestrator/run-state.ts +0 -145
- package/src/orchestrator/verify.ts +0 -164
- package/src/orchestrator/wire-render.ts +0 -92
- package/src/orchestrator/wire.ts +0 -212
|
@@ -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,32 +8,36 @@ 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,
|
|
19
|
-
|
|
14
|
+
VX_RUN_TASK_ENV,
|
|
15
|
+
VX_RUN_WORKSPACE_ENV,
|
|
20
16
|
runPersistent,
|
|
21
|
-
|
|
22
|
-
|
|
17
|
+
shellQuote,
|
|
18
|
+
releaseBridges,
|
|
19
|
+
wrapSandboxedCommand,
|
|
20
|
+
signalExitCode,
|
|
21
|
+
type CaptureConfig,
|
|
22
|
+
type ExecuteRequest,
|
|
23
|
+
type ExecuteResult,
|
|
23
24
|
type SandboxViolation,
|
|
25
|
+
type TaskExecutor,
|
|
26
|
+
type TaskInputs,
|
|
24
27
|
} from '../exec/index.js'
|
|
25
|
-
import { isGroupTask, type TaskNode, type TaskOutcome
|
|
26
|
-
import {
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
} from './verify.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'
|
|
35
|
+
import type { DeferredOutputs } from './deferred-outputs.js'
|
|
34
36
|
import type { Logger } from './logger.js'
|
|
35
37
|
import {
|
|
36
38
|
computeGroupHash,
|
|
37
39
|
computeTaskHash,
|
|
40
|
+
describeTaskInputs,
|
|
38
41
|
type HashCache,
|
|
39
42
|
type TaskInputComponent,
|
|
40
43
|
} from './task-hash.js'
|
|
@@ -49,6 +52,32 @@ export interface ExecuteArgs {
|
|
|
49
52
|
cachePolicy?: CachePolicy
|
|
50
53
|
forwardArgs?: readonly string[] | undefined
|
|
51
54
|
log: Logger
|
|
55
|
+
/** The executor this task was PLACED on (run.ts, before scheduling). */
|
|
56
|
+
executor: TaskExecutor
|
|
57
|
+
/**
|
|
58
|
+
* `exec.remote: 'only'` with no remote executor to take it: succeed
|
|
59
|
+
* WITHOUT executing, cleaning, restoring or saving anything on this
|
|
60
|
+
* machine. The hash is still computed — dependents fold it.
|
|
61
|
+
*/
|
|
62
|
+
remoteOnlyNoop?: boolean
|
|
63
|
+
/**
|
|
64
|
+
* `exec.remote: 'only'` placed on a remote executor: execute remotely,
|
|
65
|
+
* but this machine's disk is untouched — no output clean, no vx cache
|
|
66
|
+
* probe/restore/save. The task's outputs live in the REMOTE store and
|
|
67
|
+
* reach dependents' input trees by reference (the executor's job).
|
|
68
|
+
*/
|
|
69
|
+
remoteOnly?: boolean
|
|
70
|
+
/**
|
|
71
|
+
* Plan-time `--download` decision for THIS task (run.ts). `'deferred'`
|
|
72
|
+
* means: do not clean, do not save — the executor is expected to leave
|
|
73
|
+
* the outputs in the remote store and hand back a closure. An executor
|
|
74
|
+
* that ignores it and materialises anyway simply gets no local entry;
|
|
75
|
+
* the outputs are on disk but unindexed, which is its contract to keep.
|
|
76
|
+
*/
|
|
77
|
+
download?: 'eager' | 'deferred'
|
|
78
|
+
/** Run-scoped deferral registry — where deferred closures land, and
|
|
79
|
+
* where a locally-placed consumer fetches its producers from. */
|
|
80
|
+
deferred?: DeferredOutputs
|
|
52
81
|
nestedProjectDirs: string[]
|
|
53
82
|
/** Anchor for hrtime spans across all tasks in this run. */
|
|
54
83
|
runStartHrTimeNs: bigint
|
|
@@ -78,25 +107,6 @@ export interface ExecuteArgs {
|
|
|
78
107
|
* `exec.timeout` wins. Threaded as an option only — never hashed.
|
|
79
108
|
*/
|
|
80
109
|
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
110
|
/** Per-run memo for `git ls-files` (one entry per project dir). */
|
|
101
111
|
gitFilesCache?: GitFilesCache
|
|
102
112
|
/** Per-run memo for derived hashes (package.json bytes + task config). */
|
|
@@ -113,6 +123,37 @@ export interface ExecuteArgs {
|
|
|
113
123
|
* Absent → probe lazily, exactly as today (unstable / unclassified).
|
|
114
124
|
*/
|
|
115
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
|
|
116
157
|
}
|
|
117
158
|
|
|
118
159
|
/**
|
|
@@ -141,6 +182,9 @@ function executeGroupTask(args: ExecuteArgs): TaskOutcome {
|
|
|
141
182
|
exitCode: 0,
|
|
142
183
|
durationMs: 0,
|
|
143
184
|
hash: computeGroupHash(args.upstream),
|
|
185
|
+
// What this group stands for. A dependent expands it to describe the
|
|
186
|
+
// real tasks in its input closure — see `TaskOutcome.groupUpstream`.
|
|
187
|
+
groupUpstream: args.upstream,
|
|
144
188
|
wallclockStartNs: wallclockNs,
|
|
145
189
|
wallclockEndNs: wallclockNs,
|
|
146
190
|
}
|
|
@@ -155,6 +199,9 @@ function executeGroupTask(args: ExecuteArgs): TaskOutcome {
|
|
|
155
199
|
* Never reads or writes the cache — the project loader rejects
|
|
156
200
|
* `cache + persistent` at config-load time, so by the time we get
|
|
157
201
|
* here, `cache` is guaranteed undefined.
|
|
202
|
+
*
|
|
203
|
+
* Never routed through an executor: a persistent task is local by
|
|
204
|
+
* construction (its port lives on this machine).
|
|
158
205
|
*/
|
|
159
206
|
async function executePersistentTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
160
207
|
const { node, log } = args
|
|
@@ -164,20 +211,39 @@ async function executePersistentTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
164
211
|
persistent: NonNullable<ExecConfig['persistent']>
|
|
165
212
|
}
|
|
166
213
|
const effectiveForwardArgs = node.requested ? (args.forwardArgs ?? []) : []
|
|
167
|
-
const env = taskEnv(node, step)
|
|
214
|
+
const env = taskEnv(node, step, args.workspaceRoot)
|
|
168
215
|
const wallclockStartNs = process.hrtime.bigint() - args.runStartHrTimeNs
|
|
169
216
|
|
|
170
217
|
// When readyWhen is set we leave the command untouched so the
|
|
171
218
|
// regex matcher sees the unmodified output. When it's absent the
|
|
172
219
|
// task is "ready on spawn" — we can safely append forwardArgs in
|
|
173
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
|
+
}
|
|
174
245
|
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,
|
|
246
|
+
command,
|
|
181
247
|
cwd: node.projectDir,
|
|
182
248
|
env,
|
|
183
249
|
onStdout: (chunk) => log.taskStdout(node, chunk),
|
|
@@ -195,14 +261,23 @@ async function executePersistentTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
195
261
|
}
|
|
196
262
|
|
|
197
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
|
+
}
|
|
198
273
|
try {
|
|
199
274
|
await spawn.ready
|
|
200
275
|
} catch (err) {
|
|
201
276
|
const message = err instanceof Error ? err.message : String(err)
|
|
202
|
-
//
|
|
203
|
-
//
|
|
204
|
-
//
|
|
205
|
-
|
|
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`)
|
|
206
281
|
return {
|
|
207
282
|
node,
|
|
208
283
|
status: 'failed',
|
|
@@ -241,8 +316,28 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
241
316
|
// on. Output management (clean before exec) keys off writes — so
|
|
242
317
|
// `--no-cache` (all off) leaves the user's tree alone, while `--force`
|
|
243
318
|
// (reads off, writes on) still wipes + repopulates a clean snapshot.
|
|
244
|
-
|
|
245
|
-
|
|
319
|
+
// A remote-only task never touches this machine's disk: no probe/restore
|
|
320
|
+
// (restoring node_modules onto a dev machine is exactly what the field
|
|
321
|
+
// exists to prevent), no output clean, no local artifact save. Its result
|
|
322
|
+
// lives in the remote executor's own store.
|
|
323
|
+
const remoteOnly = args.remoteOnly === true
|
|
324
|
+
const willRead = !remoteOnly && cfgCacheable && (policy.localRead || policy.remoteRead)
|
|
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
|
|
329
|
+
|
|
330
|
+
// Retain only what is read back. `cache.save` below is the single consumer
|
|
331
|
+
// of `result.stdout`, and it runs only when this task will WRITE an entry;
|
|
332
|
+
// `result.stderr` has no consumer at all (a failing task's stderr reaches
|
|
333
|
+
// the user through the live `onStderr` callback, and the cache has never
|
|
334
|
+
// stored stderr — see the v17 artifact format). Both streams are still
|
|
335
|
+
// drained and still stream chunk-by-chunk to the logger; only the retained
|
|
336
|
+
// copy is dropped, which for a chatty task is its full byte size in heap.
|
|
337
|
+
// Deferral is decided at plan time and only ever set for a remote-placed,
|
|
338
|
+
// eligibility-cleared task; it suppresses this machine's clean AND save.
|
|
339
|
+
const deferralRequested = args.download === 'deferred'
|
|
340
|
+
const capture: CaptureConfig = { stdout: willSave, stderr: false }
|
|
246
341
|
|
|
247
342
|
const outputs = cacheCfg?.outputs.files ?? []
|
|
248
343
|
const wsOutputs = cacheCfg?.outputs.workspaceFiles ?? []
|
|
@@ -295,6 +390,22 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
295
390
|
...(args.hashCache !== undefined ? { hashCache: args.hashCache } : {}),
|
|
296
391
|
})
|
|
297
392
|
|
|
393
|
+
// The local no-op half of `exec.remote: 'only'`: no remote executor took
|
|
394
|
+
// the task, so it succeeds without running. The hash was still computed —
|
|
395
|
+
// dependents fold it — and the machine's ambient state (node_modules as
|
|
396
|
+
// installed by the dev) serves dependents exactly as before the field.
|
|
397
|
+
if (args.remoteOnlyNoop === true) {
|
|
398
|
+
return {
|
|
399
|
+
node,
|
|
400
|
+
status: 'success',
|
|
401
|
+
exitCode: 0,
|
|
402
|
+
durationMs: 0,
|
|
403
|
+
hash,
|
|
404
|
+
wallclockStartNs: taskStartNs,
|
|
405
|
+
wallclockEndNs: process.hrtime.bigint() - args.runStartHrTimeNs,
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
298
409
|
const cleanArgs = {
|
|
299
410
|
projectDir: node.projectDir,
|
|
300
411
|
outputs,
|
|
@@ -316,27 +427,60 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
316
427
|
}
|
|
317
428
|
// Confirmed stable miss — skip the probe, fall through to run.
|
|
318
429
|
} else {
|
|
430
|
+
const endProbe = span('cache.get')
|
|
319
431
|
const hit = await cache.get(hash, { taskId: node.id, command: step.command })
|
|
432
|
+
endProbe()
|
|
320
433
|
if (hit) {
|
|
321
434
|
return restoreHit({ args, hash, hit, cacheOpStart, taskStartNs })
|
|
322
435
|
}
|
|
323
436
|
}
|
|
324
437
|
}
|
|
325
438
|
|
|
326
|
-
const env = taskEnv(node, step)
|
|
439
|
+
const env = taskEnv(node, step, args.workspaceRoot)
|
|
327
440
|
const wallclockStartNs = process.hrtime.bigint() - args.runStartHrTimeNs
|
|
328
441
|
|
|
329
|
-
//
|
|
330
|
-
//
|
|
331
|
-
//
|
|
332
|
-
//
|
|
333
|
-
//
|
|
334
|
-
//
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
442
|
+
// Miss path: describe the input set ONCE — the executor gets the values
|
|
443
|
+
// (what a remote worker must reproduce) and the save below reuses the
|
|
444
|
+
// captured digest rows for the Tier-3 `entry_inputs` fingerprint, so the
|
|
445
|
+
// fold runs once here instead of once after the subprocess. The HashCache
|
|
446
|
+
// memos + gitFilesCache OID map make this a fold + array pushes, no I/O.
|
|
447
|
+
// A non-cacheable task folds nothing and ships no inputs.
|
|
448
|
+
const captured: TaskInputComponent[] = []
|
|
449
|
+
const described = cfgCacheable
|
|
450
|
+
? await describeTaskInputs({
|
|
451
|
+
node,
|
|
452
|
+
upstream,
|
|
453
|
+
workspaceRoot: args.workspaceRoot,
|
|
454
|
+
workspaceFingerprint: args.workspaceFingerprint,
|
|
455
|
+
cache,
|
|
456
|
+
forwardArgs: args.forwardArgs,
|
|
457
|
+
nestedProjectDirs: args.nestedProjectDirs,
|
|
458
|
+
captureInto: captured,
|
|
459
|
+
...(args.gitFilesCache !== undefined ? { gitFilesCache: args.gitFilesCache } : {}),
|
|
460
|
+
...(args.hashCache !== undefined ? { hashCache: args.hashCache } : {}),
|
|
461
|
+
})
|
|
462
|
+
: undefined
|
|
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
|
+
}
|
|
479
|
+
|
|
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
|
|
340
484
|
let violations: SandboxViolation[] = []
|
|
341
485
|
|
|
342
486
|
// Cache miss path (or caching disabled), up to `1 + retries` attempts.
|
|
@@ -344,7 +488,7 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
344
488
|
// an explicit `retries: 0`.
|
|
345
489
|
const maxAttempts = 1 + (step.retries ?? args.retries ?? 0)
|
|
346
490
|
let attempt = 0
|
|
347
|
-
let result:
|
|
491
|
+
let result: ExecuteResult
|
|
348
492
|
let effectiveExitCode: number
|
|
349
493
|
|
|
350
494
|
// One task attempt: clean the declared outputs (before EVERY attempt — so a
|
|
@@ -352,41 +496,109 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
352
496
|
// attempt's partial outputs can't leak into the next; gated on WRITES so a
|
|
353
497
|
// `--no-cache` run leaves the user's tree alone), spawn, and classify the
|
|
354
498
|
// exit (a sandbox violation on a 0 exit → fail; a timeout SIGTERM → the
|
|
355
|
-
// streamed notice). Shared by
|
|
356
|
-
//
|
|
499
|
+
// streamed notice). Shared by every attempt of the retry loop, so they
|
|
500
|
+
// can never drift on the spawn/clean/classify path.
|
|
357
501
|
async function runAttempt(): Promise<{
|
|
358
|
-
result:
|
|
502
|
+
result: ExecuteResult
|
|
359
503
|
exitCode: number
|
|
360
504
|
}> {
|
|
361
|
-
|
|
362
|
-
|
|
505
|
+
// A deferred task's outputs are deliberately NOT coming, so wiping the
|
|
506
|
+
// tree would replace a stale build with nothing at all. The eligibility
|
|
507
|
+
// gate guarantees no key in this run can see what stays behind, and the
|
|
508
|
+
// summary names every deferred task so `dist/` is not silently stale.
|
|
509
|
+
if (willWrite && !deferralRequested && outputs.length > 0) {
|
|
510
|
+
// Mark the wiped paths, exactly as the workspace twin below does. The
|
|
511
|
+
// git snapshot still lists them with their committed index OIDs, and
|
|
512
|
+
// resolveFiles SKIPS its existence probe for any path carrying a
|
|
513
|
+
// trusted OID — so a declared output the producer deletes and does not
|
|
514
|
+
// re-create would stay in a same-project consumer's input set, keeping
|
|
515
|
+
// that consumer's key unchanged while the file is gone from disk.
|
|
516
|
+
const endClean = span('miss: clean outputs')
|
|
517
|
+
const cleanedRels = await cleanOutputs(cleanArgs)
|
|
518
|
+
endClean()
|
|
519
|
+
args.gitFilesCache?.markOutputsChanged(node.projectDir, cleanedRels)
|
|
520
|
+
}
|
|
521
|
+
if (willWrite && !deferralRequested && wsOutputs.length > 0) {
|
|
363
522
|
// Root-anchored deletions can land in other projects' dirs; mark
|
|
364
523
|
// them so stale per-project git snapshots can't survive the wipe.
|
|
365
524
|
const cleanedWsRels = await cleanWorkspaceOutputs(wsCleanArgs)
|
|
366
525
|
args.gitFilesCache?.markWorkspaceOutputsChanged(args.workspaceRoot, cleanedWsRels)
|
|
367
526
|
}
|
|
368
527
|
violations = []
|
|
369
|
-
const
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
//
|
|
373
|
-
//
|
|
374
|
-
//
|
|
528
|
+
const endReq = span('miss: build request')
|
|
529
|
+
const req = await buildRequest()
|
|
530
|
+
endReq()
|
|
531
|
+
// An executor that THROWS produces no captured output, so the task's
|
|
532
|
+
// frame would print the command and nothing else while the reason went
|
|
533
|
+
// straight to stderr and scrolled away in a broad run. Put it in the
|
|
534
|
+
// task's own stream first: the frame is where a reader looks for why a
|
|
535
|
+
// task failed, and a remote executor's failures are exactly the ones with
|
|
536
|
+
// no other trace. Rethrown unchanged — the scheduler still classifies it,
|
|
537
|
+
// and still prints it plainly for a UserError.
|
|
538
|
+
const endExec = span('miss: execute')
|
|
539
|
+
const res = await args.executor.execute(req).catch((err: unknown) => {
|
|
540
|
+
const message = err instanceof Error ? err.message : String(err)
|
|
541
|
+
log.taskStderr(node, `${message}\n`)
|
|
542
|
+
throw err
|
|
543
|
+
})
|
|
544
|
+
endExec()
|
|
545
|
+
violations = [...res.violations]
|
|
546
|
+
// Fail-on-violation, on BOTH platforms: macOS reads SRT's structured
|
|
547
|
+
// violation store, Linux parses the strace log the sandboxed spawn
|
|
548
|
+
// writes. (This comment used to claim Linux violations are "always 0"
|
|
549
|
+
// and that enforcement there rests on the child failing naturally — true
|
|
550
|
+
// before the strace pass shipped, false since, and the branch below is
|
|
551
|
+
// live on Linux.) Violations surface via `TaskOutcome.sandboxViolationLines`.
|
|
375
552
|
let code = res.exitCode
|
|
376
|
-
//
|
|
377
|
-
//
|
|
378
|
-
//
|
|
379
|
-
//
|
|
380
|
-
//
|
|
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.
|
|
381
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".
|
|
382
563
|
// A child we SIGTERMed for exceeding the timeout is a genuine failure —
|
|
383
564
|
// stream a clear line so the 143 exit reads as a timeout.
|
|
384
565
|
if (res.timedOut) {
|
|
385
566
|
log.taskStderr(node, `\n[vx] timed out after ${effectiveTimeout}ms — killed (SIGTERM)\n`)
|
|
567
|
+
// Force a non-zero classification even if the child TRAPPED SIGTERM and
|
|
568
|
+
// still exited 0 (`trap 'exit 0' TERM`, a common graceful-shutdown
|
|
569
|
+
// pattern). Without this a timed-out task is classified `success` and its
|
|
570
|
+
// PARTIAL outputs are cached + replayed forever — the run even reports
|
|
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.
|
|
573
|
+
if (code === 0) code = signalExitCode('SIGTERM')
|
|
386
574
|
}
|
|
387
575
|
return { result: res, exitCode: code }
|
|
388
576
|
}
|
|
389
577
|
|
|
578
|
+
// This task is about to run a command HERE, and it missed the cache, so
|
|
579
|
+
// whatever its dependency closure produced has to actually be on disk.
|
|
580
|
+
// Producers left deferred are fetched now — once per run, concurrently,
|
|
581
|
+
// on this task's own worker slot, because they are its real critical
|
|
582
|
+
// path. A remote-placed task needs nothing: its worker grafts the
|
|
583
|
+
// upstream bytes by reference. A cache HIT reads no inputs at all.
|
|
584
|
+
if (args.executor.remote !== true && args.deferred !== undefined) {
|
|
585
|
+
try {
|
|
586
|
+
await args.deferred.materializeFor(node)
|
|
587
|
+
} catch (err) {
|
|
588
|
+
const msg = err instanceof Error ? err.message : String(err)
|
|
589
|
+
log.taskStderr(node, `\n[vx] ${msg}\n`)
|
|
590
|
+
return {
|
|
591
|
+
node,
|
|
592
|
+
status: 'failed',
|
|
593
|
+
exitCode: 1,
|
|
594
|
+
durationMs: 0,
|
|
595
|
+
hash,
|
|
596
|
+
wallclockStartNs: taskStartNs,
|
|
597
|
+
wallclockEndNs: process.hrtime.bigint() - args.runStartHrTimeNs,
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
|
|
390
602
|
for (;;) {
|
|
391
603
|
attempt++
|
|
392
604
|
const a = await runAttempt()
|
|
@@ -418,297 +630,82 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
418
630
|
)
|
|
419
631
|
}
|
|
420
632
|
|
|
421
|
-
async function
|
|
422
|
-
|
|
423
|
-
|
|
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,
|
|
633
|
+
async function buildRequest(): Promise<ExecuteRequest> {
|
|
634
|
+
const base: ExecuteRequest = {
|
|
635
|
+
taskId: node.id,
|
|
442
636
|
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
637
|
command: step.command,
|
|
638
|
+
forwardArgs: effectiveForwardArgs,
|
|
476
639
|
cwd: node.projectDir,
|
|
477
640
|
env,
|
|
478
|
-
|
|
641
|
+
envDefine: step.env?.define ?? {},
|
|
642
|
+
capture,
|
|
479
643
|
onStdout: (chunk) => log.taskStdout(node, chunk),
|
|
480
644
|
onStderr: (chunk) => log.taskStderr(node, chunk),
|
|
481
645
|
...(args.liveChildren !== undefined ? { liveChildren: args.liveChildren } : {}),
|
|
482
646
|
...(effectiveTimeout !== undefined ? { timeoutMs: effectiveTimeout } : {}),
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
647
|
+
...(inputs !== undefined ? { inputs } : {}),
|
|
648
|
+
...(cfgCacheable ? { cacheKey: hash } : {}),
|
|
649
|
+
...(remoteOnly ? { remoteOnly: true } : {}),
|
|
650
|
+
...(deferralRequested ? { download: 'deferred' as const } : {}),
|
|
651
|
+
// `--force`/`--no-cache` reach a remote executor's private record
|
|
652
|
+
// through this flag — the policy gates above only cover vx's OWN cache.
|
|
653
|
+
...(cfgCacheable && !(policy.localRead || policy.remoteRead) ? { refresh: true } : {}),
|
|
654
|
+
outputs: { files: outputs, workspaceFiles: wsOutputs },
|
|
655
|
+
}
|
|
656
|
+
if (!userSandbox) return base
|
|
657
|
+
await args.armSandbox?.()
|
|
658
|
+
return { ...base, sandbox: await sandboxRequestFor(node, step.sandbox!, args.workspaceRoot) }
|
|
491
659
|
}
|
|
492
660
|
|
|
493
661
|
const wallclockEndNs = process.hrtime.bigint() - args.runStartHrTimeNs
|
|
494
662
|
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
await
|
|
663
|
+
if (effectiveExitCode === 0 && willSave && deferralRequested) {
|
|
664
|
+
// The outputs never landed here: no artifact, no rows. A partial local
|
|
665
|
+
// record (a row with no artifact) is exactly the corrupt-entry shape
|
|
666
|
+
// `restoreOutputs` refuses, so writing none is the only clean answer.
|
|
667
|
+
// The closure is what a later local consumer — or a later eager run —
|
|
668
|
+
// pulls the bytes with.
|
|
669
|
+
const materialize = result.outputs?.kind === 'deferred' ? result.outputs.materialize : undefined
|
|
670
|
+
if (materialize !== undefined) {
|
|
671
|
+
args.deferred?.register(node.id, {
|
|
672
|
+
materialize,
|
|
673
|
+
hash,
|
|
674
|
+
entry: {
|
|
675
|
+
taskId: node.id,
|
|
676
|
+
command: step.command,
|
|
677
|
+
durationMs: result.durationMs,
|
|
678
|
+
stdout: result.stdout,
|
|
679
|
+
...(result.cpuMs !== undefined ? { cpuMs: result.cpuMs } : {}),
|
|
680
|
+
...(result.peakRssBytes !== undefined ? { peakRssBytes: result.peakRssBytes } : {}),
|
|
681
|
+
},
|
|
682
|
+
})
|
|
683
|
+
}
|
|
684
|
+
} else if (effectiveExitCode === 0 && willSave) {
|
|
685
|
+
const { landed } = await saveMiss({
|
|
518
686
|
node,
|
|
519
|
-
|
|
520
|
-
workspaceRoot: args.workspaceRoot,
|
|
521
|
-
workspaceFingerprint: args.workspaceFingerprint,
|
|
687
|
+
hash,
|
|
522
688
|
cache,
|
|
523
|
-
|
|
689
|
+
log,
|
|
690
|
+
workspaceRoot: args.workspaceRoot,
|
|
524
691
|
nestedProjectDirs: args.nestedProjectDirs,
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
...(
|
|
534
|
-
|
|
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
|
-
},
|
|
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,
|
|
544
703
|
})
|
|
545
|
-
|
|
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
|
-
}
|
|
704
|
+
args.deferredSaves?.set(node.id, landed)
|
|
596
705
|
}
|
|
597
706
|
|
|
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
707
|
const finalViolations = violations
|
|
602
708
|
|
|
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
709
|
return {
|
|
713
710
|
node,
|
|
714
711
|
status: effectiveExitCode === 0 ? 'success' : 'failed',
|
|
@@ -718,10 +715,12 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
718
715
|
...(attempt > 1 ? { attempts: attempt } : {}),
|
|
719
716
|
...(result.cpuMs !== undefined ? { cpuMs: result.cpuMs } : {}),
|
|
720
717
|
...(result.peakRssBytes !== undefined ? { peakRssBytes: result.peakRssBytes } : {}),
|
|
718
|
+
...(result.where !== undefined ? { where: result.where } : {}),
|
|
719
|
+
...(deferralRequested && result.outputs?.kind === 'deferred'
|
|
720
|
+
? { outputs: 'deferred' as const }
|
|
721
|
+
: {}),
|
|
721
722
|
wallclockStartNs,
|
|
722
723
|
wallclockEndNs,
|
|
723
|
-
...(verify !== undefined ? { verify } : {}),
|
|
724
|
-
...(outputFp !== undefined ? { outputFp } : {}),
|
|
725
724
|
...(finalViolations.length > 0
|
|
726
725
|
? {
|
|
727
726
|
sandboxViolations: finalViolations.length,
|
|
@@ -731,209 +730,35 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
731
730
|
}
|
|
732
731
|
}
|
|
733
732
|
|
|
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
733
|
/**
|
|
746
|
-
*
|
|
747
|
-
*
|
|
748
|
-
*
|
|
749
|
-
*
|
|
750
|
-
*
|
|
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).
|
|
734
|
+
* Build the child-process env for one task. Same arguments at every call site
|
|
735
|
+
* (persistent + cached). Two `node_modules/.bin` directories are prepended to
|
|
736
|
+
* PATH: the project's own, then the WORKSPACE ROOT's. Never a sibling
|
|
737
|
+
* project's, and never an arbitrary ancestor — that is the project-isolation
|
|
738
|
+
* rule, and the root is not a sibling.
|
|
883
739
|
*
|
|
884
|
-
*
|
|
885
|
-
*
|
|
886
|
-
* the
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
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).
|
|
740
|
+
* The root entry is where a monorepo's shared tooling actually lives: declare
|
|
741
|
+
* `oxlint` once as a root devDependency and every member's `.bin` is empty of
|
|
742
|
+
* it. Without the root on PATH such a task exits 127, which is what this
|
|
743
|
+
* repo's own gate did the moment core stopped BEING the root and the two
|
|
744
|
+
* paths stopped coinciding. It also removes a divergence that mattered more:
|
|
745
|
+
* the REAPI executor already rebuilds both entries in the action's command,
|
|
746
|
+
* so a task that resolved on a worker failed on the machine that submitted
|
|
747
|
+
* it. npm/pnpm/yarn all put the ancestor chain on PATH for the same reason.
|
|
931
748
|
*/
|
|
932
|
-
function taskEnv(node: TaskNode, step: ExecConfig): NodeJS.ProcessEnv {
|
|
933
|
-
|
|
749
|
+
function taskEnv(node: TaskNode, step: ExecConfig, workspaceRoot: string): NodeJS.ProcessEnv {
|
|
750
|
+
const bins = [path.join(node.projectDir, 'node_modules', '.bin')]
|
|
751
|
+
const rootBin = path.join(workspaceRoot, 'node_modules', '.bin')
|
|
752
|
+
// Identical when the root is itself a project — dedupe rather than list it
|
|
753
|
+
// twice, so PATH reads the same either way.
|
|
754
|
+
if (rootBin !== bins[0]) bins.push(rootBin)
|
|
755
|
+
const env = buildIsolatedEnv({
|
|
934
756
|
passThrough: step.env?.passThrough ?? [],
|
|
935
757
|
define: step.env?.define ?? {},
|
|
936
758
|
source: process.env,
|
|
937
|
-
binPaths:
|
|
759
|
+
binPaths: bins,
|
|
938
760
|
})
|
|
761
|
+
env[VX_RUN_WORKSPACE_ENV] = workspaceRoot
|
|
762
|
+
env[VX_RUN_TASK_ENV] = node.id
|
|
763
|
+
return env
|
|
939
764
|
}
|