@vzn/vx 0.0.16 → 0.0.18
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/README.md +66 -89
- package/index.ts +10 -0
- package/launcher.mjs +10 -11
- package/package.json +19 -5
- package/plugins/local-cache/index.ts +2 -0
- package/plugins/local-executor/index.ts +2 -0
- package/plugins/schedule-history/index.ts +2 -0
- package/src/bin.ts +2 -2
- package/src/cache/archive.ts +557 -0
- package/src/cache/cache.ts +929 -250
- package/src/cache/cas-backend.ts +20 -2
- package/src/cache/chained-cache.ts +202 -0
- package/src/cache/digest.ts +0 -15
- package/src/cache/index.ts +13 -3
- package/src/cache/inputs.ts +445 -41
- package/src/cache/layered-cache.ts +88 -30
- package/src/cache/tar-stream.ts +358 -0
- package/src/cli/cache.ts +36 -10
- package/src/cli/help.ts +167 -128
- package/src/cli/index.ts +74 -43
- package/src/cli/info.ts +30 -0
- package/src/cli/last.ts +140 -0
- package/src/cli/migrate-emit.ts +21 -0
- package/src/cli/migrate-nx.ts +23 -0
- package/src/cli/migrate-scripts.ts +127 -0
- package/src/cli/migrate-turbo.ts +43 -6
- package/src/cli/migrate.ts +119 -33
- package/src/cli/plan-format.ts +58 -3
- package/src/cli/plugin-commands.ts +96 -0
- package/src/cli/prune.ts +291 -0
- package/src/cli/run.ts +272 -80
- package/src/cli/upgrade.ts +7 -6
- package/src/cli/watch.ts +259 -61
- package/src/cli/why.ts +193 -0
- package/src/config.ts +40 -23
- package/src/exec/env.ts +7 -1
- package/src/exec/executor.ts +239 -0
- package/src/exec/index.ts +19 -1
- package/src/exec/runner.ts +116 -29
- package/src/exec/sandbox-runtime.ts +194 -24
- package/src/graph/index.ts +3 -1
- package/src/graph/scheduler.ts +92 -18
- package/src/graph/task-graph.ts +220 -6
- package/src/index.ts +82 -84
- package/src/orchestrator/deferred-outputs.ts +144 -0
- package/src/orchestrator/download-policy.ts +171 -0
- package/src/orchestrator/events.ts +30 -0
- package/src/orchestrator/execute-task.ts +328 -94
- package/src/orchestrator/failure-mode.ts +61 -0
- package/src/orchestrator/framed-output.ts +87 -35
- package/src/orchestrator/history.ts +78 -31
- package/src/orchestrator/index.ts +43 -90
- package/src/orchestrator/local-shortcircuit.ts +22 -0
- package/src/orchestrator/logger.ts +196 -14
- package/src/orchestrator/metrics.ts +57 -1376
- package/src/orchestrator/missing-plugin.ts +9 -0
- package/src/orchestrator/options.ts +22 -4
- package/src/orchestrator/plan.ts +144 -1
- package/src/orchestrator/plugin-host.ts +199 -119
- package/src/orchestrator/plugin.ts +144 -57
- package/src/orchestrator/prepare.ts +220 -69
- package/src/orchestrator/remote-prefetch.ts +12 -8
- package/src/orchestrator/resources.ts +20 -34
- package/src/orchestrator/run-artifacts.ts +41 -16
- package/src/orchestrator/run-context.ts +167 -29
- package/src/orchestrator/run-report.ts +73 -46
- package/src/orchestrator/run.ts +590 -122
- package/src/orchestrator/stable-keys.ts +74 -33
- package/src/orchestrator/status-line.ts +47 -8
- package/src/orchestrator/summary.ts +22 -1
- package/src/orchestrator/tally.ts +78 -35
- package/src/orchestrator/task-hash.ts +105 -11
- package/src/orchestrator/task-log-buffer.ts +291 -0
- package/src/orchestrator/telemetry-host.ts +24 -6
- package/src/orchestrator/telemetry.ts +181 -21
- package/src/orchestrator/upstream.ts +72 -31
- package/src/orchestrator/verify.ts +12 -2
- package/src/plugins/local-cache/index.ts +11 -0
- package/src/plugins/local-executor/index.ts +45 -0
- package/src/plugins/schedule-history/index.ts +109 -0
- package/src/util/edit-distance.ts +21 -0
- package/src/util/errors.ts +15 -1
- package/src/util/index.ts +7 -2
- package/src/util/num.ts +37 -0
- package/src/util/paths.ts +43 -0
- package/src/util/settle.ts +48 -0
- package/src/util/size.ts +5 -1
- package/src/util/tail.ts +66 -0
- package/src/util/timing.ts +63 -0
- package/src/workspace/affected.ts +142 -15
- package/src/workspace/config-cache.ts +304 -0
- package/src/workspace/config-eval.ts +210 -0
- package/src/workspace/config-imports.ts +161 -0
- package/src/workspace/filter.ts +22 -4
- package/src/workspace/fingerprint.ts +16 -1
- package/src/workspace/index.ts +15 -3
- package/src/workspace/lockfile.ts +13 -2
- package/src/workspace/nested-dirs.ts +21 -7
- package/src/workspace/package-graph.ts +23 -20
- package/src/workspace/project-loader.ts +503 -59
- package/src/workspace/workspace.ts +213 -72
- 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/orchestrator/devframe-surface.ts +0 -74
- package/src/orchestrator/predict.ts +0 -106
- package/src/orchestrator/protocol.ts +0 -156
- package/src/orchestrator/run-state.ts +0 -145
- package/src/orchestrator/wire-render.ts +0 -92
- package/src/orchestrator/wire.ts +0 -212
|
@@ -16,11 +16,16 @@ import {
|
|
|
16
16
|
} from '../cache/index.js'
|
|
17
17
|
import {
|
|
18
18
|
buildIsolatedEnv,
|
|
19
|
-
runCommand,
|
|
20
19
|
runPersistent,
|
|
21
|
-
runSandboxed,
|
|
22
20
|
resolveSandboxConfig,
|
|
21
|
+
shellQuote,
|
|
22
|
+
signalExitCode,
|
|
23
|
+
type CaptureConfig,
|
|
24
|
+
type ExecuteRequest,
|
|
25
|
+
type ExecuteResult,
|
|
23
26
|
type SandboxViolation,
|
|
27
|
+
type TaskExecutor,
|
|
28
|
+
type TaskInputs,
|
|
24
29
|
} from '../exec/index.js'
|
|
25
30
|
import { isGroupTask, type TaskNode, type TaskOutcome, type VerifyVerdict } from '../graph/index.js'
|
|
26
31
|
import {
|
|
@@ -31,10 +36,13 @@ import {
|
|
|
31
36
|
outputRefs,
|
|
32
37
|
undeclaredInputPaths,
|
|
33
38
|
} from './verify.js'
|
|
39
|
+
import { span, staticPrefix, wholeSubtreePrefixes } from '../util/index.js'
|
|
40
|
+
import type { DeferredOutputs } from './deferred-outputs.js'
|
|
34
41
|
import type { Logger } from './logger.js'
|
|
35
42
|
import {
|
|
36
43
|
computeGroupHash,
|
|
37
44
|
computeTaskHash,
|
|
45
|
+
describeTaskInputs,
|
|
38
46
|
type HashCache,
|
|
39
47
|
type TaskInputComponent,
|
|
40
48
|
} from './task-hash.js'
|
|
@@ -49,6 +57,32 @@ export interface ExecuteArgs {
|
|
|
49
57
|
cachePolicy?: CachePolicy
|
|
50
58
|
forwardArgs?: readonly string[] | undefined
|
|
51
59
|
log: Logger
|
|
60
|
+
/** The executor this task was PLACED on (run.ts, before scheduling). */
|
|
61
|
+
executor: TaskExecutor
|
|
62
|
+
/**
|
|
63
|
+
* `exec.remote: 'only'` with no remote executor to take it: succeed
|
|
64
|
+
* WITHOUT executing, cleaning, restoring or saving anything on this
|
|
65
|
+
* machine. The hash is still computed — dependents fold it.
|
|
66
|
+
*/
|
|
67
|
+
remoteOnlyNoop?: boolean
|
|
68
|
+
/**
|
|
69
|
+
* `exec.remote: 'only'` placed on a remote executor: execute remotely,
|
|
70
|
+
* but this machine's disk is untouched — no output clean, no vx cache
|
|
71
|
+
* probe/restore/save. The task's outputs live in the REMOTE store and
|
|
72
|
+
* reach dependents' input trees by reference (the executor's job).
|
|
73
|
+
*/
|
|
74
|
+
remoteOnly?: boolean
|
|
75
|
+
/**
|
|
76
|
+
* Plan-time `--download` decision for THIS task (run.ts). `'deferred'`
|
|
77
|
+
* means: do not clean, do not save — the executor is expected to leave
|
|
78
|
+
* the outputs in the remote store and hand back a closure. An executor
|
|
79
|
+
* that ignores it and materialises anyway simply gets no local entry;
|
|
80
|
+
* the outputs are on disk but unindexed, which is its contract to keep.
|
|
81
|
+
*/
|
|
82
|
+
download?: 'eager' | 'deferred'
|
|
83
|
+
/** Run-scoped deferral registry — where deferred closures land, and
|
|
84
|
+
* where a locally-placed consumer fetches its producers from. */
|
|
85
|
+
deferred?: DeferredOutputs
|
|
52
86
|
nestedProjectDirs: string[]
|
|
53
87
|
/** Anchor for hrtime spans across all tasks in this run. */
|
|
54
88
|
runStartHrTimeNs: bigint
|
|
@@ -141,6 +175,9 @@ function executeGroupTask(args: ExecuteArgs): TaskOutcome {
|
|
|
141
175
|
exitCode: 0,
|
|
142
176
|
durationMs: 0,
|
|
143
177
|
hash: computeGroupHash(args.upstream),
|
|
178
|
+
// What this group stands for. A dependent expands it to describe the
|
|
179
|
+
// real tasks in its input closure — see `TaskOutcome.groupUpstream`.
|
|
180
|
+
groupUpstream: args.upstream,
|
|
144
181
|
wallclockStartNs: wallclockNs,
|
|
145
182
|
wallclockEndNs: wallclockNs,
|
|
146
183
|
}
|
|
@@ -155,6 +192,9 @@ function executeGroupTask(args: ExecuteArgs): TaskOutcome {
|
|
|
155
192
|
* Never reads or writes the cache — the project loader rejects
|
|
156
193
|
* `cache + persistent` at config-load time, so by the time we get
|
|
157
194
|
* here, `cache` is guaranteed undefined.
|
|
195
|
+
*
|
|
196
|
+
* Never routed through an executor: a persistent task is local by
|
|
197
|
+
* construction (its port lives on this machine).
|
|
158
198
|
*/
|
|
159
199
|
async function executePersistentTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
160
200
|
const { node, log } = args
|
|
@@ -164,7 +204,7 @@ async function executePersistentTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
164
204
|
persistent: NonNullable<ExecConfig['persistent']>
|
|
165
205
|
}
|
|
166
206
|
const effectiveForwardArgs = node.requested ? (args.forwardArgs ?? []) : []
|
|
167
|
-
const env = taskEnv(node, step)
|
|
207
|
+
const env = taskEnv(node, step, args.workspaceRoot)
|
|
168
208
|
const wallclockStartNs = process.hrtime.bigint() - args.runStartHrTimeNs
|
|
169
209
|
|
|
170
210
|
// When readyWhen is set we leave the command untouched so the
|
|
@@ -176,7 +216,7 @@ async function executePersistentTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
176
216
|
step.persistent.readyWhen !== undefined
|
|
177
217
|
? step.command
|
|
178
218
|
: effectiveForwardArgs.length > 0
|
|
179
|
-
? step.command + ' ' + effectiveForwardArgs.map(
|
|
219
|
+
? step.command + ' ' + effectiveForwardArgs.map(shellQuote).join(' ')
|
|
180
220
|
: step.command,
|
|
181
221
|
cwd: node.projectDir,
|
|
182
222
|
env,
|
|
@@ -241,8 +281,25 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
241
281
|
// on. Output management (clean before exec) keys off writes — so
|
|
242
282
|
// `--no-cache` (all off) leaves the user's tree alone, while `--force`
|
|
243
283
|
// (reads off, writes on) still wipes + repopulates a clean snapshot.
|
|
244
|
-
|
|
245
|
-
|
|
284
|
+
// A remote-only task never touches this machine's disk: no probe/restore
|
|
285
|
+
// (restoring node_modules onto a dev machine is exactly what the field
|
|
286
|
+
// exists to prevent), no output clean, no local artifact save. Its result
|
|
287
|
+
// lives in the remote executor's own store.
|
|
288
|
+
const remoteOnly = args.remoteOnly === true
|
|
289
|
+
const willRead = !remoteOnly && cfgCacheable && (policy.localRead || policy.remoteRead)
|
|
290
|
+
const willWrite = !remoteOnly && cfgCacheable && (policy.localWrite || policy.remoteWrite)
|
|
291
|
+
|
|
292
|
+
// Retain only what is read back. `cache.save` below is the single consumer
|
|
293
|
+
// of `result.stdout`, and it runs only when this task will WRITE an entry;
|
|
294
|
+
// `result.stderr` has no consumer at all (a failing task's stderr reaches
|
|
295
|
+
// the user through the live `onStderr` callback, and the cache has never
|
|
296
|
+
// stored stderr — see the v17 artifact format). Both streams are still
|
|
297
|
+
// drained and still stream chunk-by-chunk to the logger; only the retained
|
|
298
|
+
// copy is dropped, which for a chatty task is its full byte size in heap.
|
|
299
|
+
// Deferral is decided at plan time and only ever set for a remote-placed,
|
|
300
|
+
// eligibility-cleared task; it suppresses this machine's clean AND save.
|
|
301
|
+
const deferralRequested = args.download === 'deferred'
|
|
302
|
+
const capture: CaptureConfig = { stdout: willWrite, stderr: false }
|
|
246
303
|
|
|
247
304
|
const outputs = cacheCfg?.outputs.files ?? []
|
|
248
305
|
const wsOutputs = cacheCfg?.outputs.workspaceFiles ?? []
|
|
@@ -295,6 +352,30 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
295
352
|
...(args.hashCache !== undefined ? { hashCache: args.hashCache } : {}),
|
|
296
353
|
})
|
|
297
354
|
|
|
355
|
+
// The local no-op half of `exec.remote: 'only'`: no remote executor took
|
|
356
|
+
// the task, so it succeeds without running. The hash was still computed —
|
|
357
|
+
// dependents fold it — and the machine's ambient state (node_modules as
|
|
358
|
+
// installed by the dev) serves dependents exactly as before the field.
|
|
359
|
+
if (args.remoteOnlyNoop === true) {
|
|
360
|
+
// Under a `--verify` proof the noop must be REPORTED, not silent: there
|
|
361
|
+
// is no execution to sandbox or re-run, so the proof cannot cover this
|
|
362
|
+
// task (the eleventh-wave residual — a silently unverified task reads
|
|
363
|
+
// as a green proof over ground the proof never touched).
|
|
364
|
+
const wantsProof =
|
|
365
|
+
args.verify !== undefined &&
|
|
366
|
+
(args.verify.inputs || args.verify.determinism || args.verify.fingerprint)
|
|
367
|
+
return {
|
|
368
|
+
node,
|
|
369
|
+
status: 'success',
|
|
370
|
+
exitCode: 0,
|
|
371
|
+
durationMs: 0,
|
|
372
|
+
hash,
|
|
373
|
+
...(wantsProof ? { verify: { kind: 'unverifiable-remote-only' as const } } : {}),
|
|
374
|
+
wallclockStartNs: taskStartNs,
|
|
375
|
+
wallclockEndNs: process.hrtime.bigint() - args.runStartHrTimeNs,
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
298
379
|
const cleanArgs = {
|
|
299
380
|
projectDir: node.projectDir,
|
|
300
381
|
outputs,
|
|
@@ -316,16 +397,41 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
316
397
|
}
|
|
317
398
|
// Confirmed stable miss — skip the probe, fall through to run.
|
|
318
399
|
} else {
|
|
400
|
+
const endProbe = span('cache.get')
|
|
319
401
|
const hit = await cache.get(hash, { taskId: node.id, command: step.command })
|
|
402
|
+
endProbe()
|
|
320
403
|
if (hit) {
|
|
321
404
|
return restoreHit({ args, hash, hit, cacheOpStart, taskStartNs })
|
|
322
405
|
}
|
|
323
406
|
}
|
|
324
407
|
}
|
|
325
408
|
|
|
326
|
-
const env = taskEnv(node, step)
|
|
409
|
+
const env = taskEnv(node, step, args.workspaceRoot)
|
|
327
410
|
const wallclockStartNs = process.hrtime.bigint() - args.runStartHrTimeNs
|
|
328
411
|
|
|
412
|
+
// Miss path: describe the input set ONCE — the executor gets the values
|
|
413
|
+
// (what a remote worker must reproduce) and the save below reuses the
|
|
414
|
+
// captured digest rows for the Tier-3 `entry_inputs` fingerprint, so the
|
|
415
|
+
// fold runs once here instead of once after the subprocess. The HashCache
|
|
416
|
+
// memos + gitFilesCache OID map make this a fold + array pushes, no I/O.
|
|
417
|
+
// A non-cacheable task folds nothing and ships no inputs.
|
|
418
|
+
const captured: TaskInputComponent[] = []
|
|
419
|
+
const described = cfgCacheable
|
|
420
|
+
? await describeTaskInputs({
|
|
421
|
+
node,
|
|
422
|
+
upstream,
|
|
423
|
+
workspaceRoot: args.workspaceRoot,
|
|
424
|
+
workspaceFingerprint: args.workspaceFingerprint,
|
|
425
|
+
cache,
|
|
426
|
+
forwardArgs: args.forwardArgs,
|
|
427
|
+
nestedProjectDirs: args.nestedProjectDirs,
|
|
428
|
+
captureInto: captured,
|
|
429
|
+
...(args.gitFilesCache !== undefined ? { gitFilesCache: args.gitFilesCache } : {}),
|
|
430
|
+
...(args.hashCache !== undefined ? { hashCache: args.hashCache } : {}),
|
|
431
|
+
})
|
|
432
|
+
: undefined
|
|
433
|
+
const inputs: TaskInputs | undefined = described?.inputs
|
|
434
|
+
|
|
329
435
|
// Sandbox is opt-in per task via `sandbox: {}` (or `sandbox: {...}`)
|
|
330
436
|
// in the task config. No CLI flag, no workspace inheritance — the
|
|
331
437
|
// task config is the single source of truth. EXCEPTION: `--verify=inputs`
|
|
@@ -344,7 +450,7 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
344
450
|
// an explicit `retries: 0`.
|
|
345
451
|
const maxAttempts = 1 + (step.retries ?? args.retries ?? 0)
|
|
346
452
|
let attempt = 0
|
|
347
|
-
let result:
|
|
453
|
+
let result: ExecuteResult
|
|
348
454
|
let effectiveExitCode: number
|
|
349
455
|
|
|
350
456
|
// One task attempt: clean the declared outputs (before EVERY attempt — so a
|
|
@@ -355,23 +461,56 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
355
461
|
// streamed notice). Shared by the retry loop AND the `--verify` re-run so
|
|
356
462
|
// the two can never drift on the spawn/clean/classify path.
|
|
357
463
|
async function runAttempt(): Promise<{
|
|
358
|
-
result:
|
|
464
|
+
result: ExecuteResult
|
|
359
465
|
exitCode: number
|
|
360
466
|
}> {
|
|
361
|
-
|
|
362
|
-
|
|
467
|
+
// A deferred task's outputs are deliberately NOT coming, so wiping the
|
|
468
|
+
// tree would replace a stale build with nothing at all. The eligibility
|
|
469
|
+
// gate guarantees no key in this run can see what stays behind, and the
|
|
470
|
+
// summary names every deferred task so `dist/` is not silently stale.
|
|
471
|
+
if (willWrite && !deferralRequested && outputs.length > 0) {
|
|
472
|
+
// Mark the wiped paths, exactly as the workspace twin below does. The
|
|
473
|
+
// git snapshot still lists them with their committed index OIDs, and
|
|
474
|
+
// resolveFiles SKIPS its existence probe for any path carrying a
|
|
475
|
+
// trusted OID — so a declared output the producer deletes and does not
|
|
476
|
+
// re-create would stay in a same-project consumer's input set, keeping
|
|
477
|
+
// that consumer's key unchanged while the file is gone from disk.
|
|
478
|
+
const endClean = span('miss: clean outputs')
|
|
479
|
+
const cleanedRels = await cleanOutputs(cleanArgs)
|
|
480
|
+
endClean()
|
|
481
|
+
args.gitFilesCache?.markOutputsChanged(node.projectDir, cleanedRels)
|
|
482
|
+
}
|
|
483
|
+
if (willWrite && !deferralRequested && wsOutputs.length > 0) {
|
|
363
484
|
// Root-anchored deletions can land in other projects' dirs; mark
|
|
364
485
|
// them so stale per-project git snapshots can't survive the wipe.
|
|
365
486
|
const cleanedWsRels = await cleanWorkspaceOutputs(wsCleanArgs)
|
|
366
487
|
args.gitFilesCache?.markWorkspaceOutputsChanged(args.workspaceRoot, cleanedWsRels)
|
|
367
488
|
}
|
|
368
489
|
violations = []
|
|
369
|
-
const
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
//
|
|
373
|
-
//
|
|
374
|
-
//
|
|
490
|
+
const endReq = span('miss: build request')
|
|
491
|
+
const req = await buildRequest()
|
|
492
|
+
endReq()
|
|
493
|
+
// An executor that THROWS produces no captured output, so the task's
|
|
494
|
+
// frame would print the command and nothing else while the reason went
|
|
495
|
+
// straight to stderr and scrolled away in a broad run. Put it in the
|
|
496
|
+
// task's own stream first: the frame is where a reader looks for why a
|
|
497
|
+
// task failed, and a remote executor's failures are exactly the ones with
|
|
498
|
+
// no other trace. Rethrown unchanged — the scheduler still classifies it,
|
|
499
|
+
// and still prints it plainly for a UserError.
|
|
500
|
+
const endExec = span('miss: execute')
|
|
501
|
+
const res = await args.executor.execute(req).catch((err: unknown) => {
|
|
502
|
+
const message = err instanceof Error ? err.message : String(err)
|
|
503
|
+
log.taskStderr(node, `${message}\n`)
|
|
504
|
+
throw err
|
|
505
|
+
})
|
|
506
|
+
endExec()
|
|
507
|
+
violations = [...res.violations]
|
|
508
|
+
// Fail-on-violation, on BOTH platforms: macOS reads SRT's structured
|
|
509
|
+
// violation store, Linux parses the strace log the sandboxed spawn
|
|
510
|
+
// writes. (This comment used to claim Linux violations are "always 0"
|
|
511
|
+
// and that enforcement there rests on the child failing naturally — true
|
|
512
|
+
// before the strace pass shipped, false since, and the branch below is
|
|
513
|
+
// live on Linux.) Violations surface via `TaskOutcome.sandboxViolationLines`.
|
|
375
514
|
let code = res.exitCode
|
|
376
515
|
// A USER-declared sandbox fails the task on any violation (that's its
|
|
377
516
|
// fail-on-violation contract). A sandbox forced on ONLY by `--verify=inputs`
|
|
@@ -383,10 +522,42 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
383
522
|
// stream a clear line so the 143 exit reads as a timeout.
|
|
384
523
|
if (res.timedOut) {
|
|
385
524
|
log.taskStderr(node, `\n[vx] timed out after ${effectiveTimeout}ms — killed (SIGTERM)\n`)
|
|
525
|
+
// Force a non-zero classification even if the child TRAPPED SIGTERM and
|
|
526
|
+
// still exited 0 (`trap 'exit 0' TERM`, a common graceful-shutdown
|
|
527
|
+
// pattern). Without this a timed-out task is classified `success` and its
|
|
528
|
+
// PARTIAL outputs are cached + replayed forever — the run even reports
|
|
529
|
+
// green. The timeout is a real, retryable failure (matches the
|
|
530
|
+
// `--verify && !result.timedOut` guard); a genuinely-killed child already
|
|
531
|
+
// reports 143, so this only rewrites the trap-exit-0 case.
|
|
532
|
+
if (code === 0) code = signalExitCode('SIGTERM')
|
|
386
533
|
}
|
|
387
534
|
return { result: res, exitCode: code }
|
|
388
535
|
}
|
|
389
536
|
|
|
537
|
+
// This task is about to run a command HERE, and it missed the cache, so
|
|
538
|
+
// whatever its dependency closure produced has to actually be on disk.
|
|
539
|
+
// Producers left deferred are fetched now — once per run, concurrently,
|
|
540
|
+
// on this task's own worker slot, because they are its real critical
|
|
541
|
+
// path. A remote-placed task needs nothing: its worker grafts the
|
|
542
|
+
// upstream bytes by reference. A cache HIT reads no inputs at all.
|
|
543
|
+
if (args.executor.remote !== true && args.deferred !== undefined) {
|
|
544
|
+
try {
|
|
545
|
+
await args.deferred.materializeFor(node)
|
|
546
|
+
} catch (err) {
|
|
547
|
+
const msg = err instanceof Error ? err.message : String(err)
|
|
548
|
+
log.taskStderr(node, `\n[vx] ${msg}\n`)
|
|
549
|
+
return {
|
|
550
|
+
node,
|
|
551
|
+
status: 'failed',
|
|
552
|
+
exitCode: 1,
|
|
553
|
+
durationMs: 0,
|
|
554
|
+
hash,
|
|
555
|
+
wallclockStartNs: taskStartNs,
|
|
556
|
+
wallclockEndNs: process.hrtime.bigint() - args.runStartHrTimeNs,
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
|
|
390
561
|
for (;;) {
|
|
391
562
|
attempt++
|
|
392
563
|
const a = await runAttempt()
|
|
@@ -418,20 +589,30 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
418
589
|
)
|
|
419
590
|
}
|
|
420
591
|
|
|
421
|
-
async function
|
|
422
|
-
|
|
592
|
+
async function buildRequest(): Promise<ExecuteRequest> {
|
|
593
|
+
const base: ExecuteRequest = {
|
|
594
|
+
taskId: node.id,
|
|
595
|
+
workspaceRoot: args.workspaceRoot,
|
|
423
596
|
command: step.command,
|
|
597
|
+
forwardArgs: effectiveForwardArgs,
|
|
424
598
|
cwd: node.projectDir,
|
|
425
599
|
env,
|
|
426
|
-
|
|
600
|
+
envDefine: step.env?.define ?? {},
|
|
601
|
+
capture,
|
|
427
602
|
onStdout: (chunk) => log.taskStdout(node, chunk),
|
|
428
603
|
onStderr: (chunk) => log.taskStderr(node, chunk),
|
|
429
604
|
...(args.liveChildren !== undefined ? { liveChildren: args.liveChildren } : {}),
|
|
430
605
|
...(effectiveTimeout !== undefined ? { timeoutMs: effectiveTimeout } : {}),
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
606
|
+
...(inputs !== undefined ? { inputs } : {}),
|
|
607
|
+
...(cfgCacheable ? { cacheKey: hash } : {}),
|
|
608
|
+
...(remoteOnly ? { remoteOnly: true } : {}),
|
|
609
|
+
...(deferralRequested ? { download: 'deferred' as const } : {}),
|
|
610
|
+
// `--force`/`--no-cache` reach a remote executor's private record
|
|
611
|
+
// through this flag — the policy gates above only cover vx's OWN cache.
|
|
612
|
+
...(cfgCacheable && !(policy.localRead || policy.remoteRead) ? { refresh: true } : {}),
|
|
613
|
+
outputs: { files: outputs, workspaceFiles: wsOutputs },
|
|
614
|
+
}
|
|
615
|
+
if (!useSandbox) return base
|
|
435
616
|
// Baseline allowRead = resolved cache.inputs.files (absolute paths)
|
|
436
617
|
// Baseline allowWrite = static prefix of every cache.outputs.files glob
|
|
437
618
|
// Baseline denyRead = the workspace root, so any read outside the
|
|
@@ -468,26 +649,17 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
468
649
|
await prepareOutputsForBind(args.workspaceRoot, wsOutputs)
|
|
469
650
|
// Output paths are read+write — a task that declares `dist/**` as
|
|
470
651
|
// output expects to read what it just wrote (e.g. `touch dist/x`
|
|
471
|
-
// stats the file; `tsc --incremental` re-reads .tsbuildinfo).
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
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
|
|
652
|
+
// stats the file; `tsc --incremental` re-reads .tsbuildinfo).
|
|
653
|
+
return {
|
|
654
|
+
...base,
|
|
655
|
+
sandbox: {
|
|
656
|
+
baseAllowRead: [...resolved.files, ...baseAllowWrite],
|
|
657
|
+
baseAllowWrite,
|
|
658
|
+
baseDenyRead: [args.workspaceRoot],
|
|
659
|
+
config: resolveSandboxConfig(cfg.sandbox ?? {}, node.projectDir),
|
|
660
|
+
settleOnCleanExit: verifyInputs,
|
|
661
|
+
},
|
|
662
|
+
}
|
|
491
663
|
}
|
|
492
664
|
|
|
493
665
|
const wallclockEndNs = process.hrtime.bigint() - args.runStartHrTimeNs
|
|
@@ -496,7 +668,27 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
496
668
|
// captured inside the save block while the tree is attempt-1's.
|
|
497
669
|
let verifyFp1: Map<string, string> | undefined
|
|
498
670
|
|
|
499
|
-
if (effectiveExitCode === 0 && willWrite) {
|
|
671
|
+
if (effectiveExitCode === 0 && willWrite && deferralRequested) {
|
|
672
|
+
// The outputs never landed here: no artifact, no rows. A partial local
|
|
673
|
+
// record (a row with no artifact) is exactly the corrupt-entry shape
|
|
674
|
+
// `restoreOutputs` refuses, so writing none is the only clean answer.
|
|
675
|
+
// The closure is what a later local consumer — or a later eager run —
|
|
676
|
+
// pulls the bytes with.
|
|
677
|
+
const materialize = result.outputs?.kind === 'deferred' ? result.outputs.materialize : undefined
|
|
678
|
+
if (materialize !== undefined) {
|
|
679
|
+
args.deferred?.register(node.id, {
|
|
680
|
+
materialize,
|
|
681
|
+
hash,
|
|
682
|
+
entry: {
|
|
683
|
+
taskId: node.id,
|
|
684
|
+
command: step.command,
|
|
685
|
+
durationMs: result.durationMs,
|
|
686
|
+
stdout: result.stdout,
|
|
687
|
+
},
|
|
688
|
+
})
|
|
689
|
+
}
|
|
690
|
+
} else if (effectiveExitCode === 0 && willWrite) {
|
|
691
|
+
const endResolve = span('miss: resolve outputs')
|
|
500
692
|
const outputFiles = await resolveOutputs({
|
|
501
693
|
projectDir: node.projectDir,
|
|
502
694
|
outputs,
|
|
@@ -506,26 +698,11 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
506
698
|
workspaceRoot: args.workspaceRoot,
|
|
507
699
|
outputs: wsOutputs,
|
|
508
700
|
})
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
//
|
|
512
|
-
//
|
|
513
|
-
//
|
|
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
|
-
})
|
|
701
|
+
endResolve()
|
|
702
|
+
const endSave = span('miss: save')
|
|
703
|
+
// Tier-3 input fingerprint: the digest rows captured by the pre-exec
|
|
704
|
+
// describe above, persisted with the entry inside `cache.save`'s
|
|
705
|
+
// transaction. Miss path only — the warm/hit path never reaches here.
|
|
529
706
|
await cache.save({
|
|
530
707
|
hash,
|
|
531
708
|
projectDir: node.projectDir,
|
|
@@ -534,14 +711,23 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
534
711
|
? { workspaceOutputFiles: wsOutputFiles, workspaceRoot: args.workspaceRoot }
|
|
535
712
|
: {}),
|
|
536
713
|
inputComponents: captured.map((c) => ({ entryHash: hash, ...c })),
|
|
714
|
+
// No `exitCode`: the save only runs under `effectiveExitCode === 0`, and
|
|
715
|
+
// the contract no longer accepts one — so the invariant is enforced by
|
|
716
|
+
// the type rather than by every call site remembering the gate.
|
|
537
717
|
entry: {
|
|
538
718
|
taskId: node.id,
|
|
539
719
|
command: step.command,
|
|
540
|
-
exitCode: effectiveExitCode,
|
|
541
720
|
durationMs: result.durationMs,
|
|
542
721
|
stdout: result.stdout,
|
|
543
722
|
},
|
|
544
723
|
})
|
|
724
|
+
endSave()
|
|
725
|
+
{
|
|
726
|
+
const savedDirPrefixes = wholeSubtreePrefixes(outputs)
|
|
727
|
+
if (savedDirPrefixes !== null) {
|
|
728
|
+
await cache.recordOutputDirs?.(hash, node.projectDir, savedDirPrefixes)
|
|
729
|
+
}
|
|
730
|
+
}
|
|
545
731
|
// --verify: fingerprint attempt 1's outputs by CONTENT (raw bytes, never
|
|
546
732
|
// the mtime+size memo). The determinism re-run below compares against it,
|
|
547
733
|
// and the fingerprinting modes (`--verify`/`=all`/`=fingerprint`) fold it
|
|
@@ -718,6 +904,10 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
718
904
|
...(attempt > 1 ? { attempts: attempt } : {}),
|
|
719
905
|
...(result.cpuMs !== undefined ? { cpuMs: result.cpuMs } : {}),
|
|
720
906
|
...(result.peakRssBytes !== undefined ? { peakRssBytes: result.peakRssBytes } : {}),
|
|
907
|
+
...(result.where !== undefined ? { where: result.where } : {}),
|
|
908
|
+
...(deferralRequested && result.outputs?.kind === 'deferred'
|
|
909
|
+
? { outputs: 'deferred' as const }
|
|
910
|
+
: {}),
|
|
721
911
|
wallclockStartNs,
|
|
722
912
|
wallclockEndNs,
|
|
723
913
|
...(verify !== undefined ? { verify } : {}),
|
|
@@ -758,6 +948,7 @@ export async function restoreHit(restore: RestoreHitArgs): Promise<TaskOutcome>
|
|
|
758
948
|
const outputs = cacheCfg?.outputs.files ?? []
|
|
759
949
|
const wsOutputs = cacheCfg?.outputs.workspaceFiles ?? []
|
|
760
950
|
const anyOutputs = outputs.length > 0 || wsOutputs.length > 0
|
|
951
|
+
const dirPrefixes = wholeSubtreePrefixes(outputs)
|
|
761
952
|
const cleanArgs = {
|
|
762
953
|
projectDir: node.projectDir,
|
|
763
954
|
outputs,
|
|
@@ -781,7 +972,9 @@ export async function restoreHit(restore: RestoreHitArgs): Promise<TaskOutcome>
|
|
|
781
972
|
// tar (decompress + parse) at the same point.
|
|
782
973
|
let skipRestore = false
|
|
783
974
|
if (anyOutputs) {
|
|
784
|
-
const
|
|
975
|
+
const endRows = span('output rows')
|
|
976
|
+
const expected = hit.outputRows ?? args.cache.loadOutputFilesBatch([hash]).get(hash) ?? []
|
|
977
|
+
endRows()
|
|
785
978
|
if (expected.length > 0) {
|
|
786
979
|
// Two namespaces in the rows: bare rels are project outputs,
|
|
787
980
|
// `workspace-outputs/<rel>` rows anchor at the workspace root.
|
|
@@ -789,15 +982,39 @@ export async function restoreHit(restore: RestoreHitArgs): Promise<TaskOutcome>
|
|
|
789
982
|
const wsExpected = expected
|
|
790
983
|
.filter((e) => e.path.startsWith(WORKSPACE_OUTPUT_PREFIX))
|
|
791
984
|
.map((e) => ({ ...e, path: e.path.slice(WORKSPACE_OUTPUT_PREFIX.length) }))
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
985
|
+
// The directory short-circuit: for whole-subtree globs, unchanged
|
|
986
|
+
// mtimes on every directory recorded at the last save/restore prove
|
|
987
|
+
// the output SET is unchanged (a file added or removed anywhere the
|
|
988
|
+
// glob could see bumps a recorded directory), so the walk that cost
|
|
989
|
+
// 0.36 ms per warm hit is replaced by a few stats. The per-file
|
|
990
|
+
// fingerprint check below still runs; only the enumeration is skipped.
|
|
991
|
+
let setKnown = false
|
|
992
|
+
if (dirPrefixes !== null && wsOutputs.length === 0 && args.cache.outputDirsCurrent) {
|
|
993
|
+
const endDirs = span('output dirs')
|
|
994
|
+
// Loaded with the entry (batched by getMany, or with the lazy get).
|
|
995
|
+
const dirRows = hit.outputDirRows ?? []
|
|
996
|
+
const covers = dirPrefixes.every((pre) => dirRows.some((r) => r.path === pre))
|
|
997
|
+
setKnown = covers && (await args.cache.outputDirsCurrent!(node.projectDir, dirRows))
|
|
998
|
+
endDirs()
|
|
999
|
+
}
|
|
1000
|
+
let actualAbs: string[]
|
|
1001
|
+
let actualWsAbs: string[]
|
|
1002
|
+
if (setKnown) {
|
|
1003
|
+
actualAbs = projExpected.map((e) => path.join(node.projectDir, e.path))
|
|
1004
|
+
actualWsAbs = []
|
|
1005
|
+
} else {
|
|
1006
|
+
const endGlob = span('output glob')
|
|
1007
|
+
actualAbs = await resolveOutputs({
|
|
1008
|
+
projectDir: node.projectDir,
|
|
1009
|
+
outputs,
|
|
1010
|
+
nestedProjectDirs: args.nestedProjectDirs,
|
|
1011
|
+
})
|
|
1012
|
+
actualWsAbs = await resolveWorkspaceOutputs({
|
|
1013
|
+
workspaceRoot: args.workspaceRoot,
|
|
1014
|
+
outputs: wsOutputs,
|
|
1015
|
+
})
|
|
1016
|
+
endGlob()
|
|
1017
|
+
}
|
|
801
1018
|
const setsMatch = (
|
|
802
1019
|
actual: readonly string[],
|
|
803
1020
|
exp: ReadonlyArray<{ path: string }>,
|
|
@@ -812,9 +1029,17 @@ export async function restoreHit(restore: RestoreHitArgs): Promise<TaskOutcome>
|
|
|
812
1029
|
path.relative(args.workspaceRoot, p).split(path.sep).join('/'),
|
|
813
1030
|
)
|
|
814
1031
|
if (setsMatch(actualRels, projExpected) && setsMatch(actualWsRels, wsExpected)) {
|
|
1032
|
+
const endStat = span('output stat')
|
|
815
1033
|
skipRestore =
|
|
816
1034
|
(await args.cache.isOutputsCurrent(node.projectDir, projExpected)) &&
|
|
817
1035
|
(await args.cache.isOutputsCurrent(args.workspaceRoot, wsExpected))
|
|
1036
|
+
endStat()
|
|
1037
|
+
// The walk ran and proved the tree current (rows absent, or a
|
|
1038
|
+
// directory had moved — a benign touch): snapshot the directories
|
|
1039
|
+
// so the next hit skips the walk.
|
|
1040
|
+
if (skipRestore && !setKnown && dirPrefixes !== null) {
|
|
1041
|
+
await args.cache.recordOutputDirs?.(hash, node.projectDir, dirPrefixes)
|
|
1042
|
+
}
|
|
818
1043
|
}
|
|
819
1044
|
}
|
|
820
1045
|
}
|
|
@@ -824,6 +1049,9 @@ export async function restoreHit(restore: RestoreHitArgs): Promise<TaskOutcome>
|
|
|
824
1049
|
if (outputs.length > 0) cleanedRels = await cleanOutputs(cleanArgs)
|
|
825
1050
|
if (wsOutputs.length > 0) cleanedWsRels = await cleanWorkspaceOutputs(wsCleanArgs)
|
|
826
1051
|
await args.cache.restoreOutputs(hash, node.projectDir, args.workspaceRoot)
|
|
1052
|
+
if (dirPrefixes !== null) {
|
|
1053
|
+
await args.cache.recordOutputDirs?.(hash, node.projectDir, dirPrefixes)
|
|
1054
|
+
}
|
|
827
1055
|
// Restored outputs changed the project's tree — but on this
|
|
828
1056
|
// path we know the EXACT changed paths (wiped declared
|
|
829
1057
|
// outputs + the artifact's files). Record them instead of
|
|
@@ -861,7 +1089,10 @@ export async function restoreHit(restore: RestoreHitArgs): Promise<TaskOutcome>
|
|
|
861
1089
|
node,
|
|
862
1090
|
status,
|
|
863
1091
|
exitCode: hit.exitCode,
|
|
1092
|
+
// What THIS run spent: probe + restore. NOT the stored exec time — see
|
|
1093
|
+
// `storedDurationMs`, which is what the hit skipped.
|
|
864
1094
|
durationMs: Math.round(performance.now() - cacheOpStart),
|
|
1095
|
+
storedDurationMs: hit.durationMs,
|
|
865
1096
|
hash,
|
|
866
1097
|
restored,
|
|
867
1098
|
// Under `--verify`, a cache hit didn't execute this run — so it wasn't
|
|
@@ -911,29 +1142,32 @@ async function prepareOutputsForBind(
|
|
|
911
1142
|
* a file path covers writes to that file; a dir path covers writes
|
|
912
1143
|
* anywhere underneath.
|
|
913
1144
|
*/
|
|
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
1145
|
/**
|
|
927
|
-
* Build the child-process env for one task. Same arguments at every
|
|
928
|
-
*
|
|
929
|
-
*
|
|
930
|
-
*
|
|
1146
|
+
* Build the child-process env for one task. Same arguments at every call site
|
|
1147
|
+
* (persistent + cached). Two `node_modules/.bin` directories are prepended to
|
|
1148
|
+
* PATH: the project's own, then the WORKSPACE ROOT's. Never a sibling
|
|
1149
|
+
* project's, and never an arbitrary ancestor — that is the project-isolation
|
|
1150
|
+
* rule, and the root is not a sibling.
|
|
1151
|
+
*
|
|
1152
|
+
* The root entry is where a monorepo's shared tooling actually lives: declare
|
|
1153
|
+
* `oxlint` once as a root devDependency and every member's `.bin` is empty of
|
|
1154
|
+
* it. Without the root on PATH such a task exits 127, which is what this
|
|
1155
|
+
* repo's own gate did the moment core stopped BEING the root and the two
|
|
1156
|
+
* paths stopped coinciding. It also removes a divergence that mattered more:
|
|
1157
|
+
* the REAPI executor already rebuilds both entries in the action's command,
|
|
1158
|
+
* so a task that resolved on a worker failed on the machine that submitted
|
|
1159
|
+
* it. npm/pnpm/yarn all put the ancestor chain on PATH for the same reason.
|
|
931
1160
|
*/
|
|
932
|
-
function taskEnv(node: TaskNode, step: ExecConfig): NodeJS.ProcessEnv {
|
|
1161
|
+
function taskEnv(node: TaskNode, step: ExecConfig, workspaceRoot: string): NodeJS.ProcessEnv {
|
|
1162
|
+
const bins = [path.join(node.projectDir, 'node_modules', '.bin')]
|
|
1163
|
+
const rootBin = path.join(workspaceRoot, 'node_modules', '.bin')
|
|
1164
|
+
// Identical when the root is itself a project — dedupe rather than list it
|
|
1165
|
+
// twice, so PATH reads the same either way.
|
|
1166
|
+
if (rootBin !== bins[0]) bins.push(rootBin)
|
|
933
1167
|
return buildIsolatedEnv({
|
|
934
1168
|
passThrough: step.env?.passThrough ?? [],
|
|
935
1169
|
define: step.env?.define ?? {},
|
|
936
1170
|
source: process.env,
|
|
937
|
-
binPaths:
|
|
1171
|
+
binPaths: bins,
|
|
938
1172
|
})
|
|
939
1173
|
}
|