@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.
Files changed (86) hide show
  1. package/README.md +21 -18
  2. package/docs/caching.md +2247 -0
  3. package/docs/cli.md +3214 -0
  4. package/docs/execution.md +708 -0
  5. package/docs/features.md +172 -0
  6. package/docs/patterns.md +219 -0
  7. package/docs/schema.md +2137 -0
  8. package/docs/security.md +209 -0
  9. package/package.json +14 -5
  10. package/schemas/cache.json +24 -0
  11. package/schemas/docs.json +28 -0
  12. package/schemas/error.json +23 -0
  13. package/schemas/info.json +147 -0
  14. package/schemas/init.json +57 -0
  15. package/schemas/last.json +188 -0
  16. package/schemas/lock.json +21 -0
  17. package/schemas/plan.json +88 -0
  18. package/schemas/show.json +124 -0
  19. package/schemas/summary.json +133 -0
  20. package/schemas/why.json +168 -0
  21. package/skills/vx/SKILL.md +89 -0
  22. package/src/bin.ts +9 -0
  23. package/src/cache/archive.ts +153 -51
  24. package/src/cache/cache.ts +542 -137
  25. package/src/cache/chained-cache.ts +9 -0
  26. package/src/cache/inputs.ts +5 -0
  27. package/src/cache/layer.ts +10 -5
  28. package/src/cache/layered-cache.ts +4 -0
  29. package/src/cache/schema.ts +44 -17
  30. package/src/cache/tar-stream.ts +204 -66
  31. package/src/cache/zstd.ts +1 -1
  32. package/src/cli/cache.ts +14 -14
  33. package/src/cli/docs-corpus.ts +21 -0
  34. package/src/cli/docs-text.d.ts +5 -0
  35. package/src/cli/docs.ts +167 -0
  36. package/src/cli/foreign-flags.ts +1 -1
  37. package/src/cli/help.ts +42 -7
  38. package/src/cli/index.ts +25 -11
  39. package/src/cli/info.ts +38 -9
  40. package/src/cli/init.ts +131 -24
  41. package/src/cli/last.ts +54 -3
  42. package/src/cli/lock.ts +43 -15
  43. package/src/cli/plan-format.ts +23 -1
  44. package/src/cli/plugin-commands.ts +3 -0
  45. package/src/cli/plugin-templates.ts +1 -1
  46. package/src/cli/run.ts +40 -21
  47. package/src/cli/show.ts +10 -7
  48. package/src/cli/upgrade.ts +19 -7
  49. package/src/cli/watch.ts +7 -2
  50. package/src/cli/why.ts +14 -40
  51. package/src/exec/runner.ts +27 -10
  52. package/src/graph/scheduler.ts +36 -0
  53. package/src/graph/task-graph.ts +4 -4
  54. package/src/index.ts +22 -5
  55. package/src/orchestrator/admission.ts +29 -1
  56. package/src/orchestrator/affected-tasks.ts +87 -18
  57. package/src/orchestrator/doctor.ts +14 -1
  58. package/src/orchestrator/execute-task.ts +149 -64
  59. package/src/orchestrator/failure-mode.ts +5 -3
  60. package/src/orchestrator/framed-output.ts +17 -3
  61. package/src/orchestrator/index.ts +11 -0
  62. package/src/orchestrator/logger.ts +57 -6
  63. package/src/orchestrator/metrics.ts +76 -4
  64. package/src/orchestrator/miss-reason.ts +3 -5
  65. package/src/orchestrator/miss-save.ts +6 -5
  66. package/src/orchestrator/options.ts +12 -0
  67. package/src/orchestrator/output-log.ts +14 -1
  68. package/src/orchestrator/path-links.ts +79 -0
  69. package/src/orchestrator/plan.ts +20 -3
  70. package/src/orchestrator/plugin.ts +7 -0
  71. package/src/orchestrator/prepare.ts +14 -4
  72. package/src/orchestrator/projects.ts +2 -2
  73. package/src/orchestrator/run-artifacts.ts +18 -2
  74. package/src/orchestrator/run-failures.ts +192 -0
  75. package/src/orchestrator/run-records.ts +10 -8
  76. package/src/orchestrator/run.ts +87 -22
  77. package/src/orchestrator/task-hash.ts +4 -4
  78. package/src/orchestrator/terminal-signals.ts +16 -0
  79. package/src/orchestrator/upstream.ts +65 -10
  80. package/src/util/errors.ts +55 -1
  81. package/src/util/index.ts +3 -0
  82. package/src/util/secret-mask.ts +13 -1
  83. package/src/util/verbs.ts +1 -0
  84. package/src/workspace/config-schema.ts +10 -8
  85. package/src/workspace/migration.ts +30 -3
  86. 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 = { judge: () => false, settled: () => {} }
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)) return true
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 !== undefined &&
104
- (changes.changed.some((rel) => declaresInput(cache, null, rel)) ||
105
- (changes.nested ?? []).some((dir) => workspaceFilesReachInto(cache, dir)))
106
- ) {
107
- return true
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
- return !isGroupTask(n) && (whole.has(n.projectName) || changes.paths.has(n.projectName))
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
- if (whole.has(n.projectName)) return true
117
- return (changes.paths.get(n.projectName) ?? []).some((rel) => declaresInput(cache, rel, null))
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
- return ids.filter(reaches)
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
- (r, i) =>
175
- keep.named?.has(ids[i]!) === true ||
176
- keep.outright?.has(r.project) === true ||
177
- reached.has(ids[i]!),
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 { encodeOutputLog } from './output-log.js'
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
- // `computeGroupKey`: an unkeyed group's key is its upstream's alone, so
275
- // the up-front one stands when that upstream is unchanged.
276
- const upfront = args.upfrontGroupKey
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
- args.node.config.cache === undefined
279
- ? upfront !== undefined && sameUpstream(args.upstream, upfront.upstream)
280
- ? upfront.key
281
- : computeGroupHash(args.upstream)
282
- : await computeTaskHash({
283
- node: args.node,
284
- upstream: args.upstream,
285
- workspaceRoot: args.workspaceRoot,
286
- workspaceFingerprint: args.workspaceFingerprint,
287
- cache: args.cache,
288
- forwardArgs: args.forwardArgs,
289
- nestedProjectDirs: args.nestedProjectDirs,
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: args.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: args.upstream,
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), for the entry.
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 ? new BoundedCapture() : undefined
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?.push(t)
1388
+ kept.push(t)
1297
1389
  log.taskStdout(node, t)
1298
1390
  }
1299
1391
  const toErr = (t: string): void => {
1300
- kept?.push(t, true)
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. Every flakiness reader is a filter over this one
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
- pushOutputSection(lines, output, (body.droppedStdout ?? 0) + (body.droppedStderr ?? 0), colors)
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
- if (!l.err || !colors.enabled) lines.push(l.text)
183
- else lines.push(l.text.includes('\x1b') ? l.text : paint(ERROR, l.text, colors))
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
  }