@vzn/vx 0.0.527 → 0.0.529
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/package.json +5 -5
- package/src/cache/git-inputs.ts +15 -3
- package/src/cache/inputs.ts +3 -1
- package/src/graph/index.ts +1 -0
- package/src/graph/scheduler.ts +44 -3
- package/src/orchestrator/events.ts +10 -1
- package/src/orchestrator/execute-task.ts +8 -0
- package/src/orchestrator/miss-reason.ts +55 -0
- package/src/orchestrator/run-context.ts +71 -1
- package/src/orchestrator/run.ts +11 -0
- package/src/orchestrator/telemetry.ts +29 -1
- package/src/util/index.ts +1 -0
- package/src/util/paths.ts +10 -0
- package/src/workspace/affected.ts +6 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vzn/vx",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.529",
|
|
4
4
|
"description": "The fastest task runner and build cache for JavaScript monorepos.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -21,10 +21,10 @@
|
|
|
21
21
|
"node": ">=18"
|
|
22
22
|
},
|
|
23
23
|
"optionalDependencies": {
|
|
24
|
-
"@vzn/vx-linux-x64": "0.0.
|
|
25
|
-
"@vzn/vx-linux-arm64": "0.0.
|
|
26
|
-
"@vzn/vx-darwin-x64": "0.0.
|
|
27
|
-
"@vzn/vx-darwin-arm64": "0.0.
|
|
24
|
+
"@vzn/vx-linux-x64": "0.0.529",
|
|
25
|
+
"@vzn/vx-linux-arm64": "0.0.529",
|
|
26
|
+
"@vzn/vx-darwin-x64": "0.0.529",
|
|
27
|
+
"@vzn/vx-darwin-arm64": "0.0.529"
|
|
28
28
|
},
|
|
29
29
|
"dependencies": {
|
|
30
30
|
"@anthropic-ai/sandbox-runtime": "^0.0.76"
|
package/src/cache/git-inputs.ts
CHANGED
|
@@ -7,7 +7,13 @@
|
|
|
7
7
|
|
|
8
8
|
import path from 'node:path'
|
|
9
9
|
import { existsSync, lstatSync, readdirSync, readFileSync, realpathSync } from 'node:fs'
|
|
10
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
UserError,
|
|
12
|
+
executablePath,
|
|
13
|
+
gitSpawnRefusal,
|
|
14
|
+
isInstalledPath,
|
|
15
|
+
xxh3hex,
|
|
16
|
+
} from '../util/index.js'
|
|
11
17
|
import { FILE_HASH_RACY_MS, racyWindowMs } from './layer.js'
|
|
12
18
|
|
|
13
19
|
/** Three facts of the repository a directory is in: read off the disk, or one `git rev-parse`. */
|
|
@@ -517,7 +523,8 @@ function walkFiles(dir: string, rel = ''): string[] {
|
|
|
517
523
|
}
|
|
518
524
|
const out: string[] = []
|
|
519
525
|
for (const e of entries) {
|
|
520
|
-
|
|
526
|
+
// Nothing tracks a vendored tree's files; an install there is not one.
|
|
527
|
+
if (e.name === '.git' || e.name === 'node_modules') continue
|
|
521
528
|
const r = rel === '' ? e.name : `${rel}/${e.name}`
|
|
522
529
|
if (e.isDirectory()) out.push(...walkFiles(dir, r))
|
|
523
530
|
else out.push(r)
|
|
@@ -562,6 +569,7 @@ function parseLsFilesOutput(
|
|
|
562
569
|
if (record.length === 0) continue
|
|
563
570
|
const m = LS_FILES_STAGE_RE.exec(record)
|
|
564
571
|
const filePath = m === null ? record : record.slice(m[0].length) // --others: bare path
|
|
572
|
+
if (m === null && isInstalledPath(filePath)) continue
|
|
565
573
|
files.push(filePath)
|
|
566
574
|
if (undecodableRecords.size > 0 && undecodableRecords.has(record)) undecodable.push(filePath)
|
|
567
575
|
if (m === null) continue
|
|
@@ -1376,7 +1384,11 @@ export async function startGitEnumeration(
|
|
|
1376
1384
|
// is). They come from the status walk, repo-root-relative like the dirty
|
|
1377
1385
|
// set. Without a status answer the enumeration is the index alone.
|
|
1378
1386
|
const untracked =
|
|
1379
|
-
parsedStatus === null
|
|
1387
|
+
parsedStatus === null
|
|
1388
|
+
? []
|
|
1389
|
+
: [...stripPrefixFromSet(new Set(parsedStatus.untracked), gitPrefix)].filter(
|
|
1390
|
+
(rel) => !isInstalledPath(rel),
|
|
1391
|
+
)
|
|
1380
1392
|
const listed = untracked.length === 0 ? tracked : tracked.concat(untracked)
|
|
1381
1393
|
const nested = expandNestedRepos(workspaceRoot, listed, gitlinks)
|
|
1382
1394
|
const all = nested.files
|
package/src/cache/inputs.ts
CHANGED
|
@@ -58,8 +58,10 @@ export {
|
|
|
58
58
|
// re-lock can't bust every cache key the way a tracked source file
|
|
59
59
|
// would. (Literal pattern, not the workspace `LOCKFILE_NAME` constant:
|
|
60
60
|
// cache is a leaf module and must not import from workspace.)
|
|
61
|
+
//
|
|
62
|
+
// `node_modules` is not here: the enumeration drops an untracked file
|
|
63
|
+
// under one (`isInstalledPath`), and a tracked one is a source.
|
|
61
64
|
const ALWAYS_IGNORE = [
|
|
62
|
-
'**/node_modules/**',
|
|
63
65
|
// Defense in depth, and measured as exactly that (item 497): git never
|
|
64
66
|
// reports a path under `.git`, so dropping this line changes NOTHING on
|
|
65
67
|
// any route into the input set. Probed three ways with the pattern in
|
package/src/graph/index.ts
CHANGED
package/src/graph/scheduler.ts
CHANGED
|
@@ -36,6 +36,14 @@ export type TaskStatus =
|
|
|
36
36
|
// are partial.
|
|
37
37
|
| 'aborted'
|
|
38
38
|
|
|
39
|
+
/** What moved in a missed task's key since the last entry saved for it. */
|
|
40
|
+
export interface InputChanges {
|
|
41
|
+
/** How many components changed, appeared or went. 0: the same key, its entry gone. */
|
|
42
|
+
count: number
|
|
43
|
+
/** The first of them, by kind then name. */
|
|
44
|
+
first: readonly { kind: string; name: string; change: 'changed' | 'added' | 'removed' }[]
|
|
45
|
+
}
|
|
46
|
+
|
|
39
47
|
export interface TaskOutcome {
|
|
40
48
|
node: TaskNode
|
|
41
49
|
status: TaskStatus
|
|
@@ -69,6 +77,20 @@ export interface TaskOutcome {
|
|
|
69
77
|
* stream and the summary footer show it.
|
|
70
78
|
*/
|
|
71
79
|
admissionHeldMs?: number
|
|
80
|
+
/**
|
|
81
|
+
* How long this task waited ready (its deps done, or none to wait on)
|
|
82
|
+
* before it was dispatched: a full worker pool's wait, plus any
|
|
83
|
+
* `admissionHeldMs`. Absent when it started within the millisecond and
|
|
84
|
+
* on a task that never ran.
|
|
85
|
+
*/
|
|
86
|
+
queuedMs?: number
|
|
87
|
+
/** On a cacheable task that ran: how many files its key read. */
|
|
88
|
+
inputFiles?: number
|
|
89
|
+
/**
|
|
90
|
+
* On a cacheable task that ran: what its key changed since the last entry
|
|
91
|
+
* saved for it. Only when a telemetry sink asked, and the cache holds one.
|
|
92
|
+
*/
|
|
93
|
+
inputChanges?: InputChanges
|
|
72
94
|
/** v11 analytics: CPU time + peak RSS for this task's child process. */
|
|
73
95
|
cpuMs?: number
|
|
74
96
|
peakRssBytes?: number
|
|
@@ -439,9 +461,14 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
|
|
|
439
461
|
// decrements still happen (in finishOne) but never re-enqueue it.
|
|
440
462
|
// Everything else enqueues on the exec-tier the moment its deps
|
|
441
463
|
// complete.
|
|
464
|
+
// When each queued id became ready; dispatch takes it out.
|
|
465
|
+
const readyAt = new Map<string, number>()
|
|
466
|
+
const startedAt = Date.now()
|
|
442
467
|
for (const node of nodes.values()) {
|
|
443
468
|
if (restoreTier?.has(node.id)) restoreReady.push(node.id)
|
|
444
469
|
else if (node.deps.length === 0) execReady.push(node.id)
|
|
470
|
+
else continue
|
|
471
|
+
readyAt.set(node.id, startedAt)
|
|
445
472
|
}
|
|
446
473
|
|
|
447
474
|
let active = 0
|
|
@@ -603,7 +630,10 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
|
|
|
603
630
|
// don't wait on deps); one that finished early releases its own
|
|
604
631
|
// dependents now. Only an exec-tier dependent is enqueued.
|
|
605
632
|
if (heldRelease.delete(d)) release(d)
|
|
606
|
-
else if (!inRestoreTier(d))
|
|
633
|
+
else if (!inRestoreTier(d)) {
|
|
634
|
+
execReady.push(d)
|
|
635
|
+
readyAt.set(d, Date.now())
|
|
636
|
+
}
|
|
607
637
|
}
|
|
608
638
|
}
|
|
609
639
|
|
|
@@ -741,6 +771,8 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
|
|
|
741
771
|
continue
|
|
742
772
|
}
|
|
743
773
|
|
|
774
|
+
const queuedMs = Date.now() - (readyAt.get(id) ?? Date.now())
|
|
775
|
+
readyAt.delete(id)
|
|
744
776
|
const leave = admit(id)
|
|
745
777
|
// Listed as running on dispatch, so the policy's next ask in this
|
|
746
778
|
// tick sees it; the completion callbacks unlist it.
|
|
@@ -749,7 +781,13 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
|
|
|
749
781
|
const heldMs = since === undefined ? 0 : Math.max(1, Date.now() - since)
|
|
750
782
|
if (since !== undefined) heldSince.delete(id)
|
|
751
783
|
const withHold = (o: TaskOutcome): TaskOutcome =>
|
|
752
|
-
heldMs
|
|
784
|
+
heldMs === 0 && queuedMs === 0
|
|
785
|
+
? o
|
|
786
|
+
: {
|
|
787
|
+
...o,
|
|
788
|
+
...(heldMs > 0 ? { admissionHeldMs: heldMs } : {}),
|
|
789
|
+
...(queuedMs > 0 ? { queuedMs } : {}),
|
|
790
|
+
}
|
|
753
791
|
// Crash-isolated observer hook — a throwing onStart must not abort
|
|
754
792
|
// the dispatch loop (it would strand the tick with the slot held).
|
|
755
793
|
// A demoted task's second dispatch is the same task, already started.
|
|
@@ -799,7 +837,10 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
|
|
|
799
837
|
leave()
|
|
800
838
|
untrack()
|
|
801
839
|
demoted.add(id)
|
|
802
|
-
if (pending.get(id) === 0)
|
|
840
|
+
if (pending.get(id) === 0) {
|
|
841
|
+
execReady.push(id)
|
|
842
|
+
readyAt.set(id, Date.now())
|
|
843
|
+
}
|
|
803
844
|
tick()
|
|
804
845
|
return
|
|
805
846
|
}
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
// stall task exec. See docs/design/event-stream-2026-06.md.
|
|
8
8
|
|
|
9
9
|
import { exitSignal } from '../exec/index.js'
|
|
10
|
-
import type { TaskNode, TaskOutcome } from '../graph/index.js'
|
|
10
|
+
import type { InputChanges, TaskNode, TaskOutcome } from '../graph/index.js'
|
|
11
11
|
import { isGroupTask } from '../graph/index.js'
|
|
12
12
|
import { maskedCommand, secretMask } from '../util/index.js'
|
|
13
13
|
import type { Logger } from './logger.js'
|
|
@@ -250,6 +250,12 @@ export interface OutcomeView {
|
|
|
250
250
|
blockedBy?: string
|
|
251
251
|
/** How long an `admit` policy held the task with a worker free (see `TaskOutcome`). */
|
|
252
252
|
admissionHeldMs?: number
|
|
253
|
+
/** How long the task waited ready for a worker (see `TaskOutcome`). */
|
|
254
|
+
queuedMs?: number
|
|
255
|
+
/** How many files a cacheable task's key read when it ran (see `TaskOutcome`). */
|
|
256
|
+
inputFiles?: number
|
|
257
|
+
/** What a missed task's key changed since its last saved entry (see `TaskOutcome`). */
|
|
258
|
+
inputChanges?: InputChanges
|
|
253
259
|
restored?: boolean
|
|
254
260
|
sandboxViolations?: number
|
|
255
261
|
sandboxViolationLines?: string[]
|
|
@@ -369,6 +375,9 @@ export function projectOutcome(outcome: TaskOutcome): OutcomeView {
|
|
|
369
375
|
if (outcome.cpuMs !== undefined) view.cpuMs = outcome.cpuMs
|
|
370
376
|
if (outcome.peakRssBytes !== undefined) view.peakRssBytes = outcome.peakRssBytes
|
|
371
377
|
if (outcome.admissionHeldMs !== undefined) view.admissionHeldMs = outcome.admissionHeldMs
|
|
378
|
+
if (outcome.queuedMs !== undefined) view.queuedMs = outcome.queuedMs
|
|
379
|
+
if (outcome.inputFiles !== undefined) view.inputFiles = outcome.inputFiles
|
|
380
|
+
if (outcome.inputChanges !== undefined) view.inputChanges = outcome.inputChanges
|
|
372
381
|
if (outcome.timedOut === true) view.timedOut = true
|
|
373
382
|
if (outcome.notReady !== undefined) view.notReady = outcome.notReady
|
|
374
383
|
if (outcome.blockedBy !== undefined) view.blockedBy = outcome.blockedBy
|
|
@@ -68,6 +68,7 @@ import {
|
|
|
68
68
|
import { markUnsaved, saveMiss, type OutputDirSnapshot } from './miss-save.js'
|
|
69
69
|
import type { FingerprintWatch } from './fingerprint-watch.js'
|
|
70
70
|
import { restoreHit } from './hit-restore.js'
|
|
71
|
+
import type { MissExplainer } from './miss-reason.js'
|
|
71
72
|
import { shellVerdict } from './shell-verdict.js'
|
|
72
73
|
// The hit path's entry stays importable from here (tests).
|
|
73
74
|
export { restoreHit, type RestoreHitArgs } from './hit-restore.js'
|
|
@@ -147,6 +148,8 @@ export interface ExecuteArgs {
|
|
|
147
148
|
liveChildren?: Set<ReturnType<typeof Bun.spawn>>
|
|
148
149
|
/** Sample a spawned task's process tree (`TelemetrySource.track`); absent when nobody samples. */
|
|
149
150
|
track?: (taskId: string, pid: number) => () => void
|
|
151
|
+
/** Name what a missed key changed (`createMissExplainer`); absent when nobody listens. */
|
|
152
|
+
explainMiss?: MissExplainer
|
|
150
153
|
/**
|
|
151
154
|
* Run-level retry default (`--retry <n>` / `RunOptions.retries`).
|
|
152
155
|
* Explicit `exec.retries` wins, including an explicit 0. Threaded as
|
|
@@ -819,6 +822,7 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
819
822
|
if (c.kind === 'env' && named.includes(c.name) && !secretNamed(c.name))
|
|
820
823
|
c.hash = MASKED + c.hash
|
|
821
824
|
const inputs: TaskInputs | undefined = described?.inputs
|
|
825
|
+
const inputChanges = described !== undefined ? args.explainMiss?.(node.id, captured) : undefined
|
|
822
826
|
// A plugin may keep the request past the run (the cache closed): it gets
|
|
823
827
|
// the upstream outputs read now. Only the local floor leaves them unread.
|
|
824
828
|
if (inputs !== undefined && !isLocalExecutor(args.executor)) void inputs.upstream
|
|
@@ -1361,6 +1365,10 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
1361
1365
|
hash,
|
|
1362
1366
|
...(attempt > 1 ? { attempts: attempt } : {}),
|
|
1363
1367
|
...(failedAttempts.length > 0 ? { failedAttempts } : {}),
|
|
1368
|
+
...(described !== undefined
|
|
1369
|
+
? { inputFiles: captured.reduce((n, c) => (c.kind === 'file' ? n + 1 : n), 0) }
|
|
1370
|
+
: {}),
|
|
1371
|
+
...(inputChanges !== undefined ? { inputChanges } : {}),
|
|
1364
1372
|
...(unkeyed ? { unkeyed: true as const } : {}),
|
|
1365
1373
|
...(result.timedOut === true && effectiveExitCode !== 0 ? { timedOut: true as const } : {}),
|
|
1366
1374
|
...(result.cpuMs !== undefined ? { cpuMs: result.cpuMs } : {}),
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// Why a task missed the cache, for telemetry: its key's components against
|
|
2
|
+
// the last entry this cache saved for the same task. The `vx why` join
|
|
3
|
+
// (`diffKeyComponents`), asked at run time instead of after it.
|
|
4
|
+
//
|
|
5
|
+
// Paid only when a telemetry sink exists, and only on a miss: one scan of
|
|
6
|
+
// `entries` for the newest entry per task, the first time a miss asks, then
|
|
7
|
+
// one primary-key read of that entry's `entry_inputs` per miss.
|
|
8
|
+
|
|
9
|
+
import type { Database } from 'bun:sqlite'
|
|
10
|
+
import type { InputChanges } from '../graph/index.js'
|
|
11
|
+
import { diffKeyComponents } from './metrics.js'
|
|
12
|
+
import type { TaskInputComponent } from './task-hash.js'
|
|
13
|
+
|
|
14
|
+
/** How many changed components a miss names; `count` says how many there were. */
|
|
15
|
+
const NAMED = 10
|
|
16
|
+
|
|
17
|
+
export type MissExplainer = (
|
|
18
|
+
taskId: string,
|
|
19
|
+
components: readonly TaskInputComponent[],
|
|
20
|
+
) => InputChanges | undefined
|
|
21
|
+
|
|
22
|
+
export function createMissExplainer(db: Database): MissExplainer {
|
|
23
|
+
let newest: Map<string, string> | undefined
|
|
24
|
+
return (taskId, components) => {
|
|
25
|
+
try {
|
|
26
|
+
// Each task asks before its own command runs, so its own new entry is
|
|
27
|
+
// never the one it is compared with.
|
|
28
|
+
newest ??= new Map(
|
|
29
|
+
(
|
|
30
|
+
db
|
|
31
|
+
// A bare column beside MAX() comes from the row that has it.
|
|
32
|
+
.query(
|
|
33
|
+
'SELECT project, task, hash, MAX(created_at) FROM entries GROUP BY project, task',
|
|
34
|
+
)
|
|
35
|
+
.all() as { project: string; task: string; hash: string }[]
|
|
36
|
+
).map((r) => [`${r.project}#${r.task}`, r.hash]),
|
|
37
|
+
)
|
|
38
|
+
const prev = newest.get(taskId)
|
|
39
|
+
if (prev === undefined) return undefined
|
|
40
|
+
const before = db
|
|
41
|
+
.query('SELECT kind, name, hash FROM entry_inputs WHERE entry_hash = ?')
|
|
42
|
+
.all(prev) as TaskInputComponent[]
|
|
43
|
+
// Pruned, or saved before fingerprints were kept: nothing to compare.
|
|
44
|
+
if (before.length === 0) return undefined
|
|
45
|
+
const { entries } = diffKeyComponents(before, components)
|
|
46
|
+
return {
|
|
47
|
+
count: entries.length,
|
|
48
|
+
first: entries.slice(0, NAMED).map(({ kind, name, change }) => ({ kind, name, change })),
|
|
49
|
+
}
|
|
50
|
+
} catch {
|
|
51
|
+
// History is observability: an unreadable one explains nothing.
|
|
52
|
+
return undefined
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -29,6 +29,16 @@ export interface CiContext {
|
|
|
29
29
|
/** Which CI matched: 'github' | 'gitlab' | 'buildkite' | 'circleci'
|
|
30
30
|
* | 'generic' (bare `CI`), or null when no CI env is present. */
|
|
31
31
|
provider: string | null
|
|
32
|
+
/** The provider's page for this run (a GitHub Actions run, a GitLab pipeline). */
|
|
33
|
+
runUrl?: string
|
|
34
|
+
/** The pull or merge request number this run builds. */
|
|
35
|
+
change?: string
|
|
36
|
+
/** The workflow or pipeline name. */
|
|
37
|
+
pipeline?: string
|
|
38
|
+
/** The job (or step) within it. */
|
|
39
|
+
job?: string
|
|
40
|
+
/** 1 on a first run, 2 on its first re-run. */
|
|
41
|
+
attempt?: number
|
|
32
42
|
}
|
|
33
43
|
|
|
34
44
|
export interface HostContext {
|
|
@@ -307,11 +317,71 @@ function isTruthy(v: string | undefined): boolean {
|
|
|
307
317
|
*/
|
|
308
318
|
export function detectCi(env: NodeJS.ProcessEnv | Record<string, string | undefined>): CiContext {
|
|
309
319
|
for (const [varName, provider] of CI_PROVIDERS) {
|
|
310
|
-
if (isTruthy(env[varName])) return { ci: true, provider }
|
|
320
|
+
if (isTruthy(env[varName])) return { ci: true, provider, ...ciLinks(provider, env) }
|
|
311
321
|
}
|
|
312
322
|
return { ci: false, provider: null }
|
|
313
323
|
}
|
|
314
324
|
|
|
325
|
+
type CiLinks = Pick<CiContext, 'runUrl' | 'change' | 'pipeline' | 'job' | 'attempt'>
|
|
326
|
+
|
|
327
|
+
/** Where a dashboard links back to: each provider's own documented variables. */
|
|
328
|
+
function ciLinks(provider: string, env: Record<string, string | undefined>): CiLinks {
|
|
329
|
+
const v = (k: string): string | undefined => (env[k] === '' ? undefined : env[k])
|
|
330
|
+
const n = (raw: string | undefined, plus = 0): number | undefined => {
|
|
331
|
+
const x = raw === undefined ? NaN : Number(raw)
|
|
332
|
+
return Number.isInteger(x) && x >= 0 ? x + plus : undefined
|
|
333
|
+
}
|
|
334
|
+
let links: Record<string, string | number | undefined>
|
|
335
|
+
switch (provider) {
|
|
336
|
+
case 'github': {
|
|
337
|
+
const server = v('GITHUB_SERVER_URL') ?? 'https://github.com'
|
|
338
|
+
const repo = v('GITHUB_REPOSITORY')
|
|
339
|
+
const id = v('GITHUB_RUN_ID')
|
|
340
|
+
links = {
|
|
341
|
+
runUrl: repo && id ? `${server}/${repo}/actions/runs/${id}` : undefined,
|
|
342
|
+
// `refs/pull/<n>/merge` on a pull_request event.
|
|
343
|
+
change: /^refs\/pull\/(\d+)\//.exec(v('GITHUB_REF') ?? '')?.[1],
|
|
344
|
+
pipeline: v('GITHUB_WORKFLOW'),
|
|
345
|
+
job: v('GITHUB_JOB'),
|
|
346
|
+
attempt: n(v('GITHUB_RUN_ATTEMPT')),
|
|
347
|
+
}
|
|
348
|
+
break
|
|
349
|
+
}
|
|
350
|
+
case 'gitlab':
|
|
351
|
+
links = {
|
|
352
|
+
runUrl: v('CI_PIPELINE_URL'),
|
|
353
|
+
change: v('CI_MERGE_REQUEST_IID'),
|
|
354
|
+
pipeline: v('CI_PIPELINE_NAME'),
|
|
355
|
+
job: v('CI_JOB_NAME'),
|
|
356
|
+
}
|
|
357
|
+
break
|
|
358
|
+
case 'buildkite': {
|
|
359
|
+
const pr = v('BUILDKITE_PULL_REQUEST')
|
|
360
|
+
links = {
|
|
361
|
+
runUrl: v('BUILDKITE_BUILD_URL'),
|
|
362
|
+
// `false` when the build is not for one.
|
|
363
|
+
change: pr !== undefined && /^\d+$/.test(pr) ? pr : undefined,
|
|
364
|
+
pipeline: v('BUILDKITE_PIPELINE_SLUG'),
|
|
365
|
+
job: v('BUILDKITE_LABEL'),
|
|
366
|
+
// Counts re-runs from 0.
|
|
367
|
+
attempt: n(v('BUILDKITE_RETRY_COUNT'), 1),
|
|
368
|
+
}
|
|
369
|
+
break
|
|
370
|
+
}
|
|
371
|
+
case 'circleci':
|
|
372
|
+
links = {
|
|
373
|
+
runUrl: v('CIRCLE_BUILD_URL'),
|
|
374
|
+
// The PR's URL, ending in its number.
|
|
375
|
+
change: /\/(\d+)$/.exec(v('CIRCLE_PULL_REQUEST') ?? '')?.[1] ?? v('CIRCLE_PR_NUMBER'),
|
|
376
|
+
job: v('CIRCLE_JOB'),
|
|
377
|
+
}
|
|
378
|
+
break
|
|
379
|
+
default:
|
|
380
|
+
return {}
|
|
381
|
+
}
|
|
382
|
+
return Object.fromEntries(Object.entries(links).filter(([, x]) => x !== undefined)) as CiLinks
|
|
383
|
+
}
|
|
384
|
+
|
|
315
385
|
/** Host name (null on failure) + platform + arch. */
|
|
316
386
|
export function captureHostContext(): HostContext {
|
|
317
387
|
let host: string | null = null
|
package/src/orchestrator/run.ts
CHANGED
|
@@ -92,6 +92,7 @@ import { writeRunProfile, writeRunSummary } from './run-artifacts.js'
|
|
|
92
92
|
import { createSaveLane } from './save-lane.js'
|
|
93
93
|
import { formatRunSummary } from './summary.js'
|
|
94
94
|
import { detectFlaky, type FlakyCandidate, type FlakyFinding } from './failure-mode.js'
|
|
95
|
+
import { createMissExplainer } from './miss-reason.js'
|
|
95
96
|
import type { RunOptions, RunSummary } from './options.js'
|
|
96
97
|
|
|
97
98
|
// Per run, never shared: a `vx watch` process runs many, and a shared map
|
|
@@ -747,6 +748,11 @@ async function runOnBus(
|
|
|
747
748
|
dirty: gitContext.dirty,
|
|
748
749
|
ci: ciContext.ci,
|
|
749
750
|
ciProvider: ciContext.provider,
|
|
751
|
+
...(ciContext.runUrl !== undefined ? { ciRunUrl: ciContext.runUrl } : {}),
|
|
752
|
+
...(ciContext.change !== undefined ? { ciChange: ciContext.change } : {}),
|
|
753
|
+
...(ciContext.pipeline !== undefined ? { ciPipeline: ciContext.pipeline } : {}),
|
|
754
|
+
...(ciContext.job !== undefined ? { ciJob: ciContext.job } : {}),
|
|
755
|
+
...(ciContext.attempt !== undefined ? { ciAttempt: ciContext.attempt } : {}),
|
|
750
756
|
host: hostContext.host,
|
|
751
757
|
os: hostContext.os,
|
|
752
758
|
arch: hostContext.arch,
|
|
@@ -916,6 +922,10 @@ async function runOnBus(
|
|
|
916
922
|
const dependedOn = new Set<string>()
|
|
917
923
|
for (const n of nodes.values()) for (const d of n.deps) dependedOn.add(d)
|
|
918
924
|
|
|
925
|
+
// Why a task missed, for a sink: a read of the history per miss, so only
|
|
926
|
+
// when a sink exists.
|
|
927
|
+
const explainMiss =
|
|
928
|
+
telemetry === undefined ? undefined : createMissExplainer(prepared.localCache.dbHandle())
|
|
919
929
|
const buildExecuteArgs = (node: TaskNode, upstream: TaskOutcome[], reuseProbe = true) => {
|
|
920
930
|
const probe = reuseProbe ? shortCircuit.preProbed.get(node.id) : undefined
|
|
921
931
|
const upfrontKey = shortCircuit.uncachedKeys.get(node.id)
|
|
@@ -947,6 +957,7 @@ async function runOnBus(
|
|
|
947
957
|
persistentRegistry,
|
|
948
958
|
liveChildren,
|
|
949
959
|
...(telemetry?.track !== undefined ? { track: telemetry.track } : {}),
|
|
960
|
+
...(explainMiss !== undefined ? { explainMiss } : {}),
|
|
950
961
|
gitFilesCache,
|
|
951
962
|
hashCache,
|
|
952
963
|
...(probe !== undefined ? { preProbed: probe } : {}),
|
|
@@ -17,7 +17,13 @@
|
|
|
17
17
|
// core calls INTO; those are the behavior capabilities, kept separate.
|
|
18
18
|
|
|
19
19
|
import { sampleTrees } from '../exec/index.js'
|
|
20
|
-
import {
|
|
20
|
+
import {
|
|
21
|
+
isGroupTask,
|
|
22
|
+
type InputChanges,
|
|
23
|
+
type TaskNode,
|
|
24
|
+
type TaskOutcome,
|
|
25
|
+
type TaskStatus,
|
|
26
|
+
} from '../graph/index.js'
|
|
21
27
|
import { maskedCommand, settleWithin, teardownTimeoutMs } from '../util/index.js'
|
|
22
28
|
import type { RunEvent, RunEventSubscriber } from './events.js'
|
|
23
29
|
|
|
@@ -143,6 +149,16 @@ export interface RunContextRecord {
|
|
|
143
149
|
dirty: boolean | null
|
|
144
150
|
ci: boolean
|
|
145
151
|
ciProvider: string | null
|
|
152
|
+
/** The CI provider's page for this run. */
|
|
153
|
+
ciRunUrl?: string
|
|
154
|
+
/** The pull or merge request number the run builds. */
|
|
155
|
+
ciChange?: string
|
|
156
|
+
/** The CI workflow or pipeline name. */
|
|
157
|
+
ciPipeline?: string
|
|
158
|
+
/** The CI job (or step) within it. */
|
|
159
|
+
ciJob?: string
|
|
160
|
+
/** 1 on a first run, 2 on its first re-run. */
|
|
161
|
+
ciAttempt?: number
|
|
146
162
|
host: string | null
|
|
147
163
|
os: string
|
|
148
164
|
arch: string
|
|
@@ -207,6 +223,15 @@ export interface TaskTelemetry {
|
|
|
207
223
|
storedPeakRssBytes?: number
|
|
208
224
|
/** How long an `admit` policy held the task once it was ready. Additive. */
|
|
209
225
|
admissionHeldMs?: number
|
|
226
|
+
/** How long the task waited ready for a worker, any admission hold included. Additive. */
|
|
227
|
+
queuedMs?: number
|
|
228
|
+
/** On a cacheable task that ran: how many files its key read. Additive. */
|
|
229
|
+
inputFiles?: number
|
|
230
|
+
/**
|
|
231
|
+
* On a cacheable task that ran, when the cache holds an earlier entry for
|
|
232
|
+
* it: what its key changed since, the first ten named. Additive.
|
|
233
|
+
*/
|
|
234
|
+
inputChanges?: InputChanges
|
|
210
235
|
/**
|
|
211
236
|
* On a cache hit: whether outputs were written this run (`true`) or the
|
|
212
237
|
* disk already matched the entry and nothing was restored (`false`, an
|
|
@@ -741,6 +766,9 @@ export function taskTelemetryOf(o: TaskOutcome): TaskTelemetry {
|
|
|
741
766
|
if (o.storedCpuMs !== undefined) t.storedCpuMs = o.storedCpuMs
|
|
742
767
|
if (o.storedPeakRssBytes !== undefined) t.storedPeakRssBytes = o.storedPeakRssBytes
|
|
743
768
|
if (o.admissionHeldMs !== undefined) t.admissionHeldMs = o.admissionHeldMs
|
|
769
|
+
if (o.queuedMs !== undefined) t.queuedMs = o.queuedMs
|
|
770
|
+
if (o.inputFiles !== undefined) t.inputFiles = o.inputFiles
|
|
771
|
+
if (o.inputChanges !== undefined) t.inputChanges = o.inputChanges
|
|
744
772
|
if (isCacheHit(o.status)) t.restored = o.restored === true
|
|
745
773
|
if (o.wallclockStartNs !== undefined) t.wallclockStartNs = o.wallclockStartNs.toString()
|
|
746
774
|
if (o.wallclockEndNs !== undefined) t.wallclockEndNs = o.wallclockEndNs.toString()
|
package/src/util/index.ts
CHANGED
package/src/util/paths.ts
CHANGED
|
@@ -366,6 +366,16 @@ export function slashBraceExpansions(pattern: string): string[] {
|
|
|
366
366
|
return [pattern]
|
|
367
367
|
}
|
|
368
368
|
|
|
369
|
+
/**
|
|
370
|
+
* Whether a path git lists as UNTRACKED is an installed dependency: it sits
|
|
371
|
+
* under a `node_modules` directory. Such a file is never an input, and the
|
|
372
|
+
* enumeration drops it. A TRACKED file there (a committed test fixture) is
|
|
373
|
+
* a source like any other: dropped by path, an edit to it was a stale hit.
|
|
374
|
+
*/
|
|
375
|
+
export function isInstalledPath(rel: string): boolean {
|
|
376
|
+
return rel.startsWith('node_modules/') || rel.includes('/node_modules/')
|
|
377
|
+
}
|
|
378
|
+
|
|
369
379
|
// oxlint-disable-next-line no-control-regex
|
|
370
380
|
const CONTROL = /[\x00-\x1f\x7f-\x9f]/g
|
|
371
381
|
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
UserError,
|
|
16
16
|
gitSpawnRefusal,
|
|
17
17
|
isExecutableMissing,
|
|
18
|
+
isInstalledPath,
|
|
18
19
|
relPosix,
|
|
19
20
|
taskGlob,
|
|
20
21
|
} from '../util/index.js'
|
|
@@ -234,7 +235,11 @@ export async function affectedChanges(
|
|
|
234
235
|
|
|
235
236
|
// vx-lock.json (workspace-root metadata) is excluded like a gitignored
|
|
236
237
|
// file: re-running `vx lock` must not mark every project affected.
|
|
237
|
-
|
|
238
|
+
// An untracked file under `node_modules` is an install, which the input
|
|
239
|
+
// enumeration drops too (`isInstalledPath`); a tracked one is a source.
|
|
240
|
+
const changed = [...diffed, ...untracked.filter((s) => !isInstalledPath(s))].filter(
|
|
241
|
+
(s) => s !== LOCKFILE_NAME,
|
|
242
|
+
)
|
|
238
243
|
|
|
239
244
|
// A lockfile or workspace-definition change re-keys EVERY task, because the
|
|
240
245
|
// workspace fingerprint folds those files into every cache key. Mapping
|