@vzn/vx 0.0.631 → 0.0.633
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 +21 -18
- package/docs/caching.md +2247 -0
- package/docs/cli.md +3214 -0
- package/docs/execution.md +708 -0
- package/docs/features.md +172 -0
- package/docs/patterns.md +219 -0
- package/docs/schema.md +2137 -0
- package/docs/security.md +209 -0
- package/package.json +14 -5
- package/schemas/cache.json +24 -0
- package/schemas/docs.json +28 -0
- package/schemas/error.json +23 -0
- package/schemas/info.json +147 -0
- package/schemas/init.json +57 -0
- package/schemas/last.json +188 -0
- package/schemas/lock.json +21 -0
- package/schemas/plan.json +88 -0
- package/schemas/show.json +124 -0
- package/schemas/summary.json +133 -0
- package/schemas/why.json +168 -0
- package/skills/vx/SKILL.md +89 -0
- package/src/bin.ts +9 -0
- package/src/cache/archive.ts +153 -51
- package/src/cache/cache.ts +542 -137
- package/src/cache/chained-cache.ts +9 -0
- package/src/cache/inputs.ts +5 -0
- package/src/cache/layer.ts +10 -5
- package/src/cache/layered-cache.ts +4 -0
- package/src/cache/schema.ts +44 -17
- package/src/cache/tar-stream.ts +204 -66
- package/src/cache/zstd.ts +1 -1
- package/src/cli/cache.ts +14 -14
- package/src/cli/docs-corpus.ts +21 -0
- package/src/cli/docs-text.d.ts +5 -0
- package/src/cli/docs.ts +167 -0
- package/src/cli/foreign-flags.ts +1 -1
- package/src/cli/help.ts +42 -7
- package/src/cli/index.ts +25 -11
- package/src/cli/info.ts +38 -9
- package/src/cli/init.ts +131 -24
- package/src/cli/last.ts +54 -3
- package/src/cli/lock.ts +43 -15
- package/src/cli/plan-format.ts +23 -1
- package/src/cli/plugin-commands.ts +3 -0
- package/src/cli/plugin-templates.ts +1 -1
- package/src/cli/run.ts +40 -21
- package/src/cli/show.ts +10 -7
- package/src/cli/upgrade.ts +19 -7
- package/src/cli/watch.ts +7 -2
- package/src/cli/why.ts +14 -40
- package/src/exec/runner.ts +27 -10
- package/src/graph/scheduler.ts +36 -0
- package/src/graph/task-graph.ts +4 -4
- package/src/index.ts +22 -5
- package/src/orchestrator/admission.ts +29 -1
- package/src/orchestrator/affected-tasks.ts +87 -18
- package/src/orchestrator/doctor.ts +14 -1
- package/src/orchestrator/execute-task.ts +149 -64
- package/src/orchestrator/failure-mode.ts +5 -3
- package/src/orchestrator/framed-output.ts +17 -3
- package/src/orchestrator/index.ts +11 -0
- package/src/orchestrator/logger.ts +57 -6
- package/src/orchestrator/metrics.ts +76 -4
- package/src/orchestrator/miss-reason.ts +3 -5
- package/src/orchestrator/miss-save.ts +6 -5
- package/src/orchestrator/options.ts +12 -0
- package/src/orchestrator/output-log.ts +14 -1
- package/src/orchestrator/path-links.ts +79 -0
- package/src/orchestrator/plan.ts +20 -3
- package/src/orchestrator/plugin.ts +7 -0
- package/src/orchestrator/prepare.ts +14 -4
- package/src/orchestrator/projects.ts +2 -2
- package/src/orchestrator/run-artifacts.ts +18 -2
- package/src/orchestrator/run-failures.ts +192 -0
- package/src/orchestrator/run-records.ts +10 -8
- package/src/orchestrator/run.ts +87 -22
- package/src/orchestrator/task-hash.ts +4 -4
- package/src/orchestrator/terminal-signals.ts +16 -0
- package/src/orchestrator/upstream.ts +65 -10
- package/src/util/errors.ts +55 -1
- package/src/util/index.ts +3 -0
- package/src/util/secret-mask.ts +13 -1
- package/src/util/verbs.ts +1 -0
- package/src/workspace/config-schema.ts +10 -8
- package/src/workspace/migration.ts +30 -3
- package/src/workspace/workspace.ts +1 -0
|
@@ -32,11 +32,21 @@ export interface TaintTracker {
|
|
|
32
32
|
* bytes nothing vouches for. Its order-only deps count too.
|
|
33
33
|
*/
|
|
34
34
|
judge: (node: TaskNode, upstream: TaskOutcome[]) => boolean
|
|
35
|
+
/**
|
|
36
|
+
* For a task `judge` called tainted: the task at the root of it, a
|
|
37
|
+
* failed one or a dependency `--exclude-dependencies` dropped. Its run
|
|
38
|
+
* row names it (`blocked_by`), so no reader counts the row on its key.
|
|
39
|
+
*/
|
|
40
|
+
cause: (node: TaskNode, upstream: TaskOutcome[]) => string
|
|
35
41
|
/** Every settled outcome, the scheduler's `onFinish`. */
|
|
36
42
|
settled: (outcome: TaskOutcome) => void
|
|
37
43
|
}
|
|
38
44
|
|
|
39
|
-
const UNTAINTED: TaintTracker = {
|
|
45
|
+
const UNTAINTED: TaintTracker = {
|
|
46
|
+
judge: () => false,
|
|
47
|
+
cause: (node) => node.id,
|
|
48
|
+
settled: () => {},
|
|
49
|
+
}
|
|
40
50
|
|
|
41
51
|
/**
|
|
42
52
|
* Records which tasks ran behind a failure, or on the key of a dependency
|
|
@@ -61,6 +71,7 @@ export function taintTracker(
|
|
|
61
71
|
if (!continueAlways && seeds.size === 0) return UNTAINTED
|
|
62
72
|
const outcomes = new Map<string, TaskOutcome>()
|
|
63
73
|
const memo = new Map<string, boolean>()
|
|
74
|
+
const causes = new Map<string, string>()
|
|
64
75
|
const failed = (u: TaskOutcome): boolean =>
|
|
65
76
|
continueAlways && (u.status === 'failed' || u.status === 'aborted' || u.status === 'skipped')
|
|
66
77
|
const through = (u: TaskOutcome | undefined): boolean =>
|
|
@@ -113,6 +124,23 @@ export function taintTracker(
|
|
|
113
124
|
seeds.has(node.id) ||
|
|
114
125
|
upstream.some(through) ||
|
|
115
126
|
node.orderOnly?.some((d) => through(outcomes.get(d))) === true,
|
|
127
|
+
cause: (node, upstream) => {
|
|
128
|
+
// A restore-tier task's upstream holds holes (see `judge`).
|
|
129
|
+
const deps = upstream.filter((u) => u !== undefined)
|
|
130
|
+
for (const d of node.orderOnly ?? []) {
|
|
131
|
+
const u = outcomes.get(d)
|
|
132
|
+
if (u !== undefined) deps.push(u)
|
|
133
|
+
}
|
|
134
|
+
const root =
|
|
135
|
+
deps.find(failed) ?? deps.find((u) => taintOf(u.node.id)) ?? node.excludedUpstream?.[0]
|
|
136
|
+
let c: string
|
|
137
|
+
if (root === undefined) c = node.id
|
|
138
|
+
else if (failed(root))
|
|
139
|
+
c = root.status === 'skipped' ? (root.blockedBy ?? root.node.id) : root.node.id
|
|
140
|
+
else c = causes.get(root.node.id) ?? root.node.id
|
|
141
|
+
causes.set(node.id, c)
|
|
142
|
+
return c
|
|
143
|
+
},
|
|
116
144
|
settled: (outcome) => {
|
|
117
145
|
outcomes.set(outcome.node.id, outcome)
|
|
118
146
|
},
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// behind `^build` runs when `ui#build` (or what it depends on) is reached,
|
|
5
5
|
// and a spec edit `ui#build`'s inputs leave out stops at `ui`'s own tasks.
|
|
6
6
|
|
|
7
|
+
import path from 'node:path'
|
|
7
8
|
import type { WorkspaceRules } from '../config.js'
|
|
8
9
|
import { declaresInput, workspaceFilesReachInto } from '../cache/index.js'
|
|
9
10
|
import {
|
|
@@ -54,6 +55,30 @@ function wholeProjects(
|
|
|
54
55
|
return whole
|
|
55
56
|
}
|
|
56
57
|
|
|
58
|
+
/**
|
|
59
|
+
* Why `--affected` kept a requested task (`vx run --affected --dry`):
|
|
60
|
+
* `input`, a changed `file` is one of its declared inputs; `project`, its
|
|
61
|
+
* project changed and the task reads it whole (an uncached task, a path no
|
|
62
|
+
* cached task declares, or no single `file`: a lockfile claim, a manifest
|
|
63
|
+
* edge, a config import); `package`, a `^` edge passes through `project`, a
|
|
64
|
+
* changed package with no such task; `named`, asked as `pkg#task`;
|
|
65
|
+
* `selected`, another filter selected its project outright. `via` is the
|
|
66
|
+
* `dependsOn` chain, nearest first, to the task the change seeded.
|
|
67
|
+
*/
|
|
68
|
+
export interface AffectedReason {
|
|
69
|
+
kind: 'input' | 'project' | 'package' | 'named' | 'selected'
|
|
70
|
+
/** Workspace-relative. */
|
|
71
|
+
file?: string
|
|
72
|
+
project?: string
|
|
73
|
+
via?: string[]
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Collects each kept task's `AffectedReason` when given; a run without one pays nothing. */
|
|
77
|
+
export interface AffectedExplain {
|
|
78
|
+
workspaceRoot: string
|
|
79
|
+
reasons: Map<string, AffectedReason>
|
|
80
|
+
}
|
|
81
|
+
|
|
57
82
|
/**
|
|
58
83
|
* The task ids of `ids` whose closure the change reaches, in their order.
|
|
59
84
|
* A group seeds nothing (it runs nothing) unless it is keyed, as the default
|
|
@@ -69,8 +94,16 @@ export function affectedRoots(
|
|
|
69
94
|
changes: AffectedChanges,
|
|
70
95
|
projects: ReadonlyMap<string, ProjectEntry>,
|
|
71
96
|
packageGraph: PackageGraph,
|
|
97
|
+
explain?: AffectedExplain,
|
|
72
98
|
): string[] {
|
|
73
99
|
const whole = wholeProjects(changes, projects)
|
|
100
|
+
const seeds = explain === undefined ? null : new Map<string, AffectedReason>()
|
|
101
|
+
const through = explain === undefined ? null : new Map<string, string>()
|
|
102
|
+
const inProject = (name: string, rel: string | undefined): AffectedReason => {
|
|
103
|
+
if (rel === undefined) return { kind: 'project' }
|
|
104
|
+
const dir = path.relative(explain!.workspaceRoot, projects.get(name)!.dir)
|
|
105
|
+
return { kind: 'project', file: dir === '' ? rel : path.posix.join(dir, rel) }
|
|
106
|
+
}
|
|
74
107
|
// The builder's `^name` walk (task-graph.ts), keeping only the packages
|
|
75
108
|
// it passes through: a holder's own node is already in `deps`.
|
|
76
109
|
const passesChanged = (n: TaskNode): boolean => {
|
|
@@ -89,7 +122,10 @@ export function affectedRoots(
|
|
|
89
122
|
if (visited.has(target)) continue
|
|
90
123
|
visited.add(target)
|
|
91
124
|
if (holds(target)) continue
|
|
92
|
-
if (changes.projects.has(target))
|
|
125
|
+
if (changes.projects.has(target)) {
|
|
126
|
+
seeds?.set(n.id, { kind: 'package', project: target })
|
|
127
|
+
return true
|
|
128
|
+
}
|
|
93
129
|
frontier.push(...packageGraph.directDeps(target))
|
|
94
130
|
}
|
|
95
131
|
}
|
|
@@ -99,22 +135,35 @@ export function affectedRoots(
|
|
|
99
135
|
const cache = n.config.cache
|
|
100
136
|
// Asked of every node, not only the changed projects' (the
|
|
101
137
|
// `workspaceFiles` owners): a `graph` hook may have given the glob.
|
|
102
|
-
if (
|
|
103
|
-
cache
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
138
|
+
if (cache !== undefined) {
|
|
139
|
+
const file = changes.changed.find((rel) => declaresInput(cache, null, rel))
|
|
140
|
+
const dir =
|
|
141
|
+
file === undefined
|
|
142
|
+
? (changes.nested ?? []).find((d) => workspaceFilesReachInto(cache, d))
|
|
143
|
+
: undefined
|
|
144
|
+
if (file !== undefined || dir !== undefined) {
|
|
145
|
+
seeds?.set(n.id, { kind: 'input', file: (file ?? dir)! })
|
|
146
|
+
return true
|
|
147
|
+
}
|
|
108
148
|
}
|
|
109
149
|
if (!changes.projects.has(n.projectName)) return false
|
|
150
|
+
const rels = changes.paths.get(n.projectName) ?? []
|
|
110
151
|
// A group runs nothing; only the default `build` (projects.ts) is keyed.
|
|
111
152
|
// An uncached task reads its project: a root file another task of it
|
|
112
153
|
// declares (`workspaceFiles`) is no change there.
|
|
113
154
|
if (cache === undefined) {
|
|
114
|
-
|
|
155
|
+
const hit = !isGroupTask(n) && (whole.has(n.projectName) || changes.paths.has(n.projectName))
|
|
156
|
+
if (hit) seeds?.set(n.id, inProject(n.projectName, rels[0]))
|
|
157
|
+
return hit
|
|
158
|
+
}
|
|
159
|
+
if (whole.has(n.projectName)) {
|
|
160
|
+
seeds?.set(n.id, inProject(n.projectName, undefined))
|
|
161
|
+
return true
|
|
115
162
|
}
|
|
116
|
-
|
|
117
|
-
|
|
163
|
+
const rel = rels.find((r) => declaresInput(cache, r, null))
|
|
164
|
+
if (rel === undefined) return false
|
|
165
|
+
if (seeds !== null) seeds.set(n.id, { ...inProject(n.projectName, rel), kind: 'input' })
|
|
166
|
+
return true
|
|
118
167
|
}
|
|
119
168
|
const reached = new Map<string, boolean>()
|
|
120
169
|
// Post-order on an explicit stack: a closure is as deep as the graph, and
|
|
@@ -145,6 +194,7 @@ export function affectedRoots(
|
|
|
145
194
|
frame[1] = i
|
|
146
195
|
if (i === deps.length || reached.get(deps[i]!) === true) {
|
|
147
196
|
reached.set(id, i < deps.length)
|
|
197
|
+
if (i < deps.length) through?.set(id, deps[i]!)
|
|
148
198
|
stack.pop()
|
|
149
199
|
continue
|
|
150
200
|
}
|
|
@@ -152,7 +202,20 @@ export function affectedRoots(
|
|
|
152
202
|
}
|
|
153
203
|
return reached.get(root)!
|
|
154
204
|
}
|
|
155
|
-
|
|
205
|
+
const kept = ids.filter(reaches)
|
|
206
|
+
if (explain !== undefined) {
|
|
207
|
+
for (const root of kept) {
|
|
208
|
+
const via: string[] = []
|
|
209
|
+
let id = root
|
|
210
|
+
for (let next = through!.get(id); next !== undefined; next = through!.get(id)) {
|
|
211
|
+
via.push(next)
|
|
212
|
+
id = next
|
|
213
|
+
}
|
|
214
|
+
const seed = seeds!.get(id)!
|
|
215
|
+
explain.reasons.set(root, via.length > 0 ? { ...seed, via } : seed)
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
return kept
|
|
156
219
|
}
|
|
157
220
|
|
|
158
221
|
/**
|
|
@@ -167,15 +230,21 @@ export function keptByAffected<R extends { project: string; task: string }>(
|
|
|
167
230
|
projects: ReadonlyMap<string, ProjectEntry>,
|
|
168
231
|
packageGraph: PackageGraph,
|
|
169
232
|
keep: { named?: ReadonlySet<string>; outright?: ReadonlySet<string> } = {},
|
|
233
|
+
explain?: AffectedExplain,
|
|
170
234
|
): R[] {
|
|
171
235
|
const ids = requested.map((r) => `${r.project}#${r.task}`)
|
|
172
|
-
const reached = new Set(affectedRoots(nodes, ids, changes, projects, packageGraph))
|
|
173
|
-
return requested.filter(
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
keep.
|
|
177
|
-
|
|
178
|
-
|
|
236
|
+
const reached = new Set(affectedRoots(nodes, ids, changes, projects, packageGraph, explain))
|
|
237
|
+
return requested.filter((r, i) => {
|
|
238
|
+
const id = ids[i]!
|
|
239
|
+
const why: AffectedReason['kind'] | null =
|
|
240
|
+
keep.named?.has(id) === true
|
|
241
|
+
? 'named'
|
|
242
|
+
: keep.outright?.has(r.project) === true
|
|
243
|
+
? 'selected'
|
|
244
|
+
: null
|
|
245
|
+
if (why !== null) explain?.reasons.set(id, { kind: why })
|
|
246
|
+
return why !== null || reached.has(id)
|
|
247
|
+
})
|
|
179
248
|
}
|
|
180
249
|
|
|
181
250
|
/**
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
import os from 'node:os'
|
|
9
9
|
import path from 'node:path'
|
|
10
10
|
import { Cache, CACHE_VERSION, SCHEMA_VERSION } from '../cache/index.js'
|
|
11
|
-
import { PLUGIN_HOOKS } from '../config.js'
|
|
11
|
+
import { PLUGIN_HOOKS, type WorkspaceConfig } from '../config.js'
|
|
12
12
|
import {
|
|
13
13
|
cgroupCpuQuota,
|
|
14
14
|
cgroupMemoryLimitBytes,
|
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
machineMemoryBytes,
|
|
18
18
|
machineParallelism,
|
|
19
19
|
maskedLine,
|
|
20
|
+
parseSize,
|
|
20
21
|
relPosix,
|
|
21
22
|
} from '../util/index.js'
|
|
22
23
|
import { VERSION } from '../version.js'
|
|
@@ -93,6 +94,8 @@ export interface InfoFacts {
|
|
|
93
94
|
schemaVersion: string
|
|
94
95
|
cacheEntries: number
|
|
95
96
|
cacheBytes: number
|
|
97
|
+
/** The workspace's `cacheRetention`, with `maxSize` in bytes; null when none is declared. */
|
|
98
|
+
cacheRetention: { olderThan?: string; maxSize?: string; maxBytes?: number } | null
|
|
96
99
|
orphans: { artifacts: number; bytes: number }
|
|
97
100
|
runs24h: number
|
|
98
101
|
hits24h: number
|
|
@@ -250,6 +253,7 @@ async function collectWorkspaceInfo(
|
|
|
250
253
|
schemaVersion: SCHEMA_VERSION,
|
|
251
254
|
cacheEntries: stats.entryCount,
|
|
252
255
|
cacheBytes: stats.totalBytes,
|
|
256
|
+
cacheRetention: retentionFact(workspaceConfig?.cacheRetention),
|
|
253
257
|
// A row-less artifact is indexed again only when its task asks for its
|
|
254
258
|
// key; the rest only `vx cache prune` reclaims (after an upgrade's
|
|
255
259
|
// schema reset, most often).
|
|
@@ -407,3 +411,12 @@ async function countLoadableTasks(
|
|
|
407
411
|
errors.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0))
|
|
408
412
|
return { tasks, sandboxed, errors }
|
|
409
413
|
}
|
|
414
|
+
|
|
415
|
+
function retentionFact(r: WorkspaceConfig['cacheRetention']): InfoFacts['cacheRetention'] {
|
|
416
|
+
if (r === undefined) return null
|
|
417
|
+
return {
|
|
418
|
+
...(r.olderThan !== undefined ? { olderThan: r.olderThan } : {}),
|
|
419
|
+
// Validated at load, so it parses.
|
|
420
|
+
...(r.maxSize !== undefined ? { maxSize: r.maxSize, maxBytes: parseSize(r.maxSize)! } : {}),
|
|
421
|
+
}
|
|
422
|
+
}
|
|
@@ -41,7 +41,12 @@ import {
|
|
|
41
41
|
maskCaptured,
|
|
42
42
|
BoundedCapture,
|
|
43
43
|
} from '../exec/index.js'
|
|
44
|
-
import {
|
|
44
|
+
import {
|
|
45
|
+
encodeOutputLog,
|
|
46
|
+
FAILED_OUTPUT_HEAD_CHARS,
|
|
47
|
+
FAILED_OUTPUT_TAIL_CHARS,
|
|
48
|
+
failedOutputLog,
|
|
49
|
+
} from './output-log.js'
|
|
45
50
|
import { isGroupTask, RestoreDemoted, type TaskNode, type TaskOutcome } from '../graph/index.js'
|
|
46
51
|
import {
|
|
47
52
|
killGraceMs,
|
|
@@ -260,9 +265,87 @@ export interface ExecuteArgs {
|
|
|
260
265
|
export async function executeTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
261
266
|
if (isGroupTask(args.node)) return executeGroupTask(args)
|
|
262
267
|
if (args.node.config.exec?.persistent !== undefined) return executePersistentTask(args)
|
|
268
|
+
// A probed hit needs none of the run path's set-up: the upstream folding,
|
|
269
|
+
// timers and closures `executeCachedTask` builds on entry were ~15 µs of
|
|
270
|
+
// each of a warm run's hits (X-193). The same branch there handles it
|
|
271
|
+
// when this one does not apply.
|
|
272
|
+
const probed = args.preProbed
|
|
273
|
+
if (probed?.hit != null && readsCache(args) && args.remoteOnlyNoop !== true) {
|
|
274
|
+
return restoreProbed(
|
|
275
|
+
args,
|
|
276
|
+
probed.hash,
|
|
277
|
+
probed.hit,
|
|
278
|
+
process.hrtime.bigint() - args.runStartHrTimeNs,
|
|
279
|
+
)
|
|
280
|
+
}
|
|
263
281
|
return executeCachedTask(args)
|
|
264
282
|
}
|
|
265
283
|
|
|
284
|
+
/** Whether this task reads the cache: a cached, not remote-only task with a read axis on. */
|
|
285
|
+
function readsCache(args: ExecuteArgs): boolean {
|
|
286
|
+
const policy = args.cachePolicy ?? FULL_CACHE_POLICY
|
|
287
|
+
return (
|
|
288
|
+
args.remoteOnly !== true &&
|
|
289
|
+
args.node.config.cache !== undefined &&
|
|
290
|
+
(policy.localRead || policy.remoteRead)
|
|
291
|
+
)
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Restore the up-front probe's hit, or demote it. A key taken before a task
|
|
296
|
+
* rewrote the lockfile names an install the tree no longer holds, and the
|
|
297
|
+
* lazy path never probes one (X-124): demoted like a vanished hit, it runs
|
|
298
|
+
* dep-gated. The hit may be restoring AHEAD of this task's deps, and a
|
|
299
|
+
* command run now would build from outputs they have not written yet and
|
|
300
|
+
* save that under the good key: the scheduler runs it again once they are
|
|
301
|
+
* done (admission drops the probe).
|
|
302
|
+
*/
|
|
303
|
+
async function restoreProbed(
|
|
304
|
+
args: ExecuteArgs,
|
|
305
|
+
hash: string,
|
|
306
|
+
hit: CacheEntry,
|
|
307
|
+
taskStartNs: bigint,
|
|
308
|
+
): Promise<TaskOutcome> {
|
|
309
|
+
const cacheOpStart = performance.now()
|
|
310
|
+
const restored = fingerprintMoved(args)
|
|
311
|
+
? null
|
|
312
|
+
: await restoreOrMiss(args, hash, hit, cacheOpStart, taskStartNs)
|
|
313
|
+
if (restored !== null) return restored
|
|
314
|
+
throw new RestoreDemoted(args.node.id)
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* A hit restored, or null when its artifact was removed after the probe (a
|
|
319
|
+
* prune, another workspace's retention on a shared cache directory): a
|
|
320
|
+
* miss. The restore has wiped the declared outputs by then; the run path
|
|
321
|
+
* cleans them again and the task writes them.
|
|
322
|
+
*/
|
|
323
|
+
async function restoreOrMiss(
|
|
324
|
+
args: ExecuteArgs,
|
|
325
|
+
hash: string,
|
|
326
|
+
hit: CacheEntry,
|
|
327
|
+
cacheOpStart: number,
|
|
328
|
+
taskStartNs: bigint,
|
|
329
|
+
): Promise<TaskOutcome | null> {
|
|
330
|
+
try {
|
|
331
|
+
return await restoreHit({ args, hash, hit, cacheOpStart, taskStartNs })
|
|
332
|
+
} catch (err) {
|
|
333
|
+
if (!(err instanceof ArtifactVanishedError)) throw err
|
|
334
|
+
args.log.status(`[vx] ${args.node.id}: ${err.message} — running it`)
|
|
335
|
+
return null
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* The workspace fingerprint every key here folded has moved (a task
|
|
341
|
+
* rewrote the lockfile), said once per run.
|
|
342
|
+
*/
|
|
343
|
+
function fingerprintMoved(args: ExecuteArgs): boolean {
|
|
344
|
+
if (args.fingerprintWatch?.moved() === undefined) return false
|
|
345
|
+
args.fingerprintWatch.say(args.log)
|
|
346
|
+
return true
|
|
347
|
+
}
|
|
348
|
+
|
|
266
349
|
/**
|
|
267
350
|
* Group task: no `exec`. The scheduler has already ensured every
|
|
268
351
|
* dependency completed; we just return success with a hash rolled up
|
|
@@ -270,35 +353,65 @@ export async function executeTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
270
353
|
* through us.
|
|
271
354
|
*/
|
|
272
355
|
async function executeGroupTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
356
|
+
if (args.node.config.cache === undefined) {
|
|
357
|
+
return unkeyedGroupOutcome(
|
|
358
|
+
args.node,
|
|
359
|
+
args.upstream,
|
|
360
|
+
args.upfrontGroupKey,
|
|
361
|
+
args.runStartHrTimeNs,
|
|
362
|
+
)
|
|
363
|
+
}
|
|
273
364
|
const wallclockNs = process.hrtime.bigint() - args.runStartHrTimeNs
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
365
|
+
const hash = await computeTaskHash({
|
|
366
|
+
node: args.node,
|
|
367
|
+
upstream: args.upstream,
|
|
368
|
+
workspaceRoot: args.workspaceRoot,
|
|
369
|
+
workspaceFingerprint: args.workspaceFingerprint,
|
|
370
|
+
cache: args.cache,
|
|
371
|
+
forwardArgs: args.forwardArgs,
|
|
372
|
+
nestedProjectDirs: args.nestedProjectDirs,
|
|
373
|
+
...(args.gitFilesCache !== undefined ? { gitFilesCache: args.gitFilesCache } : {}),
|
|
374
|
+
...(args.hashCache !== undefined ? { hashCache: args.hashCache } : {}),
|
|
375
|
+
})
|
|
376
|
+
return groupOutcome(args.node, args.upstream, hash, wallclockNs)
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* An unkeyed group's outcome (no `cache`, no key of its own): its key is
|
|
381
|
+
* its upstream's alone (`computeGroupKey`), so the up-front one stands when
|
|
382
|
+
* that upstream is unchanged. Synchronous: the scheduler settles such a
|
|
383
|
+
* group in place (`ScheduleOptions.settleNow`), and `upstream` is the key's
|
|
384
|
+
* (`keyUpstream`).
|
|
385
|
+
*/
|
|
386
|
+
export function unkeyedGroupOutcome(
|
|
387
|
+
node: TaskNode,
|
|
388
|
+
upstream: TaskOutcome[],
|
|
389
|
+
upfront: UpfrontGroupKey | undefined,
|
|
390
|
+
runStartHrTimeNs: bigint,
|
|
391
|
+
): TaskOutcome {
|
|
392
|
+
const wallclockNs = process.hrtime.bigint() - runStartHrTimeNs
|
|
277
393
|
const hash =
|
|
278
|
-
|
|
279
|
-
? upfront
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
...(args.gitFilesCache !== undefined ? { gitFilesCache: args.gitFilesCache } : {}),
|
|
291
|
-
...(args.hashCache !== undefined ? { hashCache: args.hashCache } : {}),
|
|
292
|
-
})
|
|
394
|
+
upfront !== undefined && sameUpstream(upstream, upfront.upstream)
|
|
395
|
+
? upfront.key
|
|
396
|
+
: computeGroupHash(upstream)
|
|
397
|
+
return groupOutcome(node, upstream, hash, wallclockNs)
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function groupOutcome(
|
|
401
|
+
node: TaskNode,
|
|
402
|
+
upstream: TaskOutcome[],
|
|
403
|
+
hash: string,
|
|
404
|
+
wallclockNs: bigint,
|
|
405
|
+
): TaskOutcome {
|
|
293
406
|
return {
|
|
294
|
-
node
|
|
407
|
+
node,
|
|
295
408
|
status: 'success',
|
|
296
409
|
exitCode: 0,
|
|
297
410
|
durationMs: 0,
|
|
298
411
|
hash,
|
|
299
412
|
// What this group stands for. A dependent expands it to describe the
|
|
300
413
|
// real tasks in its input closure — see `TaskOutcome.groupUpstream`.
|
|
301
|
-
groupUpstream:
|
|
414
|
+
groupUpstream: upstream,
|
|
302
415
|
wallclockStartNs: wallclockNs,
|
|
303
416
|
wallclockEndNs: wallclockNs,
|
|
304
417
|
}
|
|
@@ -823,40 +936,16 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
823
936
|
// the probe already ran — restore its hit, or (null hit) fall straight
|
|
824
937
|
// to the run path as a known stable miss. No second cache.get.
|
|
825
938
|
if (willRead) {
|
|
826
|
-
const cacheOpStart = performance.now()
|
|
827
|
-
// A hit whose artifact was removed after the probe (a prune, another
|
|
828
|
-
// workspace's retention on a shared cache directory) is a miss. The
|
|
829
|
-
// restore has wiped the declared outputs by then; the run path cleans
|
|
830
|
-
// them again and the task writes them.
|
|
831
|
-
const restoreOrMiss = async (hit: CacheEntry): Promise<TaskOutcome | null> => {
|
|
832
|
-
try {
|
|
833
|
-
return await restoreHit({ args, hash, hit, cacheOpStart, taskStartNs })
|
|
834
|
-
} catch (err) {
|
|
835
|
-
if (!(err instanceof ArtifactVanishedError)) throw err
|
|
836
|
-
log.status(`[vx] ${node.id}: ${err.message} — running it`)
|
|
837
|
-
return null
|
|
838
|
-
}
|
|
839
|
-
}
|
|
840
939
|
if (preProbed !== undefined) {
|
|
841
|
-
if (preProbed.hit !== null)
|
|
842
|
-
// A key taken before a task rewrote the lockfile names an install
|
|
843
|
-
// the tree no longer holds, and the lazy path below never probes
|
|
844
|
-
// one (X-124): demoted like a vanished hit, it runs dep-gated.
|
|
845
|
-
const restored = fingerprintMoved() ? null : await restoreOrMiss(preProbed.hit)
|
|
846
|
-
if (restored !== null) return restored
|
|
847
|
-
// The up-front probe's hit may be restoring AHEAD of this task's
|
|
848
|
-
// deps, and a command run now would build from outputs they have
|
|
849
|
-
// not written yet and save that under the good key. The scheduler
|
|
850
|
-
// runs it again once they are done (admission drops the probe).
|
|
851
|
-
throw new RestoreDemoted(node.id)
|
|
852
|
-
}
|
|
940
|
+
if (preProbed.hit !== null) return restoreProbed(args, hash, preProbed.hit, taskStartNs)
|
|
853
941
|
// Confirmed stable miss — skip the probe, fall through to run.
|
|
854
|
-
} else if (!fingerprintMoved()) {
|
|
942
|
+
} else if (!fingerprintMoved(args)) {
|
|
943
|
+
const cacheOpStart = performance.now()
|
|
855
944
|
const endProbe = span('cache.get')
|
|
856
945
|
const hit = await cache.get(hash, getContext(node, step.command))
|
|
857
946
|
endProbe()
|
|
858
947
|
if (hit) {
|
|
859
|
-
const restored = await restoreOrMiss(hit)
|
|
948
|
+
const restored = await restoreOrMiss(args, hash, hit, cacheOpStart, taskStartNs)
|
|
860
949
|
if (restored !== null) return restored
|
|
861
950
|
}
|
|
862
951
|
}
|
|
@@ -1198,7 +1287,8 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
1198
1287
|
// and what the cache keeps of it (L-11); null when there are none.
|
|
1199
1288
|
const secrets = secretMask([process.env, env, step.env?.define], step.env?.secret)
|
|
1200
1289
|
let flushMasked = (): void => {}
|
|
1201
|
-
// The attempt's output as the logger got it (masked)
|
|
1290
|
+
// The attempt's output as the logger got it (masked): the entry's when
|
|
1291
|
+
// the task saves, else only what a failure reports (`failedOutput`).
|
|
1202
1292
|
let output: BoundedCapture | undefined
|
|
1203
1293
|
const storedOutput = (): string => encodeOutputLog(output?.chunks() ?? [])
|
|
1204
1294
|
const failedAttempts: { endedAt: number; exitCode: number; timedOut?: true }[] = []
|
|
@@ -1290,14 +1380,16 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
1290
1380
|
}
|
|
1291
1381
|
|
|
1292
1382
|
async function buildRequest(local: boolean): Promise<ExecuteRequest> {
|
|
1293
|
-
const kept = willSave
|
|
1383
|
+
const kept = willSave
|
|
1384
|
+
? new BoundedCapture()
|
|
1385
|
+
: new BoundedCapture(FAILED_OUTPUT_HEAD_CHARS, FAILED_OUTPUT_TAIL_CHARS)
|
|
1294
1386
|
output = kept
|
|
1295
1387
|
const toOut = (t: string): void => {
|
|
1296
|
-
kept
|
|
1388
|
+
kept.push(t)
|
|
1297
1389
|
log.taskStdout(node, t)
|
|
1298
1390
|
}
|
|
1299
1391
|
const toErr = (t: string): void => {
|
|
1300
|
-
kept
|
|
1392
|
+
kept.push(t, true)
|
|
1301
1393
|
log.taskStderr(node, t)
|
|
1302
1394
|
}
|
|
1303
1395
|
const out = secrets && maskedEmitter(secrets, toOut)
|
|
@@ -1436,16 +1528,6 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
1436
1528
|
}
|
|
1437
1529
|
}
|
|
1438
1530
|
|
|
1439
|
-
/**
|
|
1440
|
-
* The workspace fingerprint every key here folded has moved (a task
|
|
1441
|
-
* rewrote the lockfile), said once per run.
|
|
1442
|
-
*/
|
|
1443
|
-
function fingerprintMoved(): boolean {
|
|
1444
|
-
if (args.fingerprintWatch?.moved() === undefined) return false
|
|
1445
|
-
args.fingerprintWatch.say(log)
|
|
1446
|
-
return true
|
|
1447
|
-
}
|
|
1448
|
-
|
|
1449
1531
|
/**
|
|
1450
1532
|
* What an additive run saves as its own, or undefined when it removed a
|
|
1451
1533
|
* file it found: no entry replays a removal (`ownOutputsSince`), so the
|
|
@@ -1491,7 +1573,7 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
1491
1573
|
* withholds it too: the key folded the old one.
|
|
1492
1574
|
*/
|
|
1493
1575
|
async function keyStillTrue(): Promise<boolean> {
|
|
1494
|
-
if (fingerprintMoved()) {
|
|
1576
|
+
if (fingerprintMoved(args)) {
|
|
1495
1577
|
unkeyed = true
|
|
1496
1578
|
return false
|
|
1497
1579
|
}
|
|
@@ -1540,6 +1622,9 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
1540
1622
|
: {}),
|
|
1541
1623
|
...(inputChanges !== undefined ? { inputChanges } : {}),
|
|
1542
1624
|
...(unkeyed ? { unkeyed: true as const } : {}),
|
|
1625
|
+
...(effectiveExitCode !== 0 && output !== undefined
|
|
1626
|
+
? { failedOutput: failedOutputLog(output, willSave) }
|
|
1627
|
+
: {}),
|
|
1543
1628
|
...(result.timedOut === true && effectiveExitCode !== 0 ? { timedOut: true as const } : {}),
|
|
1544
1629
|
...(result.cpuMs !== undefined ? { cpuMs: result.cpuMs } : {}),
|
|
1545
1630
|
...(result.peakRssBytes !== undefined ? { peakRssBytes: result.peakRssBytes } : {}),
|
|
@@ -27,7 +27,9 @@ export type FailureMode = 'stable' | 'flaky-recoverable' | 'flaky-fatal'
|
|
|
27
27
|
* and how often it failed AFTER its first pass (`relapses`). A task with
|
|
28
28
|
* no cache block keys on its config alone and runs every time, so its key
|
|
29
29
|
* says nothing about its inputs: no reader counts it, as no run's row
|
|
30
|
-
* calls it flaky.
|
|
30
|
+
* calls it flaky. Nor is a row that names a `blocked_by`: it ran behind a
|
|
31
|
+
* failure (`--continue=always`), on bytes its key never named, so its
|
|
32
|
+
* pass or failure is not the key's. Every flakiness reader is a filter over this one
|
|
31
33
|
* projection.
|
|
32
34
|
*/
|
|
33
35
|
function keyOutcomesSql(source: string, where = ''): string {
|
|
@@ -42,7 +44,7 @@ function keyOutcomesSql(source: string, where = ''): string {
|
|
|
42
44
|
CASE WHEN status = 'success' OR status IN ${HIT_STATUSES} OR cache_hit = 1
|
|
43
45
|
THEN 1 ELSE 0 END AS passed
|
|
44
46
|
FROM ${source}
|
|
45
|
-
WHERE ${KEYED_RUNS_SQL} AND cached IS NOT 0${where}))
|
|
47
|
+
WHERE ${KEYED_RUNS_SQL} AND cached IS NOT 0 AND blocked_by IS NULL${where}))
|
|
46
48
|
GROUP BY project, task, hash`
|
|
47
49
|
}
|
|
48
50
|
|
|
@@ -148,7 +150,7 @@ export function detectFlaky(db: Database, candidates: readonly FlakyCandidate[])
|
|
|
148
150
|
const rows = db
|
|
149
151
|
.query(
|
|
150
152
|
`SELECT DISTINCT project, task, hash FROM runs
|
|
151
|
-
WHERE status = 'failed' AND hash IN (${chunk.map(() => '?').join(', ')})`,
|
|
153
|
+
WHERE status = 'failed' AND blocked_by IS NULL AND hash IN (${chunk.map(() => '?').join(', ')})`,
|
|
152
154
|
)
|
|
153
155
|
.all(...chunk.map((c) => c.hash)) as { project: string; task: string; hash: string }[]
|
|
154
156
|
for (const r of rows) everFailed.add(keyOf(r))
|
|
@@ -31,6 +31,7 @@ import { formatElapsed, maskedCommand } from '../util/index.js'
|
|
|
31
31
|
import { paint, type ColorSupport } from './colors.js'
|
|
32
32
|
import { neverStarted } from './summary.js'
|
|
33
33
|
import { outcomeLabel, ranNoCache, skippedReason } from './events.js'
|
|
34
|
+
import { fileMemo, linkPaths } from './path-links.js'
|
|
34
35
|
|
|
35
36
|
const NO_COLOR: ColorSupport = { enabled: false }
|
|
36
37
|
|
|
@@ -88,6 +89,8 @@ export function formatTaskBlock(
|
|
|
88
89
|
// ran or was cached — you asked for it, you see what it would run.
|
|
89
90
|
forceCommand = false,
|
|
90
91
|
forwardArgs: readonly string[] = [],
|
|
92
|
+
// The terminal opens OSC 8 links: a failed task's paths become links.
|
|
93
|
+
links = false,
|
|
91
94
|
): string {
|
|
92
95
|
// Group tasks (no `exec`) do no work and have no body — they're
|
|
93
96
|
// organizational nodes the user wrote so a `vx run ci` invocation
|
|
@@ -113,7 +116,16 @@ export function formatTaskBlock(
|
|
|
113
116
|
lines.push('', corner(`$ ${shownCommand(node, forwardArgs)}`), '')
|
|
114
117
|
}
|
|
115
118
|
|
|
116
|
-
|
|
119
|
+
const isFile = links && outcome.status === 'failed' ? fileMemo() : undefined
|
|
120
|
+
const link =
|
|
121
|
+
isFile === undefined ? undefined : (t: string) => linkPaths(t, node.projectDir, isFile)
|
|
122
|
+
pushOutputSection(
|
|
123
|
+
lines,
|
|
124
|
+
output,
|
|
125
|
+
(body.droppedStdout ?? 0) + (body.droppedStderr ?? 0),
|
|
126
|
+
colors,
|
|
127
|
+
link,
|
|
128
|
+
)
|
|
117
129
|
|
|
118
130
|
lines.push(...violationSection(outcome, colors))
|
|
119
131
|
|
|
@@ -156,6 +168,7 @@ function pushOutputSection(
|
|
|
156
168
|
output: readonly OutputChunk[],
|
|
157
169
|
lost: number,
|
|
158
170
|
colors: ColorSupport,
|
|
171
|
+
link?: (line: string) => string,
|
|
159
172
|
): void {
|
|
160
173
|
if (output.every((c) => c.text.trim().length === 0)) return
|
|
161
174
|
lines.push(sectionLine('OUTPUT', '', colors), '')
|
|
@@ -179,8 +192,9 @@ function pushOutputSection(
|
|
|
179
192
|
}
|
|
180
193
|
if (open !== null) body.push({ text: open.parts.join(''), err: open.err })
|
|
181
194
|
for (const l of body) {
|
|
182
|
-
|
|
183
|
-
|
|
195
|
+
const text = link === undefined ? l.text : link(l.text)
|
|
196
|
+
if (!l.err || !colors.enabled) lines.push(text)
|
|
197
|
+
else lines.push(l.text.includes('\x1b') ? text : paint(ERROR, text, colors))
|
|
184
198
|
}
|
|
185
199
|
lines.push('')
|
|
186
200
|
}
|