@vzn/vx 0.0.594 → 0.0.596
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 +1 -1
- package/package.json +5 -5
- package/src/bin.ts +26 -11
- package/src/cache/archive.ts +2 -2
- package/src/cache/cache.ts +235 -174
- package/src/cache/chained-cache.ts +25 -7
- package/src/cache/config-evals.ts +19 -4
- package/src/cache/git-inputs.ts +19 -8
- package/src/cache/index.ts +1 -0
- package/src/cache/inputs.ts +122 -49
- package/src/cache/layer.ts +7 -5
- package/src/cache/layered-cache.ts +25 -2
- package/src/cache/policy.ts +5 -0
- package/src/cache/tar-stream.ts +1 -1
- package/src/cache/zstd.ts +10 -4
- package/src/cli/cache.ts +11 -15
- package/src/cli/completions.ts +42 -6
- package/src/cli/foreign-flags.ts +26 -5
- package/src/cli/help.ts +5 -3
- package/src/cli/init.ts +1 -1
- package/src/cli/last.ts +4 -1
- package/src/cli/plan-format.ts +9 -0
- package/src/cli/plugin-templates.ts +2 -2
- package/src/cli/run.ts +60 -42
- package/src/cli/select.ts +30 -3
- package/src/cli/show.ts +42 -3
- package/src/cli/watch-filter.ts +77 -13
- package/src/cli/watch-fs.ts +15 -7
- package/src/cli/watch-judge.ts +16 -9
- package/src/cli/watch.ts +22 -15
- package/src/config.ts +26 -26
- package/src/exec/env.ts +5 -0
- package/src/exec/index.ts +1 -0
- package/src/exec/proc-sample.ts +6 -2
- package/src/exec/runner.ts +98 -40
- package/src/exec/sandbox-paths.ts +12 -6
- package/src/exec/sandbox-runtime.ts +136 -44
- package/src/exec/sandbox-violations.ts +58 -11
- package/src/graph/index.ts +1 -0
- package/src/graph/scheduler.ts +29 -13
- package/src/graph/task-graph.ts +134 -25
- package/src/index.ts +6 -0
- package/src/orchestrator/admission.ts +12 -3
- package/src/orchestrator/events.ts +10 -1
- package/src/orchestrator/execute-task.ts +65 -20
- package/src/orchestrator/failure-mode.ts +8 -2
- package/src/orchestrator/framed-output.ts +3 -3
- package/src/orchestrator/hit-restore.ts +6 -3
- package/src/orchestrator/local-shortcircuit.ts +4 -1
- package/src/orchestrator/lockfile-claim.ts +43 -46
- package/src/orchestrator/logger.ts +11 -6
- package/src/orchestrator/metrics.ts +19 -8
- package/src/orchestrator/miss-save.ts +2 -2
- package/src/orchestrator/options.ts +7 -0
- package/src/orchestrator/plan.ts +28 -4
- package/src/orchestrator/plugin-host.ts +25 -3
- package/src/orchestrator/plugin.ts +39 -17
- package/src/orchestrator/prepare.ts +13 -4
- package/src/orchestrator/run-artifacts.ts +2 -1
- package/src/orchestrator/run.ts +72 -12
- package/src/orchestrator/sandbox-request.ts +65 -9
- package/src/orchestrator/save-lane.ts +5 -5
- package/src/orchestrator/signals.ts +5 -2
- package/src/orchestrator/summary.ts +16 -5
- package/src/orchestrator/telemetry.ts +16 -7
- package/src/util/index.ts +7 -1
- package/src/util/paths.ts +23 -3
- package/src/util/secret-mask.ts +36 -0
- package/src/util/settle.ts +15 -0
- package/src/workspace/affected.ts +30 -8
- package/src/workspace/config-cache.ts +25 -4
- package/src/workspace/config-imports.ts +8 -6
- package/src/workspace/config-schema.ts +74 -9
- package/src/workspace/filter.ts +19 -0
- package/src/workspace/migrate-scripts.ts +5 -3
- package/src/workspace/migration.ts +1 -1
- package/src/workspace/package-graph.ts +15 -3
- package/src/workspace/project-loader.ts +76 -2
- package/src/workspace/repo-id.ts +42 -4
- package/src/workspace/workspace.ts +98 -20
package/src/graph/task-graph.ts
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import path from 'node:path'
|
|
2
1
|
import type { ProjectConfig, TaskConfig, WorkspaceRules } from '../config.js'
|
|
3
2
|
import {
|
|
4
3
|
asTrees,
|
|
@@ -109,6 +108,11 @@ export function isGroupTask(node: TaskNode): boolean {
|
|
|
109
108
|
return node.config.exec === undefined
|
|
110
109
|
}
|
|
111
110
|
|
|
111
|
+
/** The default `build` (orchestrator/projects.ts): a config cannot key a group, so it is the one keyed group. */
|
|
112
|
+
function isKeyedGroup(task: TaskConfig): boolean {
|
|
113
|
+
return task.exec === undefined && task.cache !== undefined
|
|
114
|
+
}
|
|
115
|
+
|
|
112
116
|
/**
|
|
113
117
|
* Mark, for focused-flow display, the real tasks a requested GROUP
|
|
114
118
|
* stands for. A group has no output of its own, so `vx run build`
|
|
@@ -160,7 +164,8 @@ export function markSurfacedDeps(nodes: Map<string, TaskNode>): number {
|
|
|
160
164
|
*
|
|
161
165
|
* - Bare task names (`'build'`) → one entry per project in
|
|
162
166
|
* `candidates` that declares the task. Missing in a given project
|
|
163
|
-
* is silent (sparse tasks are normal across a workspace).
|
|
167
|
+
* is silent (sparse tasks are normal across a workspace). A task
|
|
168
|
+
* `unselected` holds (the default `build`) is no match.
|
|
164
169
|
* - Anchored entries (`'pkg#task'`) → one entry exactly, ignoring
|
|
165
170
|
* `candidates`. Silently dropped if pkg/task doesn't exist (the
|
|
166
171
|
* CLI's pre-validation catches malformed strings).
|
|
@@ -171,6 +176,7 @@ export function expandRequested(
|
|
|
171
176
|
tasks: readonly string[],
|
|
172
177
|
candidates: readonly string[],
|
|
173
178
|
projects: Map<string, ProjectEntry>,
|
|
179
|
+
unselected?: (task: TaskConfig) => boolean,
|
|
174
180
|
): Array<{ project: string; task: string }> {
|
|
175
181
|
const seen = new Set<string>()
|
|
176
182
|
const out: Array<{ project: string; task: string }> = []
|
|
@@ -189,7 +195,7 @@ export function expandRequested(
|
|
|
189
195
|
continue
|
|
190
196
|
}
|
|
191
197
|
for (const name of candidates) {
|
|
192
|
-
if (
|
|
198
|
+
if (selectsTask(projects, name, spec, unselected)) push(name, spec)
|
|
193
199
|
}
|
|
194
200
|
}
|
|
195
201
|
return out
|
|
@@ -214,6 +220,17 @@ function declaresTask(projects: Map<string, ProjectEntry>, project: string, task
|
|
|
214
220
|
return declaredTask(projects.get(project)?.config, task) !== undefined
|
|
215
221
|
}
|
|
216
222
|
|
|
223
|
+
/** What a bare name matches: a declared task `unselected` leaves alone. */
|
|
224
|
+
function selectsTask(
|
|
225
|
+
projects: Map<string, ProjectEntry>,
|
|
226
|
+
project: string,
|
|
227
|
+
task: string,
|
|
228
|
+
unselected: ((task: TaskConfig) => boolean) | undefined,
|
|
229
|
+
): boolean {
|
|
230
|
+
const config = declaredTask(projects.get(project)?.config, task)
|
|
231
|
+
return config !== undefined && unselected?.(config) !== true
|
|
232
|
+
}
|
|
233
|
+
|
|
217
234
|
/**
|
|
218
235
|
* The requested specs `expandRequested` silently dropped — each one
|
|
219
236
|
* matched NO project, so nothing it asked for will run.
|
|
@@ -230,6 +247,7 @@ export function unresolvedRequests(
|
|
|
230
247
|
tasks: readonly string[],
|
|
231
248
|
candidates: readonly string[],
|
|
232
249
|
projects: Map<string, ProjectEntry>,
|
|
250
|
+
unselected?: (task: TaskConfig) => boolean,
|
|
233
251
|
): string[] {
|
|
234
252
|
const out: string[] = []
|
|
235
253
|
const seen = new Set<string>()
|
|
@@ -239,7 +257,8 @@ export function unresolvedRequests(
|
|
|
239
257
|
const resolved =
|
|
240
258
|
idx >= 0
|
|
241
259
|
? declaresTask(projects, spec.slice(0, idx), spec.slice(idx + 1))
|
|
242
|
-
: candidates.length === 0 ||
|
|
260
|
+
: candidates.length === 0 ||
|
|
261
|
+
candidates.some((name) => selectsTask(projects, name, spec, unselected))
|
|
243
262
|
if (resolved) continue
|
|
244
263
|
seen.add(spec)
|
|
245
264
|
out.push(spec)
|
|
@@ -380,6 +399,55 @@ export function buildTaskGraph(options: BuildGraphOptions): Map<string, TaskNode
|
|
|
380
399
|
return declared.has(name)
|
|
381
400
|
}
|
|
382
401
|
|
|
402
|
+
// Package cycles, for the default `build` (`isKeyedGroup`): each project
|
|
403
|
+
// on one maps to its strongly connected component, found on the first ask
|
|
404
|
+
// (iterative Tarjan over `directDeps`, the order the `^name` walk reads).
|
|
405
|
+
const component = new Map<string, number>()
|
|
406
|
+
const cyclic = new Set<number>()
|
|
407
|
+
let components = 0
|
|
408
|
+
function componentOf(start: string): number {
|
|
409
|
+
const known = component.get(start)
|
|
410
|
+
if (known !== undefined) return known
|
|
411
|
+
const index = new Map<string, number>()
|
|
412
|
+
const low = new Map<string, number>()
|
|
413
|
+
const open: string[] = []
|
|
414
|
+
const work: Array<[name: string, next: number]> = []
|
|
415
|
+
const enter = (name: string): void => {
|
|
416
|
+
index.set(name, index.size)
|
|
417
|
+
low.set(name, index.size - 1)
|
|
418
|
+
open.push(name)
|
|
419
|
+
work.push([name, 0])
|
|
420
|
+
}
|
|
421
|
+
enter(start)
|
|
422
|
+
while (work.length > 0) {
|
|
423
|
+
const top = work[work.length - 1]!
|
|
424
|
+
const deps = packageGraph.directDeps(top[0])
|
|
425
|
+
if (top[1] < deps.length) {
|
|
426
|
+
const dep = deps[top[1]++]!
|
|
427
|
+
if (component.has(dep)) continue
|
|
428
|
+
if (!index.has(dep)) enter(dep)
|
|
429
|
+
else low.set(top[0], Math.min(low.get(top[0])!, index.get(dep)!))
|
|
430
|
+
continue
|
|
431
|
+
}
|
|
432
|
+
work.pop()
|
|
433
|
+
const name = top[0]
|
|
434
|
+
const parent = work[work.length - 1]
|
|
435
|
+
if (parent !== undefined) low.set(parent[0], Math.min(low.get(parent[0])!, low.get(name)!))
|
|
436
|
+
if (low.get(name) !== index.get(name)) continue
|
|
437
|
+
const id = components++
|
|
438
|
+
let size = 0
|
|
439
|
+
for (let member = ''; member !== name; size++) {
|
|
440
|
+
member = open.pop()!
|
|
441
|
+
component.set(member, id)
|
|
442
|
+
}
|
|
443
|
+
if (size > 1) cyclic.add(id)
|
|
444
|
+
}
|
|
445
|
+
return component.get(start)!
|
|
446
|
+
}
|
|
447
|
+
const onCycle = (name: string): boolean => cyclic.has(componentOf(name))
|
|
448
|
+
const sameCycle = (a: string, b: string): boolean =>
|
|
449
|
+
onCycle(a) && componentOf(a) === componentOf(b)
|
|
450
|
+
|
|
383
451
|
// Resolves one `dependsOn` entry of `frame`'s task into its edges.
|
|
384
452
|
function resolveEntry(frame: Frame, raw: string): void {
|
|
385
453
|
const { node } = frame
|
|
@@ -451,7 +519,14 @@ export function buildTaskGraph(options: BuildGraphOptions): Map<string, TaskNode
|
|
|
451
519
|
// shape), and a cycle walks the frontier straight back to the
|
|
452
520
|
// origin. Mirrors the self-pattern rule above — a task can never
|
|
453
521
|
// depend on itself.
|
|
522
|
+
//
|
|
523
|
+
// The default `build` sits on every project, so on a package cycle it
|
|
524
|
+
// would make a task cycle no config declares. Its own walk passes
|
|
525
|
+
// through the projects on its cycle (each of their builds reaches it),
|
|
526
|
+
// and a walk that meets it on a cycle takes the edge and goes on past
|
|
527
|
+
// it, so the builds it could not depend on still come first.
|
|
454
528
|
const re = isTaskPattern(spec.task) ? compileTaskPattern(spec.task) : null
|
|
529
|
+
const keyed = isKeyedGroup(node.config)
|
|
455
530
|
const visited = new Set<string>([projectName])
|
|
456
531
|
const frontier = [...packageGraph.directDeps(projectName)]
|
|
457
532
|
let held = false
|
|
@@ -460,8 +535,15 @@ export function buildTaskGraph(options: BuildGraphOptions): Map<string, TaskNode
|
|
|
460
535
|
if (visited.has(target)) continue
|
|
461
536
|
visited.add(target)
|
|
462
537
|
if (re === null) {
|
|
463
|
-
if (
|
|
464
|
-
|
|
538
|
+
if (keyed && sameCycle(projectName, target)) {
|
|
539
|
+
frontier.push(...packageGraph.directDeps(target))
|
|
540
|
+
} else if (visit(frame, target, spec.task, false)) {
|
|
541
|
+
held = true
|
|
542
|
+
const task = declaredTask(projects.get(target)!.config, spec.task)!
|
|
543
|
+
if (isKeyedGroup(task) && onCycle(target)) {
|
|
544
|
+
frontier.push(...packageGraph.directDeps(target))
|
|
545
|
+
}
|
|
546
|
+
} else frontier.push(...packageGraph.directDeps(target))
|
|
465
547
|
} else {
|
|
466
548
|
const names = Object.keys(projects.get(target)?.config.tasks ?? {}).filter((n) =>
|
|
467
549
|
re.test(n),
|
|
@@ -975,36 +1057,51 @@ const GLOB_HEAD_END = /[*?{}\\!]/
|
|
|
975
1057
|
* directory its own head names. A whole subtree meets the globs whose
|
|
976
1058
|
* literal prefix lies at or under it, found the same way from the glob's
|
|
977
1059
|
* side (item 941).
|
|
1060
|
+
*
|
|
1061
|
+
* Given `sideOf`, only the pairs whose members sit on different sides are
|
|
1062
|
+
* wanted (a reader and a writer, `detectInputOverlaps`), and each index is
|
|
1063
|
+
* kept per side, so a member looks up only the other side's: thousands of
|
|
1064
|
+
* readers sharing one input (`src/**`, a root `tsconfig.base.json`) were
|
|
1065
|
+
* paired with each other, millions of pairs no rule judges (X-101).
|
|
978
1066
|
*/
|
|
979
1067
|
function overlapCandidates<T>(
|
|
980
1068
|
tasks: readonly T[],
|
|
981
1069
|
globsOf: (n: T) => readonly string[] | undefined,
|
|
1070
|
+
sideOf?: (n: T) => boolean,
|
|
982
1071
|
): Array<[number, number]> {
|
|
983
|
-
const
|
|
984
|
-
|
|
1072
|
+
const sides = sideOf === undefined ? 1 : 2
|
|
1073
|
+
// The index a member of side `s` looks up: its own side's, or the other's.
|
|
1074
|
+
const across = (s: number): number => (sides === 1 ? s : 1 - s)
|
|
1075
|
+
const perSide = (): Array<Map<string, number[]>> =>
|
|
1076
|
+
Array.from({ length: sides }, () => new Map<string, number[]>())
|
|
1077
|
+
const globs = perSide()
|
|
1078
|
+
const globsUnder = perSide()
|
|
985
1079
|
const literals: Array<[task: number, path: string]> = []
|
|
986
1080
|
// `covers` (item 941): a whole subtree `P/**` meets every glob whose
|
|
987
1081
|
// literal prefix is P or under it, so each glob looks up the subtrees
|
|
988
1082
|
// filed at its prefix and at each of that prefix's ancestors.
|
|
989
|
-
const subtrees =
|
|
1083
|
+
const subtrees = perSide()
|
|
990
1084
|
const prefixes: Array<[task: number, prefix: string]> = []
|
|
991
1085
|
const file = (index: Map<string, number[]>, key: string, i: number): void => {
|
|
992
1086
|
const list = index.get(key)
|
|
993
1087
|
if (list === undefined) index.set(key, [i])
|
|
994
1088
|
else list.push(i)
|
|
995
1089
|
}
|
|
1090
|
+
const side: number[] = []
|
|
996
1091
|
for (let i = 0; i < tasks.length; i++) {
|
|
1092
|
+
const s = sideOf?.(tasks[i]!) === true ? 1 : 0
|
|
1093
|
+
side.push(s)
|
|
997
1094
|
for (const tree of asTrees(globsOf(tasks[i]!) ?? [])) {
|
|
998
1095
|
if (isLiteralPattern(tree)) {
|
|
999
1096
|
literals.push([i, tree])
|
|
1000
1097
|
continue
|
|
1001
1098
|
}
|
|
1002
|
-
file(globs
|
|
1099
|
+
file(globs[s]!, tree, i)
|
|
1003
1100
|
const dir = wholeSubtreePrefixes([tree])?.[0]
|
|
1004
|
-
if (dir !== undefined) file(subtrees
|
|
1101
|
+
if (dir !== undefined) file(subtrees[s]!, dir, i)
|
|
1005
1102
|
prefixes.push([i, staticPrefix(tree)])
|
|
1006
1103
|
const head = tree.slice(0, tree.search(GLOB_HEAD_END))
|
|
1007
|
-
file(globsUnder
|
|
1104
|
+
file(globsUnder[s]!, head.slice(0, Math.max(0, head.lastIndexOf('/'))), i)
|
|
1008
1105
|
}
|
|
1009
1106
|
}
|
|
1010
1107
|
const n = tasks.length
|
|
@@ -1012,34 +1109,38 @@ function overlapCandidates<T>(
|
|
|
1012
1109
|
const pair = (x: number, y: number): void => {
|
|
1013
1110
|
if (x !== y) pairs.add(x < y ? x * n + y : y * n + x)
|
|
1014
1111
|
}
|
|
1015
|
-
for (const list of globs
|
|
1112
|
+
for (const [tree, list] of globs[0]!) {
|
|
1113
|
+
const other = sides === 1 ? list : (globs[1]!.get(tree) ?? [])
|
|
1016
1114
|
for (let x = 0; x < list.length; x++) {
|
|
1017
|
-
for (let y = x + 1; y <
|
|
1115
|
+
for (let y = sides === 1 ? x + 1 : 0; y < other.length; y++) pair(list[x]!, other[y]!)
|
|
1018
1116
|
}
|
|
1019
1117
|
}
|
|
1020
1118
|
// `witnessed`: two globs meet only when one's literal prefix is the
|
|
1021
1119
|
// other's or under it, so each looks up the globs filed at its prefix
|
|
1022
1120
|
// and at each ancestor.
|
|
1023
|
-
const byPrefix =
|
|
1024
|
-
for (const [i, prefix] of prefixes) file(byPrefix
|
|
1121
|
+
const byPrefix = perSide()
|
|
1122
|
+
for (const [i, prefix] of prefixes) file(byPrefix[side[i]!]!, rootless(prefix), i)
|
|
1025
1123
|
for (const [i, raw] of prefixes) {
|
|
1124
|
+
const index = byPrefix[across(side[i]!)]!
|
|
1026
1125
|
const prefix = rootless(raw)
|
|
1027
|
-
for (const j of
|
|
1028
|
-
if (prefix !== '') for (const j of
|
|
1126
|
+
for (const j of index.get(prefix) ?? []) pair(i, j)
|
|
1127
|
+
if (prefix !== '') for (const j of index.get('') ?? []) pair(i, j)
|
|
1029
1128
|
for (let sep = prefix.indexOf('/'); sep !== -1; sep = prefix.indexOf('/', sep + 1)) {
|
|
1030
|
-
for (const j of
|
|
1129
|
+
for (const j of index.get(prefix.slice(0, sep)) ?? []) pair(i, j)
|
|
1031
1130
|
}
|
|
1032
1131
|
}
|
|
1033
1132
|
for (const [i, prefix] of prefixes) {
|
|
1034
|
-
|
|
1133
|
+
const index = subtrees[across(side[i]!)]!
|
|
1134
|
+
for (const j of index.get(prefix) ?? []) pair(i, j)
|
|
1035
1135
|
for (let sep = prefix.indexOf('/'); sep !== -1; sep = prefix.indexOf('/', sep + 1)) {
|
|
1036
|
-
for (const j of
|
|
1136
|
+
for (const j of index.get(prefix.slice(0, sep)) ?? []) pair(i, j)
|
|
1037
1137
|
}
|
|
1038
1138
|
}
|
|
1039
1139
|
for (const [i, path] of literals) {
|
|
1040
|
-
|
|
1140
|
+
const index = globsUnder[across(side[i]!)]!
|
|
1141
|
+
for (const j of index.get('') ?? []) pair(i, j)
|
|
1041
1142
|
for (let sep = path.indexOf('/'); sep !== -1; sep = path.indexOf('/', sep + 1)) {
|
|
1042
|
-
for (const j of
|
|
1143
|
+
for (const j of index.get(path.slice(0, sep)) ?? []) pair(i, j)
|
|
1043
1144
|
}
|
|
1044
1145
|
}
|
|
1045
1146
|
return [...pairs].sort((x, y) => x - y).map((p) => [Math.floor(p / n), p % n])
|
|
@@ -1236,7 +1337,11 @@ function detectInputOverlaps(nodes: Map<string, TaskNode>, workspaceRoot?: strin
|
|
|
1236
1337
|
if (projectReaders) {
|
|
1237
1338
|
for (const sides of byProject.values()) {
|
|
1238
1339
|
if (sides.length < 2) continue
|
|
1239
|
-
for (const [i, j] of overlapCandidates(
|
|
1340
|
+
for (const [i, j] of overlapCandidates(
|
|
1341
|
+
sides,
|
|
1342
|
+
(s) => s.globs,
|
|
1343
|
+
(s) => s.reads,
|
|
1344
|
+
)) {
|
|
1240
1345
|
readsOutputs(sides[i]!, sides[j]!, 'files')
|
|
1241
1346
|
}
|
|
1242
1347
|
}
|
|
@@ -1251,7 +1356,11 @@ function detectInputOverlaps(nodes: Map<string, TaskNode>, workspaceRoot?: strin
|
|
|
1251
1356
|
rooted.push({ node, globs, reads: false, rel })
|
|
1252
1357
|
}
|
|
1253
1358
|
}
|
|
1254
|
-
for (const [i, j] of overlapCandidates(
|
|
1359
|
+
for (const [i, j] of overlapCandidates(
|
|
1360
|
+
rooted,
|
|
1361
|
+
(s) => s.globs,
|
|
1362
|
+
(s) => s.reads,
|
|
1363
|
+
)) {
|
|
1255
1364
|
readsOutputs(rooted[i]!, rooted[j]!, 'workspaceFiles')
|
|
1256
1365
|
}
|
|
1257
1366
|
}
|
package/src/index.ts
CHANGED
|
@@ -179,6 +179,12 @@ export type {
|
|
|
179
179
|
// A remote executor gives a task back to the local floor by rejecting with
|
|
180
180
|
// this (B-100): core runs it here and says why once.
|
|
181
181
|
export { executorFallback } from './exec/index.js'
|
|
182
|
+
// How the local executor puts the args after `--` on a command, for an
|
|
183
|
+
// executor that runs `ExecuteRequest.command` itself: `@vzn/vx-reapi` and
|
|
184
|
+
// the `vx init --plugin executor` example each joined their own, and a
|
|
185
|
+
// trailing comment or a template literal's closing newline lost the args
|
|
186
|
+
// there while the local floor kept them (X-112).
|
|
187
|
+
export { withForwardArgs } from './exec/index.js'
|
|
182
188
|
export type {
|
|
183
189
|
ExecuteRequest,
|
|
184
190
|
ExecuteResult,
|
|
@@ -27,7 +27,10 @@ import { computeTaskHash, type ComputeHashArgs } from './task-hash.js'
|
|
|
27
27
|
|
|
28
28
|
/** What `taintTracker` answers per dispatch, and what it learns per finish. */
|
|
29
29
|
export interface TaintTracker {
|
|
30
|
-
/**
|
|
30
|
+
/**
|
|
31
|
+
* Whether `node`, dispatched with these key `upstream` outcomes, builds on
|
|
32
|
+
* bytes nothing vouches for. Its order-only deps count too.
|
|
33
|
+
*/
|
|
31
34
|
judge: (node: TaskNode, upstream: TaskOutcome[]) => boolean
|
|
32
35
|
/** Every settled outcome, the scheduler's `onFinish`. */
|
|
33
36
|
settled: (outcome: TaskOutcome) => void
|
|
@@ -102,8 +105,14 @@ export function taintTracker(
|
|
|
102
105
|
}
|
|
103
106
|
return {
|
|
104
107
|
// A restore-tier task may run before its deps and see holes here; it
|
|
105
|
-
// never saves anyway (a hit restores), so a hole is not taint.
|
|
106
|
-
|
|
108
|
+
// never saves anyway (a hit restores), so a hole is not taint. The
|
|
109
|
+
// upstream is the key's, which drops order-only edges; their failure
|
|
110
|
+
// reaches the task through the dependency `--exclude-dependencies`
|
|
111
|
+
// took out, so they are read from the settled outcomes.
|
|
112
|
+
judge: (node, upstream) =>
|
|
113
|
+
seeds.has(node.id) ||
|
|
114
|
+
upstream.some(through) ||
|
|
115
|
+
node.orderOnly?.some((d) => through(outcomes.get(d))) === true,
|
|
107
116
|
settled: (outcome) => {
|
|
108
117
|
outcomes.set(outcome.node.id, outcome)
|
|
109
118
|
},
|
|
@@ -13,6 +13,15 @@ import { maskedCommand, secretMask } from '../util/index.js'
|
|
|
13
13
|
import type { Logger } from './logger.js'
|
|
14
14
|
import type { RunContext } from './summary.js'
|
|
15
15
|
|
|
16
|
+
/**
|
|
17
|
+
* A task no cache answered for: it declares no `cache`, or the run's policy
|
|
18
|
+
* read and wrote nothing. Its run is `no-cache`, never a miss, on every
|
|
19
|
+
* surface — `--dry`'s plan, the frame, the footer, `--report`, `--summarize`.
|
|
20
|
+
*/
|
|
21
|
+
export function ranNoCache(o: { node: TaskNode; cacheOff?: boolean }): boolean {
|
|
22
|
+
return o.node.config.cache === undefined || o.cacheOff === true
|
|
23
|
+
}
|
|
24
|
+
|
|
16
25
|
/** Payload of the `run:start` event — mirrors the Logger.runStart hook. */
|
|
17
26
|
export interface RunStartInfo {
|
|
18
27
|
total: number
|
|
@@ -373,7 +382,7 @@ export function projectOutcome(outcome: TaskOutcome): OutcomeView {
|
|
|
373
382
|
durationMs: outcome.durationMs,
|
|
374
383
|
}
|
|
375
384
|
if (isGroupTask(outcome.node)) view.isGroup = true
|
|
376
|
-
else if (outcome
|
|
385
|
+
else if (ranNoCache(outcome)) view.noCache = true
|
|
377
386
|
if (outcome.storedDurationMs !== undefined) view.storedDurationMs = outcome.storedDurationMs
|
|
378
387
|
if (outcome.storedCpuMs !== undefined) view.storedCpuMs = outcome.storedCpuMs
|
|
379
388
|
if (outcome.storedPeakRssBytes !== undefined) view.storedPeakRssBytes = outcome.storedPeakRssBytes
|
|
@@ -38,6 +38,7 @@ import {
|
|
|
38
38
|
type TaskInputs,
|
|
39
39
|
PersistentReadyError,
|
|
40
40
|
sandboxReads,
|
|
41
|
+
maskCaptured,
|
|
41
42
|
} from '../exec/index.js'
|
|
42
43
|
import { isGroupTask, RestoreDemoted, type TaskNode, type TaskOutcome } from '../graph/index.js'
|
|
43
44
|
import {
|
|
@@ -361,12 +362,17 @@ async function executePersistentTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
361
362
|
args.cacheDir,
|
|
362
363
|
)
|
|
363
364
|
placeholders = sb.placeholders
|
|
365
|
+
// A wrap that refuses (a port the host holds) leaves the server unspawned
|
|
366
|
+
// and the placeholders vx made for it in the project for every later run.
|
|
364
367
|
const wrapped = await wrapSandboxedCommand({
|
|
365
368
|
command: plainCommand,
|
|
366
369
|
cwd: node.projectDir,
|
|
367
370
|
env,
|
|
368
371
|
...sb.sandbox,
|
|
369
372
|
server: true,
|
|
373
|
+
}).catch(async (err: unknown) => {
|
|
374
|
+
await sweepPlaceholders(placeholders)
|
|
375
|
+
throw err
|
|
370
376
|
})
|
|
371
377
|
command = wrapped.wrapped
|
|
372
378
|
bridgeTag = wrapped.tag
|
|
@@ -534,6 +540,20 @@ async function executePersistentTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
534
540
|
}
|
|
535
541
|
}
|
|
536
542
|
|
|
543
|
+
// Ready only once the stop had landed: a trap that prints the marker on
|
|
544
|
+
// the way down, or a race with the signal. The stop is killing it (it
|
|
545
|
+
// is in `liveChildren`), and it served nobody, as a one-shot that exits
|
|
546
|
+
// 0 on the stop is aborted (item 962).
|
|
547
|
+
if (isAborted(args.stopSignal)) {
|
|
548
|
+
return {
|
|
549
|
+
node,
|
|
550
|
+
status: 'aborted',
|
|
551
|
+
exitCode: signalExitCode(forwardedSignal(args.stopSignal!.reason)),
|
|
552
|
+
durationMs: spawn.readyMs(),
|
|
553
|
+
wallclockStartNs,
|
|
554
|
+
wallclockEndNs: process.hrtime.bigint() - args.runStartHrTimeNs,
|
|
555
|
+
}
|
|
556
|
+
}
|
|
537
557
|
args.persistentRegistry?.set(node.id, spawn.child)
|
|
538
558
|
forgetUndeclaredWrites(args, undeclaredWriteReach(node, args.workspaceRoot))
|
|
539
559
|
if (mayWriteFingerprint(node, args.workspaceRoot)) args.fingerprintWatch?.wrote()
|
|
@@ -914,7 +934,7 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
914
934
|
// re-create would stay in a same-project consumer's input set, keeping
|
|
915
935
|
// that consumer's key unchanged while the file is gone from disk.
|
|
916
936
|
const endClean = span('miss: clean outputs')
|
|
917
|
-
const cleanedRels = await cleanOutputs(
|
|
937
|
+
const cleanedRels = await cleanOutputs(cleanArgs)
|
|
918
938
|
endClean()
|
|
919
939
|
args.gitFilesCache?.noteClean(node.id, node.projectDir, cleanedRels)
|
|
920
940
|
args.gitFilesCache?.markOutputsChanged(node.projectDir, cleanedRels)
|
|
@@ -934,15 +954,21 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
934
954
|
const endReq = span('miss: build request')
|
|
935
955
|
const req = await buildRequest()
|
|
936
956
|
endReq()
|
|
937
|
-
// An executor that THROWS produces no captured output
|
|
938
|
-
//
|
|
939
|
-
//
|
|
940
|
-
//
|
|
941
|
-
// task failed, and a remote executor's failures are exactly the ones with
|
|
942
|
-
// no other trace. Rethrown unchanged — the scheduler still classifies it,
|
|
943
|
-
// and still prints it plainly for a UserError.
|
|
957
|
+
// An executor that THROWS produces no captured output. Rethrown: the
|
|
958
|
+
// scheduler classifies it and prints its one line into the task's own
|
|
959
|
+
// stream (run()'s onError), where the frame reads it; a copy written
|
|
960
|
+
// here too printed the reason twice.
|
|
944
961
|
const endExec = span('miss: execute')
|
|
945
|
-
|
|
962
|
+
// A plugin's execute may throw before returning or return a bare result:
|
|
963
|
+
// the throw is its rejection, the value its resolution, so both reach
|
|
964
|
+
// the naming catch and the cleanup in finally below.
|
|
965
|
+
let running: Promise<unknown>
|
|
966
|
+
try {
|
|
967
|
+
running = Promise.resolve(args.executor.execute(req))
|
|
968
|
+
} catch (thrown) {
|
|
969
|
+
running = Promise.reject(thrown)
|
|
970
|
+
}
|
|
971
|
+
let res = await boundAfterAbort(running, req.signal)
|
|
946
972
|
.then((r: unknown) => {
|
|
947
973
|
assertExecuteResult(args.executor.name, node.id, r)
|
|
948
974
|
return r
|
|
@@ -966,15 +992,12 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
966
992
|
}
|
|
967
993
|
const err = nameExecutorFailure(args.executor, raw)
|
|
968
994
|
// A remote executor's message carries the server's own text, which
|
|
969
|
-
// may echo the env it was sent: masked
|
|
970
|
-
//
|
|
995
|
+
// may echo the env it was sent: masked on the error the scheduler
|
|
996
|
+
// prints with its cause (L-39).
|
|
971
997
|
if (secrets !== null) {
|
|
972
998
|
for (const e of [err, err instanceof Error ? err.cause : undefined])
|
|
973
999
|
if (e instanceof Error) e.message = secrets.mask(e.message)
|
|
974
1000
|
}
|
|
975
|
-
const message =
|
|
976
|
-
err instanceof Error ? err.message : (secrets?.mask(String(err)) ?? String(err))
|
|
977
|
-
log.taskStderr(node, `${message}\n`)
|
|
978
1001
|
await sweepPlaceholders(placeholders)
|
|
979
1002
|
throw err
|
|
980
1003
|
})
|
|
@@ -988,7 +1011,11 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
988
1011
|
})
|
|
989
1012
|
endExec()
|
|
990
1013
|
if (secrets !== null)
|
|
991
|
-
res = {
|
|
1014
|
+
res = {
|
|
1015
|
+
...res,
|
|
1016
|
+
stdout: maskCaptured(res.stdout, secrets),
|
|
1017
|
+
stderr: maskCaptured(res.stderr, secrets),
|
|
1018
|
+
}
|
|
992
1019
|
// An executor that stopped on the timeout's abort exits non-zero; say
|
|
993
1020
|
// why, so the frame, the retry line and `timedOut` read as a timeout.
|
|
994
1021
|
if (timeoutFired && res.exitCode !== 0 && res.timedOut !== true) {
|
|
@@ -1051,8 +1078,9 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
1051
1078
|
// 258) — and an exit above 128 is a signal's number and nothing
|
|
1052
1079
|
// about what sent it (259). One frame line names the rule. A spawn that
|
|
1053
1080
|
// threw ran no shell: its 127 is vx's, and the line would send the
|
|
1054
|
-
// reader after a command that exists (A-41).
|
|
1055
|
-
|
|
1081
|
+
// reader after a command that exists (A-41). A run's stop killed it
|
|
1082
|
+
// itself: the SIGKILL past the grace read as the OOM killer's.
|
|
1083
|
+
if (!res.timedOut && res.spawnFailed !== true && args.stopSignal?.aborted !== true) {
|
|
1056
1084
|
const verdict = shellVerdict({
|
|
1057
1085
|
code,
|
|
1058
1086
|
command: step.command,
|
|
@@ -1121,6 +1149,21 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
1121
1149
|
args.executor.remote === true ? 'none' : undeclaredWriteReach(node, args.workspaceRoot)
|
|
1122
1150
|
const writesFingerprint =
|
|
1123
1151
|
args.executor.remote !== true && mayWriteFingerprint(node, args.workspaceRoot)
|
|
1152
|
+
// A stop that landed during the awaits above (the key, the probe, the
|
|
1153
|
+
// input description): the first attempt would wipe the last build and
|
|
1154
|
+
// hand the executor a request after the run had stopped. Through a call,
|
|
1155
|
+
// so the loop's own reads of the stop are not narrowed to false.
|
|
1156
|
+
if (isAborted(args.stopSignal)) {
|
|
1157
|
+
return {
|
|
1158
|
+
node,
|
|
1159
|
+
status: 'aborted',
|
|
1160
|
+
exitCode: signalExitCode(forwardedSignal(args.stopSignal?.reason)),
|
|
1161
|
+
durationMs: 0,
|
|
1162
|
+
hash,
|
|
1163
|
+
wallclockStartNs,
|
|
1164
|
+
wallclockEndNs: process.hrtime.bigint() - args.runStartHrTimeNs,
|
|
1165
|
+
}
|
|
1166
|
+
}
|
|
1124
1167
|
for (;;) {
|
|
1125
1168
|
attempt++
|
|
1126
1169
|
const a = await runAttempt()
|
|
@@ -1203,7 +1246,6 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
1203
1246
|
},
|
|
1204
1247
|
}
|
|
1205
1248
|
: {}),
|
|
1206
|
-
signal: requestSignal(),
|
|
1207
1249
|
...(effectiveTimeout !== undefined ? { timeoutMs: effectiveTimeout } : {}),
|
|
1208
1250
|
...(inputs !== undefined ? { inputs } : {}),
|
|
1209
1251
|
...(cfgCacheable ? { cacheKey: hash } : {}),
|
|
@@ -1215,7 +1257,10 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
1215
1257
|
outputs: { files: outputs, workspaceFiles: wsOutputs },
|
|
1216
1258
|
...(args.terminal === true ? { terminal: true as const } : {}),
|
|
1217
1259
|
}
|
|
1218
|
-
|
|
1260
|
+
// The signal last: it arms `exec.timeout`, and the sandbox's arming
|
|
1261
|
+
// below is vx's work, not the task's. Armed first, a 60 ms timeout
|
|
1262
|
+
// expired before the spawn and failed `echo` as timed out, unrun.
|
|
1263
|
+
if (!userSandbox) return { ...base, signal: requestSignal() }
|
|
1219
1264
|
await args.armSandbox?.()
|
|
1220
1265
|
const sb = await sandboxRequestFor(
|
|
1221
1266
|
node,
|
|
@@ -1227,7 +1272,7 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
1227
1272
|
)
|
|
1228
1273
|
placeholders = sb.placeholders
|
|
1229
1274
|
withheld = sb.withheld
|
|
1230
|
-
return { ...base, sandbox: sb.sandbox }
|
|
1275
|
+
return { ...base, sandbox: sb.sandbox, signal: requestSignal() }
|
|
1231
1276
|
}
|
|
1232
1277
|
|
|
1233
1278
|
const wallclockEndNs = process.hrtime.bigint() - args.runStartHrTimeNs
|
|
@@ -29,6 +29,8 @@ export type FailureMode = 'stable' | 'flaky-recoverable' | 'flaky-fatal'
|
|
|
29
29
|
function keyOutcomesSql(source: string, where = ''): string {
|
|
30
30
|
return `SELECT project, task, hash,
|
|
31
31
|
SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) AS failures,
|
|
32
|
+
SUM(CASE WHEN status = 'success' AND attempts > 1 THEN attempts - 1 ELSE 0 END)
|
|
33
|
+
AS retried_failures,
|
|
32
34
|
SUM(CASE WHEN status = 'success' OR status IN ${HIT_STATUSES} OR cache_hit = 1
|
|
33
35
|
THEN 1 ELSE 0 END) AS passes
|
|
34
36
|
FROM ${source}
|
|
@@ -186,13 +188,17 @@ export interface FlakyTask {
|
|
|
186
188
|
/**
|
|
187
189
|
* Every task with a mixed-outcome key anywhere in the retained history
|
|
188
190
|
* (30 days, `RunHistory.pruneOlderThan`), most failures first: the
|
|
189
|
-
* doctor's list. A
|
|
191
|
+
* doctor's list. A pass that took a retry mixes its key on its own: it is
|
|
192
|
+
* one `success` row whose failed attempts count as failures (`vx info`
|
|
193
|
+
* said `none` over one). A full scan of `runs`, which an inspection verb
|
|
194
|
+
* affords.
|
|
190
195
|
*/
|
|
191
196
|
export function flakyTasks(db: Database): FlakyTask[] {
|
|
192
197
|
const rows = db
|
|
193
198
|
.query(
|
|
194
199
|
`SELECT project, task, COUNT(*) AS keys, SUM(passes) AS passes, SUM(failures) AS failures
|
|
195
|
-
FROM (
|
|
200
|
+
FROM (SELECT project, task, passes, failures + retried_failures AS failures
|
|
201
|
+
FROM (${keyOutcomesSql('runs')}))
|
|
196
202
|
WHERE failures > 0 AND passes > 0
|
|
197
203
|
GROUP BY project, task
|
|
198
204
|
ORDER BY failures DESC, passes DESC, project, task`,
|
|
@@ -30,7 +30,7 @@ import { isGroupTask, type TaskNode, type TaskOutcome } from '../graph/index.js'
|
|
|
30
30
|
import { maskedCommand } from '../util/index.js'
|
|
31
31
|
import { paint, type ColorSupport } from './colors.js'
|
|
32
32
|
import { formatDuration, neverStarted } from './summary.js'
|
|
33
|
-
import { outcomeLabel, skippedReason } from './events.js'
|
|
33
|
+
import { outcomeLabel, ranNoCache, skippedReason } from './events.js'
|
|
34
34
|
|
|
35
35
|
const NO_COLOR: ColorSupport = { enabled: false }
|
|
36
36
|
|
|
@@ -274,8 +274,8 @@ function cacheOf(o: TaskOutcome): { word: string; color: string } {
|
|
|
274
274
|
switch (o.status) {
|
|
275
275
|
case 'success':
|
|
276
276
|
case 'failed':
|
|
277
|
-
// A task
|
|
278
|
-
return { word: o
|
|
277
|
+
// A task no cache answered for never consulted one: not a miss.
|
|
278
|
+
return { word: ranNoCache(o) ? 'no-cache' : 'miss', color: '' } // dim
|
|
279
279
|
case 'cache-hit':
|
|
280
280
|
return o.restored === false
|
|
281
281
|
? { word: 'fresh', color: SUCCESS }
|
|
@@ -8,7 +8,6 @@
|
|
|
8
8
|
// which bytes a green run replays. Moved out of execute-task.ts
|
|
9
9
|
// 2026-09-10 as pure code motion, the mirror of miss-save.ts.
|
|
10
10
|
|
|
11
|
-
import path from 'node:path'
|
|
12
11
|
import type { CacheConfig } from '../config.js'
|
|
13
12
|
import {
|
|
14
13
|
type CacheEntry,
|
|
@@ -224,11 +223,15 @@ export async function restoreHit(restore: RestoreHitArgs): Promise<TaskOutcome>
|
|
|
224
223
|
let cleanedWsRels: string[] = []
|
|
225
224
|
if (additive) {
|
|
226
225
|
cleanedRels = hit.outputFiles.filter((p) => !p.startsWith(WORKSPACE_OUTPUT_PREFIX))
|
|
227
|
-
await cleanOutputPaths({ projectDir: node.projectDir, rels: cleanedRels })
|
|
226
|
+
await cleanOutputPaths({ projectDir: node.projectDir, rels: cleanedRels, outputs })
|
|
228
227
|
cleanedWsRels = hit.outputFiles
|
|
229
228
|
.filter((p) => p.startsWith(WORKSPACE_OUTPUT_PREFIX))
|
|
230
229
|
.map((p) => p.slice(WORKSPACE_OUTPUT_PREFIX.length))
|
|
231
|
-
await cleanOutputPaths({
|
|
230
|
+
await cleanOutputPaths({
|
|
231
|
+
projectDir: args.workspaceRoot,
|
|
232
|
+
rels: cleanedWsRels,
|
|
233
|
+
outputs: wsOutputs,
|
|
234
|
+
})
|
|
232
235
|
} else {
|
|
233
236
|
if (outputs.length > 0) cleanedRels = await cleanOutputs(cleanArgs)
|
|
234
237
|
if (wsOutputs.length > 0) cleanedWsRels = await cleanWorkspaceOutputs(wsCleanArgs)
|
|
@@ -194,7 +194,10 @@ export async function startLocalShortCircuit(args: ShortCircuitArgs): Promise<Sh
|
|
|
194
194
|
* task's key, which is preliminary, so it cannot restore early either.
|
|
195
195
|
* Cost: one pass over the nodes, no filesystem.
|
|
196
196
|
*/
|
|
197
|
-
function restoreTierExclusions(
|
|
197
|
+
export function restoreTierExclusions(
|
|
198
|
+
nodes: Map<string, TaskNode>,
|
|
199
|
+
workspaceRoot: string,
|
|
200
|
+
): Set<string> {
|
|
198
201
|
const prefixes: string[] = []
|
|
199
202
|
let everything = false
|
|
200
203
|
for (const node of nodes.values()) {
|