@vzn/vx 0.0.18 → 0.0.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +1 -1
- package/README.md +99 -65
- package/package.json +10 -20
- package/src/bin.ts +12 -2
- package/src/cache/archive.ts +120 -18
- package/src/cache/cache.ts +313 -1324
- package/src/cache/chained-cache.ts +0 -8
- package/src/cache/config-evals.ts +89 -0
- package/src/cache/file-hashes.ts +262 -0
- package/src/cache/git-inputs.ts +637 -0
- package/src/cache/index.ts +16 -7
- package/src/cache/inputs.ts +246 -733
- package/src/cache/layer.ts +604 -0
- package/src/cache/layered-cache.ts +12 -17
- package/src/cache/output-index.ts +224 -0
- package/src/cache/policy.ts +84 -0
- package/src/cache/run-history.ts +147 -0
- package/src/cache/zstd.ts +160 -0
- package/src/cli/cache.ts +39 -11
- package/src/cli/completions.ts +104 -0
- package/src/cli/core-alias.ts +25 -0
- package/src/cli/help.ts +64 -36
- package/src/cli/index.ts +54 -15
- package/src/cli/info.ts +120 -91
- package/src/cli/init.ts +75 -0
- package/src/cli/last.ts +38 -6
- package/src/cli/lock.ts +2 -1
- package/src/cli/plan-format.ts +14 -3
- package/src/cli/plugin-commands.ts +15 -8
- package/src/cli/run.ts +84 -302
- package/src/cli/select.ts +332 -0
- package/src/cli/show.ts +144 -69
- package/src/cli/upgrade.ts +2 -1
- package/src/cli/watch.ts +647 -97
- package/src/cli/why.ts +44 -14
- package/src/cli/workspace-config.ts +126 -0
- package/src/config.ts +156 -136
- package/src/exec/env.ts +11 -0
- package/src/exec/executor.ts +11 -26
- package/src/exec/index.ts +6 -2
- package/src/{plugins/local-executor/index.ts → exec/local-executor.ts} +12 -16
- package/src/exec/runner.ts +57 -18
- package/src/exec/sandbox-binds.ts +182 -0
- package/src/exec/sandbox-paths.ts +50 -0
- package/src/exec/sandbox-runtime.ts +582 -370
- package/src/exec/sandbox-violations.ts +280 -0
- package/src/graph/index.ts +1 -9
- package/src/graph/scheduler.ts +158 -203
- package/src/graph/task-graph.ts +2 -2
- package/src/index.ts +97 -102
- package/src/orchestrator/admission.ts +137 -0
- package/src/orchestrator/deferred-outputs.ts +3 -0
- package/src/orchestrator/doctor.ts +255 -0
- package/src/orchestrator/events.ts +42 -8
- package/src/orchestrator/execute-task.ts +145 -554
- package/src/orchestrator/failure-mode.ts +169 -28
- package/src/orchestrator/framed-output.ts +41 -36
- package/src/orchestrator/history.ts +118 -98
- package/src/orchestrator/hit-restore.ts +221 -0
- package/src/orchestrator/index.ts +35 -4
- package/src/orchestrator/lockfile-claim.ts +281 -0
- package/src/orchestrator/logger.ts +13 -22
- package/src/orchestrator/metrics.ts +53 -20
- package/src/orchestrator/miss-save.ts +169 -0
- package/src/orchestrator/options.ts +34 -31
- package/src/orchestrator/persistent.ts +79 -0
- package/src/orchestrator/placement.ts +182 -0
- package/src/orchestrator/plugin-host.ts +195 -38
- package/src/orchestrator/plugin.ts +162 -9
- package/src/orchestrator/prepare.ts +77 -132
- package/src/orchestrator/projects.ts +259 -0
- package/src/orchestrator/remote-prefetch.ts +1 -1
- package/src/orchestrator/run-artifacts.ts +25 -3
- package/src/orchestrator/run-context.ts +1 -1
- package/src/orchestrator/run-records.ts +149 -0
- package/src/orchestrator/run-report.ts +2 -2
- package/src/orchestrator/run.ts +246 -597
- package/src/orchestrator/sandbox-request.ts +243 -0
- package/src/orchestrator/save-lane.ts +47 -0
- package/src/orchestrator/signals.ts +111 -0
- package/src/orchestrator/stable-keys.ts +60 -3
- package/src/orchestrator/status-line.ts +2 -1
- package/src/orchestrator/summary.ts +68 -25
- package/src/orchestrator/task-hash.ts +20 -18
- package/src/orchestrator/task-log-buffer.ts +8 -11
- package/src/orchestrator/telemetry-host.ts +11 -1
- package/src/orchestrator/telemetry.ts +3 -17
- package/src/util/cgroup.ts +160 -0
- package/src/util/edit-distance.ts +49 -0
- package/src/util/index.ts +11 -3
- package/src/util/paths.ts +23 -2
- package/src/util/settle.ts +19 -0
- package/src/util/verbs.ts +39 -0
- package/src/workspace/affected.ts +111 -11
- package/src/workspace/config-cache.ts +11 -2
- package/src/workspace/config-eval.ts +11 -4
- package/src/workspace/config-imports.ts +5 -4
- package/src/workspace/config-schema.ts +837 -0
- package/src/workspace/filter.ts +43 -6
- package/src/workspace/fingerprint.ts +37 -3
- package/src/workspace/index.ts +30 -3
- package/src/workspace/lockfile.ts +12 -7
- package/src/{cli → workspace}/migrate-scripts.ts +26 -19
- package/src/{cli/migrate.ts → workspace/migration.ts} +133 -124
- package/src/workspace/package-graph.ts +98 -28
- package/src/workspace/project-loader.ts +76 -746
- package/src/workspace/workspace.ts +98 -13
- package/plugins/local-cache/index.ts +0 -2
- package/plugins/local-executor/index.ts +0 -2
- package/plugins/schedule-history/index.ts +0 -2
- package/src/cache/cas-backend.ts +0 -115
- package/src/cache/digest.ts +0 -32
- package/src/cli/migrate-emit.ts +0 -21
- package/src/cli/migrate-nx.ts +0 -473
- package/src/cli/migrate-turbo.ts +0 -401
- package/src/cli/prune.ts +0 -291
- package/src/orchestrator/missing-plugin.ts +0 -9
- package/src/orchestrator/resources.ts +0 -45
- package/src/orchestrator/verify.ts +0 -174
- package/src/plugins/local-cache/index.ts +0 -11
- package/src/plugins/schedule-history/index.ts +0 -109
|
@@ -20,6 +20,7 @@ import { formatDuration, formatSummarySection, type RunContext } from './summary
|
|
|
20
20
|
import { isGroupTask } from '../graph/index.js'
|
|
21
21
|
import { appendTail, createTail, resetTail, tailText, type Tail } from '../util/index.js'
|
|
22
22
|
import { isCacheHit } from './telemetry.js'
|
|
23
|
+
import { outcomeWord } from './events.js'
|
|
23
24
|
|
|
24
25
|
export interface Logger {
|
|
25
26
|
/** Header / footer / status text. Written verbatim, one trailing \n added. */
|
|
@@ -124,19 +125,8 @@ function ghaFence(body: string, token: string): string {
|
|
|
124
125
|
return `::stop-commands::${token}\n${body}::${token}::\n`
|
|
125
126
|
}
|
|
126
127
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
switch (o.status) {
|
|
130
|
-
case 'success':
|
|
131
|
-
return 'success'
|
|
132
|
-
case 'cache-hit':
|
|
133
|
-
return o.restored === false ? 'up-to-date' : 'restored-local'
|
|
134
|
-
case 'cache-hit-remote':
|
|
135
|
-
return o.restored === false ? 'up-to-date' : 'restored-remote'
|
|
136
|
-
default:
|
|
137
|
-
return o.status
|
|
138
|
-
}
|
|
139
|
-
}
|
|
128
|
+
const cacheWordOf = (n: TaskNode): 'miss' | 'no-cache' =>
|
|
129
|
+
n.config.cache === undefined ? 'no-cache' : 'miss'
|
|
140
130
|
|
|
141
131
|
export function resolveOutputView(
|
|
142
132
|
options: {
|
|
@@ -182,8 +172,6 @@ export function defaultLogger(
|
|
|
182
172
|
// a one-liner or streamed output since the last block does.
|
|
183
173
|
let lineEmitted = false
|
|
184
174
|
let streamedSinceBlock = false
|
|
185
|
-
// Ids whose output went straight to the terminal (focused mode).
|
|
186
|
-
const streamed = new Set<string>()
|
|
187
175
|
// True while the live stream sits mid-line (chunk without trailing
|
|
188
176
|
// newline) — the frame close must not glue onto partial output.
|
|
189
177
|
let streamMidLine = false
|
|
@@ -257,6 +245,8 @@ export function defaultLogger(
|
|
|
257
245
|
let slots: (WorkerSlot | null)[] = []
|
|
258
246
|
const slotQueue: WorkerSlot[] = []
|
|
259
247
|
let succeeded = 0
|
|
248
|
+
// Executed with no `cache` block: never a miss, counted apart.
|
|
249
|
+
let noCache = 0
|
|
260
250
|
let upToDate = 0
|
|
261
251
|
let restoredLocal = 0
|
|
262
252
|
let restoredRemote = 0
|
|
@@ -311,7 +301,8 @@ export function defaultLogger(
|
|
|
311
301
|
upToDate,
|
|
312
302
|
restoredLocal,
|
|
313
303
|
restoredRemote,
|
|
314
|
-
miss: succeeded + failed,
|
|
304
|
+
miss: succeeded + failed - noCache,
|
|
305
|
+
noCache,
|
|
315
306
|
left: total - done,
|
|
316
307
|
spread:
|
|
317
308
|
spreadCount > 0
|
|
@@ -472,7 +463,6 @@ export function defaultLogger(
|
|
|
472
463
|
taskStdout(node, chunk) {
|
|
473
464
|
if (discardsOutput) return
|
|
474
465
|
if (streamsLive(node)) {
|
|
475
|
-
streamed.add(node.id)
|
|
476
466
|
streamedSinceBlock = true
|
|
477
467
|
if (chunk.length > 0) streamMidLine = !chunk.endsWith('\n')
|
|
478
468
|
writer.write(chunk)
|
|
@@ -488,7 +478,6 @@ export function defaultLogger(
|
|
|
488
478
|
taskStderr(node, chunk) {
|
|
489
479
|
if (discardsOutput) return
|
|
490
480
|
if (streamsLive(node)) {
|
|
491
|
-
streamed.add(node.id)
|
|
492
481
|
streamedSinceBlock = true
|
|
493
482
|
if (chunk.length > 0) streamMidLine = !chunk.endsWith('\n')
|
|
494
483
|
writer.write(chunk)
|
|
@@ -534,6 +523,7 @@ export function defaultLogger(
|
|
|
534
523
|
done++
|
|
535
524
|
if (outcome.status === 'failed') {
|
|
536
525
|
failed++
|
|
526
|
+
if (node.config.cache === undefined) noCache++
|
|
537
527
|
} else if (node.config.exec?.persistent !== undefined && outcome.status === 'success') {
|
|
538
528
|
// A persistent task's outcome arrives at READY; the child
|
|
539
529
|
// keeps running until the orchestrator SIGTERMs it at run
|
|
@@ -557,6 +547,7 @@ export function defaultLogger(
|
|
|
557
547
|
switch (outcome.status) {
|
|
558
548
|
case 'success':
|
|
559
549
|
succeeded++
|
|
550
|
+
if (node.config.cache === undefined) noCache++
|
|
560
551
|
break
|
|
561
552
|
case 'cache-hit':
|
|
562
553
|
if (outcome.restored === false) upToDate++
|
|
@@ -593,7 +584,7 @@ export function defaultLogger(
|
|
|
593
584
|
}
|
|
594
585
|
case 'errors-only':
|
|
595
586
|
if (outcome.status !== 'failed') return
|
|
596
|
-
emitLine(formatFailureLine(node.id, outcome.durationMs, colors))
|
|
587
|
+
emitLine(formatFailureLine(node.id, outcome.durationMs, colors, cacheWordOf(node)))
|
|
597
588
|
deferredFailures.push(
|
|
598
589
|
formatTaskBlock(node, outcome, { stdout, stderr, ...dropped }, colors),
|
|
599
590
|
)
|
|
@@ -605,7 +596,7 @@ export function defaultLogger(
|
|
|
605
596
|
// surface in the end-of-run summary.
|
|
606
597
|
if (outcome.status === 'failed') {
|
|
607
598
|
// ✗ marker now; the full frame replays at runEnd.
|
|
608
|
-
emitLine(formatFailureLine(node.id, outcome.durationMs, colors))
|
|
599
|
+
emitLine(formatFailureLine(node.id, outcome.durationMs, colors, cacheWordOf(node)))
|
|
609
600
|
deferredFailures.push(
|
|
610
601
|
formatTaskBlock(node, outcome, { stdout, stderr, ...dropped }, colors),
|
|
611
602
|
)
|
|
@@ -640,7 +631,7 @@ export function defaultLogger(
|
|
|
640
631
|
// to runEnd like everywhere else; everything else emits
|
|
641
632
|
// ONE atomic block from the buffered output.
|
|
642
633
|
if (outcome.status === 'failed') {
|
|
643
|
-
emitLine(formatFailureLine(node.id, outcome.durationMs, colors))
|
|
634
|
+
emitLine(formatFailureLine(node.id, outcome.durationMs, colors, cacheWordOf(node)))
|
|
644
635
|
deferredFailures.push(
|
|
645
636
|
formatTaskBlock(node, outcome, { stdout, stderr, ...dropped }, colors),
|
|
646
637
|
)
|
|
@@ -654,7 +645,7 @@ export function defaultLogger(
|
|
|
654
645
|
// Dependency-pulled nodes: silent on success; failures get
|
|
655
646
|
// the ✗ marker now and their frame replayed at runEnd.
|
|
656
647
|
if (outcome.status === 'failed') {
|
|
657
|
-
emitLine(formatFailureLine(node.id, outcome.durationMs, colors))
|
|
648
|
+
emitLine(formatFailureLine(node.id, outcome.durationMs, colors, cacheWordOf(node)))
|
|
658
649
|
deferredFailures.push(
|
|
659
650
|
formatTaskBlock(node, outcome, { stdout, stderr, ...dropped }, colors),
|
|
660
651
|
)
|
|
@@ -30,6 +30,8 @@ export interface RunSummaryRow {
|
|
|
30
30
|
startedAt: number
|
|
31
31
|
endedAt: number
|
|
32
32
|
cacheHit: boolean | null
|
|
33
|
+
/** Whether the task declared a `cache` block; null on rows older than the column. */
|
|
34
|
+
cached: boolean | null
|
|
33
35
|
hash: string
|
|
34
36
|
cpuMs: number | null
|
|
35
37
|
peakRssBytes: number | null
|
|
@@ -65,8 +67,12 @@ export function listRuns(db: Database, args: ListRunsArgs = {}): RunSummaryRow[]
|
|
|
65
67
|
params.push(args.runId)
|
|
66
68
|
}
|
|
67
69
|
const clause = where.length > 0 ? `WHERE ${where.join(' AND ')}` : ''
|
|
68
|
-
type RawRow = Omit<
|
|
70
|
+
type RawRow = Omit<
|
|
71
|
+
RunSummaryRow,
|
|
72
|
+
'cacheHit' | 'cached' | 'wallclockStartNs' | 'wallclockEndNs'
|
|
73
|
+
> & {
|
|
69
74
|
cacheHit: number | null
|
|
75
|
+
cached: number | null
|
|
70
76
|
wallclockStartNs: bigint | null
|
|
71
77
|
wallclockEndNs: bigint | null
|
|
72
78
|
}
|
|
@@ -74,7 +80,7 @@ export function listRuns(db: Database, args: ListRunsArgs = {}): RunSummaryRow[]
|
|
|
74
80
|
.query(
|
|
75
81
|
`SELECT run_id AS runId, project, task, status, exit_code AS exitCode,
|
|
76
82
|
duration_ms AS durationMs, started_at AS startedAt, ended_at AS endedAt,
|
|
77
|
-
cache_hit AS cacheHit, hash,
|
|
83
|
+
cache_hit AS cacheHit, cached, hash,
|
|
78
84
|
cpu_ms AS cpuMs, peak_rss_bytes AS peakRssBytes,
|
|
79
85
|
wallclock_start_ns AS wallclockStartNs, wallclock_end_ns AS wallclockEndNs
|
|
80
86
|
FROM runs ${clause} ORDER BY started_at DESC LIMIT ?`,
|
|
@@ -83,6 +89,7 @@ export function listRuns(db: Database, args: ListRunsArgs = {}): RunSummaryRow[]
|
|
|
83
89
|
return rows.map((r) => ({
|
|
84
90
|
...r,
|
|
85
91
|
cacheHit: r.cacheHit === null ? null : Boolean(r.cacheHit),
|
|
92
|
+
cached: r.cached === null ? null : Boolean(r.cached),
|
|
86
93
|
wallclockStartNs: r.wallclockStartNs === null ? null : r.wallclockStartNs.toString(),
|
|
87
94
|
wallclockEndNs: r.wallclockEndNs === null ? null : r.wallclockEndNs.toString(),
|
|
88
95
|
}))
|
|
@@ -350,7 +357,14 @@ export interface WhyDidThisRerun {
|
|
|
350
357
|
runId: string
|
|
351
358
|
taskId: string
|
|
352
359
|
found: boolean
|
|
353
|
-
thisRun?: {
|
|
360
|
+
thisRun?: {
|
|
361
|
+
hash: string
|
|
362
|
+
status: string
|
|
363
|
+
cacheHit: boolean | null
|
|
364
|
+
/** Whether the task declared a `cache` block; null on rows older than the column. */
|
|
365
|
+
cached: boolean | null
|
|
366
|
+
startedAt: number
|
|
367
|
+
}
|
|
354
368
|
previousRun?: { hash: string; status: string; cacheHit: boolean | null; startedAt: number } | null
|
|
355
369
|
hashChanged?: boolean | null
|
|
356
370
|
note: string
|
|
@@ -370,11 +384,17 @@ export function whyDidThisRerun(db: Database, runId: string, taskId: string): Wh
|
|
|
370
384
|
const [project, task] = splitTaskId(taskId)
|
|
371
385
|
const this_ = db
|
|
372
386
|
.query(
|
|
373
|
-
`SELECT hash, status, cache_hit AS cacheHit, started_at AS startedAt
|
|
387
|
+
`SELECT hash, status, cache_hit AS cacheHit, cached, started_at AS startedAt
|
|
374
388
|
FROM runs WHERE run_id = ? AND project = ? AND task = ?`,
|
|
375
389
|
)
|
|
376
390
|
.get(runId, project, task) as
|
|
377
|
-
| {
|
|
391
|
+
| {
|
|
392
|
+
hash: string
|
|
393
|
+
status: string
|
|
394
|
+
cacheHit: number | null
|
|
395
|
+
cached: number | null
|
|
396
|
+
startedAt: number
|
|
397
|
+
}
|
|
378
398
|
| undefined
|
|
379
399
|
if (!this_) {
|
|
380
400
|
return {
|
|
@@ -403,24 +423,32 @@ export function whyDidThisRerun(db: Database, runId: string, taskId: string): Wh
|
|
|
403
423
|
runId,
|
|
404
424
|
taskId,
|
|
405
425
|
found: true,
|
|
406
|
-
thisRun: {
|
|
426
|
+
thisRun: {
|
|
427
|
+
...this_,
|
|
428
|
+
cacheHit: this_.cacheHit === null ? null : Boolean(this_.cacheHit),
|
|
429
|
+
cached: this_.cached === null ? null : Boolean(this_.cached),
|
|
430
|
+
},
|
|
407
431
|
previousRun: prev
|
|
408
432
|
? { ...prev, cacheHit: prev.cacheHit === null ? null : Boolean(prev.cacheHit) }
|
|
409
433
|
: null,
|
|
410
434
|
hashChanged: prev && !noKey ? prev.hash !== this_.hash : null,
|
|
411
435
|
note: noKey
|
|
412
436
|
? 'this task recorded no cache key (skipped, or a persistent task) — nothing to compare'
|
|
413
|
-
:
|
|
414
|
-
?
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
//
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
437
|
+
: this_.cached === 0
|
|
438
|
+
? // Not a cache decision at all: the task runs every time by design,
|
|
439
|
+
// and its key is derived only so dependents can fold it.
|
|
440
|
+
'this task declares no `cache` block — it runs on every invocation; its key is folded by dependents only'
|
|
441
|
+
: prev && prev.hash !== this_.hash
|
|
442
|
+
? 'cache key changed between the previous run and this one (inputs differ)'
|
|
443
|
+
: prev
|
|
444
|
+
? // An unchanged key has two very different endings, and calling
|
|
445
|
+
// both a "re-run" answered the question wrong: a cache HIT did not
|
|
446
|
+
// re-run at all, so blaming `--no-cache` named a cause that cannot
|
|
447
|
+
// have applied. Only a run that EXECUTED on an unchanged key is
|
|
448
|
+
// the case this verb exists to explain. A row with no recorded
|
|
449
|
+
// cacheHit (older rows) is neither — say that, do not guess.
|
|
450
|
+
unchangedKeyNote(this_.cacheHit)
|
|
451
|
+
: 'no prior run for this (project, task)',
|
|
424
452
|
}
|
|
425
453
|
}
|
|
426
454
|
|
|
@@ -483,9 +511,11 @@ export function cacheKeyDiff(db: Database, runId: string, taskId: string): Cache
|
|
|
483
511
|
const [project, task] = splitTaskId(taskId)
|
|
484
512
|
const this_ = db
|
|
485
513
|
.query(
|
|
486
|
-
'SELECT hash, started_at AS startedAt FROM runs WHERE run_id = ? AND project = ? AND task = ?',
|
|
514
|
+
'SELECT hash, cached, started_at AS startedAt FROM runs WHERE run_id = ? AND project = ? AND task = ?',
|
|
487
515
|
)
|
|
488
|
-
.get(runId, project, task) as
|
|
516
|
+
.get(runId, project, task) as
|
|
517
|
+
| { hash: string; cached: number | null; startedAt: number }
|
|
518
|
+
| undefined
|
|
489
519
|
if (!this_) {
|
|
490
520
|
return {
|
|
491
521
|
runId,
|
|
@@ -559,7 +589,10 @@ export function cacheKeyDiff(db: Database, runId: string, taskId: string): Cache
|
|
|
559
589
|
previousRunId: prev.runId,
|
|
560
590
|
entries: [],
|
|
561
591
|
unchangedCount: 0,
|
|
562
|
-
note:
|
|
592
|
+
note:
|
|
593
|
+
this_.cached === 0
|
|
594
|
+
? 'this task declares no `cache` block — it runs every time; its key moved, but no input fingerprints exist for an uncached task'
|
|
595
|
+
: 'cache key changed but input fingerprints are unavailable — the entry was pruned (or the row predates the `cached` column); only the key change is known',
|
|
563
596
|
}
|
|
564
597
|
}
|
|
565
598
|
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
// What a MISS leaves behind once the command exited 0 and the task will
|
|
2
|
+
// save: the declared outputs resolved, the artifact and its rows saved in
|
|
3
|
+
// one transaction, the whole-subtree output prefixes recorded for the
|
|
4
|
+
// next hit's skip-restore, and the exact written paths marked against the
|
|
5
|
+
// git snapshot so a same-project consumer re-spawns git only when its
|
|
6
|
+
// globs can see them. Stale-hit-critical: a line moved here changes what
|
|
7
|
+
// a later run replays. Moved out of execute-task.ts 2026-09-09 as pure
|
|
8
|
+
// code motion.
|
|
9
|
+
|
|
10
|
+
import path from 'node:path'
|
|
11
|
+
import {
|
|
12
|
+
type CacheLayer,
|
|
13
|
+
type GitFilesCache,
|
|
14
|
+
resolveOutputs,
|
|
15
|
+
resolveWorkspaceOutputs,
|
|
16
|
+
} from '../cache/index.js'
|
|
17
|
+
import type { TaskNode } from '../graph/index.js'
|
|
18
|
+
import { span, wholeSubtreePrefixes } from '../util/index.js'
|
|
19
|
+
import type { Logger } from './logger.js'
|
|
20
|
+
import type { TaskInputComponent } from './task-hash.js'
|
|
21
|
+
|
|
22
|
+
/** A snapshot request for `recordOutputDirs`, taken at run end. */
|
|
23
|
+
export interface OutputDirSnapshot {
|
|
24
|
+
hash: string
|
|
25
|
+
projectDir: string
|
|
26
|
+
prefixes: readonly string[]
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface SaveMissArgs {
|
|
30
|
+
node: TaskNode
|
|
31
|
+
hash: string
|
|
32
|
+
cache: CacheLayer
|
|
33
|
+
log: Logger
|
|
34
|
+
workspaceRoot: string
|
|
35
|
+
nestedProjectDirs: string[]
|
|
36
|
+
gitFilesCache?: GitFilesCache | undefined
|
|
37
|
+
/** Declared `cache.outputs.files` / `.workspaceFiles`. */
|
|
38
|
+
outputs: string[]
|
|
39
|
+
wsOutputs: string[]
|
|
40
|
+
/** The Tier-3 input fingerprint rows captured by the pre-exec describe. */
|
|
41
|
+
captured: readonly TaskInputComponent[]
|
|
42
|
+
command: string
|
|
43
|
+
durationMs: number
|
|
44
|
+
stdout: string
|
|
45
|
+
/** The execution's usage, when the runner reported it — rides the artifact's sidecar. */
|
|
46
|
+
cpuMs?: number | undefined
|
|
47
|
+
peakRssBytes?: number | undefined
|
|
48
|
+
/** When present, the directory snapshot is queued here instead of taken now. */
|
|
49
|
+
outputDirSnapshots?: OutputDirSnapshot[] | undefined
|
|
50
|
+
/**
|
|
51
|
+
* When present, the cache save itself runs off the execution slot: the
|
|
52
|
+
* outputs are resolved and the git snapshot marked here, in the slot
|
|
53
|
+
* (a same-project downstream task reads both), and the pack + write +
|
|
54
|
+
* index go to the run's save lane (save-lane.ts).
|
|
55
|
+
*/
|
|
56
|
+
deferSave?: ((save: () => Promise<void>) => Promise<void>) | undefined
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** `landed` settles when the entry is in the cache — at once without a lane. */
|
|
60
|
+
export async function saveMiss(a: SaveMissArgs): Promise<{ landed: Promise<void> }> {
|
|
61
|
+
const { node, cache, log } = a
|
|
62
|
+
const endResolve = span('miss: resolve outputs')
|
|
63
|
+
const outputFiles = await resolveOutputs({
|
|
64
|
+
projectDir: node.projectDir,
|
|
65
|
+
outputs: a.outputs,
|
|
66
|
+
nestedProjectDirs: a.nestedProjectDirs,
|
|
67
|
+
})
|
|
68
|
+
const wsOutputFiles = await resolveWorkspaceOutputs({
|
|
69
|
+
workspaceRoot: a.workspaceRoot,
|
|
70
|
+
outputs: a.wsOutputs,
|
|
71
|
+
})
|
|
72
|
+
endResolve()
|
|
73
|
+
// The mirror of the input warning: declared outputs that resolve to
|
|
74
|
+
// nothing save an empty artifact, and the next hit "restores" it —
|
|
75
|
+
// the build that ran nowhere looks like a build that ran. `files: []`
|
|
76
|
+
// is a deliberate cached no-op and says nothing; a glob that matched
|
|
77
|
+
// nothing is a glob against the wrong directory.
|
|
78
|
+
if (
|
|
79
|
+
a.outputs.length + a.wsOutputs.length > 0 &&
|
|
80
|
+
outputFiles.length + wsOutputFiles.length === 0
|
|
81
|
+
) {
|
|
82
|
+
log.status(
|
|
83
|
+
`[vx] ${node.id}: cache.outputs matched no files (${[...a.outputs, ...a.wsOutputs].join(', ')}) — ` +
|
|
84
|
+
`an empty artifact is saved; a later hit restores nothing`,
|
|
85
|
+
)
|
|
86
|
+
}
|
|
87
|
+
const save = async (): Promise<void> => {
|
|
88
|
+
const endSave = span('miss: save')
|
|
89
|
+
// Tier-3 input fingerprint: the digest rows captured by the pre-exec
|
|
90
|
+
// describe above, persisted with the entry inside `cache.save`'s
|
|
91
|
+
// transaction. Miss path only — the warm/hit path never reaches here.
|
|
92
|
+
await cache.save({
|
|
93
|
+
hash: a.hash,
|
|
94
|
+
projectDir: node.projectDir,
|
|
95
|
+
outputFiles,
|
|
96
|
+
...(wsOutputFiles.length > 0
|
|
97
|
+
? { workspaceOutputFiles: wsOutputFiles, workspaceRoot: a.workspaceRoot }
|
|
98
|
+
: {}),
|
|
99
|
+
inputComponents: a.captured.map((c) => ({ entryHash: a.hash, ...c })),
|
|
100
|
+
// No `exitCode`: the save only runs under `effectiveExitCode === 0`, and
|
|
101
|
+
// the contract no longer accepts one — so the invariant is enforced by
|
|
102
|
+
// the type rather than by every call site remembering the gate.
|
|
103
|
+
entry: {
|
|
104
|
+
taskId: node.id,
|
|
105
|
+
command: a.command,
|
|
106
|
+
durationMs: a.durationMs,
|
|
107
|
+
stdout: a.stdout,
|
|
108
|
+
...(a.cpuMs !== undefined ? { cpuMs: a.cpuMs } : {}),
|
|
109
|
+
...(a.peakRssBytes !== undefined ? { peakRssBytes: a.peakRssBytes } : {}),
|
|
110
|
+
},
|
|
111
|
+
})
|
|
112
|
+
endSave()
|
|
113
|
+
// The directory snapshot behind the next hit's skip-restore. Taken at
|
|
114
|
+
// run end when the run keeps a list: the task wrote these directories
|
|
115
|
+
// milliseconds ago, inside the snapshot's racy window, so a snapshot
|
|
116
|
+
// taken here was refused and the next hit walked every output tree —
|
|
117
|
+
// 1,000 walks, 296 ms accumulated, on the first warm run after a cold
|
|
118
|
+
// build of the 1,000-project bench (2026-09-10).
|
|
119
|
+
const savedDirPrefixes = wholeSubtreePrefixes(a.outputs)
|
|
120
|
+
if (savedDirPrefixes !== null) {
|
|
121
|
+
if (a.outputDirSnapshots !== undefined) {
|
|
122
|
+
a.outputDirSnapshots.push({
|
|
123
|
+
hash: a.hash,
|
|
124
|
+
projectDir: node.projectDir,
|
|
125
|
+
prefixes: savedDirPrefixes,
|
|
126
|
+
})
|
|
127
|
+
} else {
|
|
128
|
+
await cache.recordOutputDirs?.(a.hash, node.projectDir, savedDirPrefixes)
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
// This task just wrote outputs to the project's tree. Record the
|
|
133
|
+
// exact declared-output paths as changed (same as the cache-hit
|
|
134
|
+
// restore path) instead of dropping the whole snapshot: a downstream
|
|
135
|
+
// same-project task then re-spawns git ONLY when its input globs can
|
|
136
|
+
// actually see one of these paths. On a 1000-package cold run this
|
|
137
|
+
// removes ~one synchronous `git ls-files` spawn per project (the
|
|
138
|
+
// single largest cold-run cost — 22% of CPU in profiling). Contract:
|
|
139
|
+
// outputs must be declared — an executed task that writes files
|
|
140
|
+
// outside `cache.outputs.files` which a same-project downstream task
|
|
141
|
+
// reads is undeclared behavior (the restore path already assumes it).
|
|
142
|
+
if (outputFiles.length > 0) {
|
|
143
|
+
a.gitFilesCache?.markOutputsChanged(
|
|
144
|
+
node.projectDir,
|
|
145
|
+
outputFiles.map((p) => path.relative(node.projectDir, p).split(path.sep).join('/')),
|
|
146
|
+
)
|
|
147
|
+
}
|
|
148
|
+
// Declared workspace outputs may have landed inside OTHER
|
|
149
|
+
// projects' dirs (no-boundary escape hatch) — mark the exact
|
|
150
|
+
// paths against every partition that can see them.
|
|
151
|
+
if (wsOutputFiles.length > 0) {
|
|
152
|
+
a.gitFilesCache?.markWorkspaceOutputsChanged(
|
|
153
|
+
a.workspaceRoot,
|
|
154
|
+
wsOutputFiles.map((f) => path.relative(a.workspaceRoot, f).split(path.sep).join('/')),
|
|
155
|
+
)
|
|
156
|
+
}
|
|
157
|
+
// The workspace-wide partition (when one exists) spans this
|
|
158
|
+
// project's subtree, so it inherits the same "undeclared writes
|
|
159
|
+
// are only visible to git" rule as the project drop above.
|
|
160
|
+
if (outputFiles.length + wsOutputFiles.length > 0) {
|
|
161
|
+
a.gitFilesCache?.invalidateWorkspacePartition()
|
|
162
|
+
}
|
|
163
|
+
// Off the slot when the run keeps a lane (the save lane bounds and
|
|
164
|
+
// drains it); in the slot otherwise — an embedder without a lane gets
|
|
165
|
+
// the entry before the outcome.
|
|
166
|
+
if (a.deferSave !== undefined) return { landed: a.deferSave(save) }
|
|
167
|
+
await save()
|
|
168
|
+
return { landed: Promise.resolve() }
|
|
169
|
+
}
|
|
@@ -8,6 +8,7 @@ import type { TaskOutcome } from '../graph/index.js'
|
|
|
8
8
|
import type { EventBus } from './events.js'
|
|
9
9
|
import type { TelemetrySink } from './telemetry.js'
|
|
10
10
|
import type { Logger } from './logger.js'
|
|
11
|
+
import type { ProjectEntry } from '../workspace/index.js'
|
|
11
12
|
|
|
12
13
|
export interface RunOptions {
|
|
13
14
|
cwd: string
|
|
@@ -19,6 +20,16 @@ export interface RunOptions {
|
|
|
19
20
|
*/
|
|
20
21
|
tasks: readonly string[]
|
|
21
22
|
projects?: string[]
|
|
23
|
+
/**
|
|
24
|
+
* Configs a selection pass in this same process already loaded and
|
|
25
|
+
* staged with the same `cacheDir` and `frozen` (the CLI walks every
|
|
26
|
+
* `pkg#task` edge for `--filter 'app...'` and `--affected`). The run
|
|
27
|
+
* seeds and scopes as it always does and reuses these entries instead
|
|
28
|
+
* of evaluating and staging them again. One run only — never carry a
|
|
29
|
+
* map across runs, a config can change between them (`vx watch` drops
|
|
30
|
+
* it per cycle).
|
|
31
|
+
*/
|
|
32
|
+
staged?: ReadonlyMap<string, ProjectEntry>
|
|
22
33
|
concurrency?: number
|
|
23
34
|
/**
|
|
24
35
|
* Cache directory override (`--cache-dir <path>`). Absolute, or relative
|
|
@@ -34,6 +45,13 @@ export interface RunOptions {
|
|
|
34
45
|
* both reads false (re-execute, still refresh the cache).
|
|
35
46
|
*/
|
|
36
47
|
cache?: CachePolicy
|
|
48
|
+
/**
|
|
49
|
+
* The caller asked for a remote axis by name (`--cache=remote:rw`). With
|
|
50
|
+
* no cache plugin supplying a remote layer the axes are inert, and a CI
|
|
51
|
+
* job that believes it is filling a shared cache deserves one line
|
|
52
|
+
* saying it is not.
|
|
53
|
+
*/
|
|
54
|
+
remoteRequested?: boolean
|
|
37
55
|
/**
|
|
38
56
|
* CI mode: load configs FROM the committed vx-lock.json instead of
|
|
39
57
|
* evaluating them (frozen-env reproducibility). Requires the lock
|
|
@@ -62,7 +80,7 @@ export interface RunOptions {
|
|
|
62
80
|
* task in the run actually needs them (Bazel's "build without the
|
|
63
81
|
* bytes").
|
|
64
82
|
* Pure transfer tuning: it can never change what a command produces,
|
|
65
|
-
* so it is NEVER folded into a cache key
|
|
83
|
+
* so it is NEVER folded into a cache key: it is a RunOption, not config.
|
|
66
84
|
* Locally-placed tasks always write in place and ignore this.
|
|
67
85
|
*/
|
|
68
86
|
download?: 'all' | 'toplevel' | 'none'
|
|
@@ -82,24 +100,6 @@ export interface RunOptions {
|
|
|
82
100
|
* the same run with and without `--retry` derives identical keys.
|
|
83
101
|
*/
|
|
84
102
|
retries?: number
|
|
85
|
-
/**
|
|
86
|
-
* Cache-correctness verification (`vx run --verify`). When set, an
|
|
87
|
-
* executed + cacheable task is re-run after its save and its outputs are
|
|
88
|
-
* content-compared: a divergence means the task is non-hermetic and its
|
|
89
|
-
* cache entry is provably unsafe (see docs/design/cache-correctness).
|
|
90
|
-
* `fingerprint` computes + ships an output-tree fingerprint on executed
|
|
91
|
-
* tasks (no re-run — the cross-machine diff feed; `--verify`/`=all` set
|
|
92
|
-
* it too, for free). `allow` (from `--verify-allow=<pkg#task>,…`) exempts
|
|
93
|
-
* known-nondeterministic tasks from failing the run. A pure side-channel —
|
|
94
|
-
* never folded into a cache key; the re-run never saves. Undefined = off
|
|
95
|
-
* (a plain run is byte-identical).
|
|
96
|
-
*/
|
|
97
|
-
verify?: {
|
|
98
|
-
determinism: boolean
|
|
99
|
-
inputs: boolean
|
|
100
|
-
fingerprint: boolean
|
|
101
|
-
allow: ReadonlySet<string>
|
|
102
|
-
}
|
|
103
103
|
/**
|
|
104
104
|
* Run-level default task timeout (ms), for tasks that declare no
|
|
105
105
|
* `exec.timeout`. Highest of the run-level defaults: `--timeout` /
|
|
@@ -109,14 +109,6 @@ export interface RunOptions {
|
|
|
109
109
|
* cached), so a `--timeout` run cache-hits a plain run's entry.
|
|
110
110
|
*/
|
|
111
111
|
timeout?: number
|
|
112
|
-
/**
|
|
113
|
-
* Memory budget (bytes) that per-task `exec.resources.memory`
|
|
114
|
-
* reservations pack against (`--memory <size>`). Defaults to
|
|
115
|
-
* `os.totalmem()` — override it in cgroup-limited containers, where
|
|
116
|
-
* totalmem() reports the HOST's RAM, not the limit. A per-run
|
|
117
|
-
* scheduling knob only — never folded into any cache key.
|
|
118
|
-
*/
|
|
119
|
-
memory?: number
|
|
120
112
|
/**
|
|
121
113
|
* Failure propagation: 'never' stops dispatch on the first failure
|
|
122
114
|
* (in-flight tasks finish; everything queued skips), 'deps-ok'
|
|
@@ -154,6 +146,16 @@ export interface RunOptions {
|
|
|
154
146
|
* exit the process out from under it.
|
|
155
147
|
*/
|
|
156
148
|
handleSignals?: boolean
|
|
149
|
+
/**
|
|
150
|
+
* Abort the run from outside. On `abort` every live and persistent
|
|
151
|
+
* child is SIGTERMed, given the kill grace, then SIGKILLed; nothing
|
|
152
|
+
* further is dispatched; run() returns with the killed tasks and the
|
|
153
|
+
* never-started ones `aborted`. The seam for an embedder that owns its
|
|
154
|
+
* process's lifetime instead of `handleSignals` — the watch loop aborts
|
|
155
|
+
* its in-flight cycle on SIGINT/SIGTERM this way, so a cancellation
|
|
156
|
+
* never orphans a cycle's children.
|
|
157
|
+
*/
|
|
158
|
+
signal?: AbortSignal
|
|
157
159
|
log?: Logger
|
|
158
160
|
/**
|
|
159
161
|
* Inject the run's event bus. When provided, the orchestrator emits
|
|
@@ -168,7 +170,8 @@ export interface RunOptions {
|
|
|
168
170
|
bus?: EventBus
|
|
169
171
|
/**
|
|
170
172
|
* Shared in-flight execution registry, keyed by task hash. Supplied by a
|
|
171
|
-
* long-lived
|
|
173
|
+
* long-lived embedder (a daemon built on the façade; core ships none) so
|
|
174
|
+
* concurrent runs DEDUP work: a task
|
|
172
175
|
* already executing for one run is awaited by another (which then
|
|
173
176
|
* restores the just-saved artifact from cache) instead of re-running.
|
|
174
177
|
* A stateless `vx run` passes none and is byte-identical to before.
|
|
@@ -183,8 +186,8 @@ export interface RunOptions {
|
|
|
183
186
|
/**
|
|
184
187
|
* Additional observe-only telemetry sinks, merged with whatever the
|
|
185
188
|
* workspace plugins contribute. The embedder seam: a host executing
|
|
186
|
-
* runs on behalf of others (a
|
|
187
|
-
* into its
|
|
189
|
+
* runs on behalf of others (a daemon recording delegated runs
|
|
190
|
+
* into its own store) attaches a sink without needing a workspace
|
|
188
191
|
* plugin. Undefined → zero cost, identical to before; the telemetry
|
|
189
192
|
* host's zero-sink invariant still applies when both sources are
|
|
190
193
|
* empty.
|
|
@@ -200,7 +203,7 @@ export interface RunOptions {
|
|
|
200
203
|
/**
|
|
201
204
|
* An injected remote cache layer — the embedder seam mirroring
|
|
202
205
|
* `telemetrySinks`: a host that already holds a wire client (a
|
|
203
|
-
* distribution agent, a
|
|
206
|
+
* distribution agent, a daemon executing on behalf of a submitter)
|
|
204
207
|
* passes it here and run() composes `LayeredCache(local, injected)`.
|
|
205
208
|
* Explicit injection WINS over the plugin `cache` capability (the
|
|
206
209
|
* host knows best; prevents double-wrapping when the workspace also
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// End-of-run disposition of persistent children (dev servers, watchers).
|
|
2
|
+
// `executeTask` spawns them and returns at "ready" without awaiting exit;
|
|
3
|
+
// ownership moves to the run's registry, and this is the one place that
|
|
4
|
+
// decides which of them outlive the graph and how the rest go down.
|
|
5
|
+
|
|
6
|
+
import type { TaskNode } from '../graph/index.js'
|
|
7
|
+
import { killGraceMs } from '../util/index.js'
|
|
8
|
+
|
|
9
|
+
type Child = ReturnType<typeof Bun.spawn>
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Grace after SIGTERMing the dependency-only persistent tasks before
|
|
13
|
+
* force-killing any that trap or ignore it — so a wedged mock server can't
|
|
14
|
+
* hang a normal run at completion. Well-behaved servers exit far under
|
|
15
|
+
* this, so the happy path never waits it out.
|
|
16
|
+
*/
|
|
17
|
+
export const PERSISTENT_SHUTDOWN_GRACE_MS = 2000
|
|
18
|
+
|
|
19
|
+
export interface KeepAlive {
|
|
20
|
+
nodes: TaskNode[]
|
|
21
|
+
children: Child[]
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* A persistent task the user REQUESTED (or one surfaced for display) is the
|
|
26
|
+
* run's whole purpose — it is left running and blocked on at the very end,
|
|
27
|
+
* after the summary. Only in the real CLI foreground: a custom logger or
|
|
28
|
+
* `handleSignals: false` (watch mode, embedders) expects `run()` to return,
|
|
29
|
+
* not to sit on a server.
|
|
30
|
+
*/
|
|
31
|
+
export function selectKeepAlive(
|
|
32
|
+
registry: ReadonlyMap<string, Child>,
|
|
33
|
+
nodes: ReadonlyMap<string, TaskNode>,
|
|
34
|
+
foreground: boolean,
|
|
35
|
+
): KeepAlive {
|
|
36
|
+
const out: KeepAlive = { nodes: [], children: [] }
|
|
37
|
+
if (!foreground) return out
|
|
38
|
+
for (const [id, child] of registry) {
|
|
39
|
+
const n = nodes.get(id)
|
|
40
|
+
if (n !== undefined && (n.requested || n.surfaced === true)) {
|
|
41
|
+
out.nodes.push(n)
|
|
42
|
+
out.children.push(child)
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return out
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* SIGTERM every persistent child not kept alive and wait for them, bounded:
|
|
50
|
+
* SIGTERM gives well-behaved servers (vite, next, esbuild --watch) a moment
|
|
51
|
+
* to clean up; past the grace the stragglers are SIGKILLed so the run's
|
|
52
|
+
* normal completion cannot hang on one that traps the signal. Bun's
|
|
53
|
+
* `kill` is idempotent on an exited child; the timer is cleared and
|
|
54
|
+
* unref'd so a fast shutdown never delays CLI exit.
|
|
55
|
+
*/
|
|
56
|
+
export async function shutdownPersistent(
|
|
57
|
+
registry: ReadonlyMap<string, Child>,
|
|
58
|
+
keepAlive: readonly Child[],
|
|
59
|
+
graceMs: number = killGraceMs(PERSISTENT_SHUTDOWN_GRACE_MS),
|
|
60
|
+
): Promise<void> {
|
|
61
|
+
const kept = new Set(keepAlive)
|
|
62
|
+
const dying = [...registry.values()].filter((c) => !kept.has(c))
|
|
63
|
+
if (dying.length === 0) return
|
|
64
|
+
for (const child of dying) child.kill('SIGTERM')
|
|
65
|
+
const allExited = Promise.allSettled(dying.map((c) => c.exited))
|
|
66
|
+
let graceTimer: ReturnType<typeof setTimeout> | undefined
|
|
67
|
+
const winner = await Promise.race([
|
|
68
|
+
allExited.then(() => 'exited' as const),
|
|
69
|
+
new Promise<'grace'>((resolve) => {
|
|
70
|
+
graceTimer = setTimeout(() => resolve('grace'), graceMs)
|
|
71
|
+
graceTimer.unref?.()
|
|
72
|
+
}),
|
|
73
|
+
])
|
|
74
|
+
if (graceTimer !== undefined) clearTimeout(graceTimer)
|
|
75
|
+
if (winner === 'grace') {
|
|
76
|
+
for (const child of dying) child.kill('SIGKILL')
|
|
77
|
+
await allExited
|
|
78
|
+
}
|
|
79
|
+
}
|