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