@vzn/vx 0.0.650 → 0.0.652

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 (146) hide show
  1. package/README.md +7 -23
  2. package/package.json +5 -5
  3. package/schemas/last.json +15 -0
  4. package/schemas/show.json +14 -20
  5. package/src/bin.ts +21 -31
  6. package/src/cache/archive.ts +1 -1
  7. package/src/cache/cache.ts +221 -30
  8. package/src/cache/file-hashes.ts +3 -0
  9. package/src/cache/git-inputs.ts +188 -39
  10. package/src/cache/history-stream.ts +134 -0
  11. package/src/cache/history-writer.ts +76 -0
  12. package/src/cache/index.ts +3 -0
  13. package/src/cache/inputs.ts +77 -14
  14. package/src/cache/layer.ts +17 -6
  15. package/src/cache/layered-cache.ts +4 -0
  16. package/src/cache/linked-inputs.ts +3 -0
  17. package/src/cache/lockfile-memo.ts +5 -3
  18. package/src/cache/output-index.ts +5 -2
  19. package/src/cache/remote-lane.ts +2 -1
  20. package/src/cache/run-history.ts +56 -17
  21. package/src/cache/schema.ts +15 -2
  22. package/src/cli/cache.ts +28 -18
  23. package/src/cli/completions.ts +4 -2
  24. package/src/cli/docs.ts +10 -11
  25. package/src/cli/help.ts +14 -25
  26. package/src/cli/index.ts +28 -9
  27. package/src/cli/info.ts +16 -8
  28. package/src/cli/init.ts +23 -15
  29. package/src/cli/last.ts +246 -204
  30. package/src/cli/lock.ts +55 -5
  31. package/src/cli/plan-format.ts +100 -118
  32. package/src/cli/plugin-commands.ts +8 -2
  33. package/src/cli/plugin-templates.ts +36 -45
  34. package/src/cli/run.ts +46 -27
  35. package/src/cli/select.ts +43 -5
  36. package/src/cli/show.ts +45 -52
  37. package/src/cli/task-verb.ts +22 -0
  38. package/src/cli/upgrade.ts +3 -1
  39. package/src/cli/watch-fs.ts +6 -1
  40. package/src/cli/watch-judge.ts +3 -2
  41. package/src/cli/watch-set.ts +4 -4
  42. package/src/cli/watch.ts +94 -52
  43. package/src/cli/why.ts +123 -89
  44. package/src/cli/workspace-config.ts +2 -1
  45. package/src/config.ts +47 -30
  46. package/src/exec/executor.ts +42 -2
  47. package/src/exec/index.ts +1 -0
  48. package/src/exec/kill-tree.ts +58 -5
  49. package/src/exec/local-executor.ts +1 -1
  50. package/src/exec/pipes.ts +152 -0
  51. package/src/exec/runner.ts +199 -182
  52. package/src/exec/sandbox-binds.ts +1 -1
  53. package/src/exec/sandbox-paths.ts +3 -1
  54. package/src/exec/sandbox-runtime.ts +317 -23
  55. package/src/exec/spawn-off.ts +154 -0
  56. package/src/exec/task-proxy.ts +93 -0
  57. package/src/exec/warm-shell.ts +146 -0
  58. package/src/graph/critical-path.ts +40 -28
  59. package/src/graph/definition.ts +16 -3
  60. package/src/graph/priorities.ts +98 -44
  61. package/src/graph/scheduler.ts +17 -11
  62. package/src/graph/task-graph.ts +51 -24
  63. package/src/handoff.ts +10 -7
  64. package/src/history/failure-mode.ts +24 -1
  65. package/src/history/history.ts +5 -0
  66. package/src/history/index.ts +1 -0
  67. package/src/history/metrics.ts +14 -6
  68. package/src/index.ts +7 -3
  69. package/src/orchestrator/admission.ts +1 -1
  70. package/src/orchestrator/command-windows.ts +1 -1
  71. package/src/orchestrator/deferred-outputs.ts +1 -1
  72. package/src/orchestrator/doctor.ts +1 -1
  73. package/src/orchestrator/download-policy.ts +5 -7
  74. package/src/orchestrator/excluded-keys.ts +1 -1
  75. package/src/orchestrator/execute-task.ts +67 -20
  76. package/src/orchestrator/graph-memo.ts +50 -1
  77. package/src/orchestrator/graph-stage.ts +6 -0
  78. package/src/orchestrator/hit-restore.ts +5 -6
  79. package/src/orchestrator/index.ts +3 -0
  80. package/src/orchestrator/keyed-projects.ts +28 -0
  81. package/src/orchestrator/live-usage.ts +16 -11
  82. package/src/orchestrator/local-shortcircuit.ts +2 -2
  83. package/src/orchestrator/locked-digests.ts +63 -0
  84. package/src/orchestrator/lockfile-claim.ts +108 -29
  85. package/src/orchestrator/miss-save.ts +58 -11
  86. package/src/orchestrator/persistent-task.ts +5 -1
  87. package/src/orchestrator/placement.ts +1 -1
  88. package/src/orchestrator/plan.ts +7 -3
  89. package/src/orchestrator/plugin-host.ts +67 -2
  90. package/src/orchestrator/plugin.ts +53 -2
  91. package/src/orchestrator/prepare.ts +16 -1
  92. package/src/orchestrator/projects.ts +4 -1
  93. package/src/orchestrator/remote-prefetch.ts +1 -1
  94. package/src/orchestrator/run-context.ts +29 -10
  95. package/src/orchestrator/run-failures.ts +62 -13
  96. package/src/orchestrator/run-keys.ts +1 -1
  97. package/src/orchestrator/run-records.ts +88 -59
  98. package/src/orchestrator/run-report.ts +1 -1
  99. package/src/orchestrator/run.ts +145 -41
  100. package/src/orchestrator/sandbox-request.ts +52 -9
  101. package/src/orchestrator/shell-verdict.ts +9 -0
  102. package/src/orchestrator/signals.ts +8 -5
  103. package/src/orchestrator/stable-keys.ts +17 -18
  104. package/src/orchestrator/task-artifacts.ts +1 -1
  105. package/src/orchestrator/task-hash.ts +21 -7
  106. package/src/orchestrator/telemetry.ts +5 -1
  107. package/src/report/events.ts +6 -14
  108. package/src/report/forecast.ts +10 -32
  109. package/src/report/framed-output.ts +283 -175
  110. package/src/report/index.ts +8 -3
  111. package/src/report/logger.ts +74 -39
  112. package/src/report/plain-output.ts +3 -1
  113. package/src/report/status-line.ts +49 -357
  114. package/src/report/summary.ts +105 -136
  115. package/src/settings.ts +8 -8
  116. package/src/ui/ansi.ts +48 -0
  117. package/src/ui/components.ts +506 -0
  118. package/src/ui/index.ts +79 -0
  119. package/src/ui/kit.ts +37 -0
  120. package/src/ui/live-region.ts +363 -0
  121. package/src/{util/refusal-notice.ts → ui/refusal.ts} +7 -5
  122. package/src/ui/terminal.ts +51 -0
  123. package/src/ui/theme.ts +224 -0
  124. package/src/ui/version-line.ts +28 -0
  125. package/src/util/config-frame.ts +191 -14
  126. package/src/util/edit-distance.ts +6 -1
  127. package/src/util/index.ts +2 -15
  128. package/src/util/paths.ts +21 -3
  129. package/src/util/secret-mask.ts +39 -3
  130. package/src/util/settle.ts +33 -1
  131. package/src/util/tail.ts +4 -1
  132. package/src/util/timing.ts +4 -16
  133. package/src/workspace/affected.ts +89 -30
  134. package/src/workspace/bracket-literal.ts +2 -2
  135. package/src/workspace/config-schema.ts +70 -63
  136. package/src/workspace/dependencies-lock.ts +191 -0
  137. package/src/workspace/filter.ts +36 -1
  138. package/src/workspace/index.ts +9 -0
  139. package/src/workspace/lockfile.ts +159 -6
  140. package/src/workspace/migration.ts +16 -9
  141. package/src/workspace/workspace.ts +3 -0
  142. package/src/util/colors.ts +0 -133
  143. package/src/util/report.ts +0 -44
  144. package/src/util/ui.ts +0 -258
  145. /package/src/{report/path-links.ts → ui/links.ts} +0 -0
  146. /package/src/{report → ui}/terminal-signals.ts +0 -0
@@ -0,0 +1,63 @@
1
+ // A run keys a claimed file from what `vx lock` recorded of it in
2
+ // `.vx/dependencies.lock.json`, as it takes configs from the config lock:
3
+ // the digests are read, never derived, so no run parses a lockfile for
4
+ // its key. The record holds only while the file's bytes hash to what was
5
+ // recorded: one that moved (an install) refuses the run before any key is
6
+ // folded, naming the file and `vx lock`. A present file with no record,
7
+ // or no lock at all, refuses when a claimant asks for it: an absent record
8
+ // is never read as "no dependencies". The other files the digests read
9
+ // (`extras`: a `link:` directory, a patch) are handed to the claimant with
10
+ // their recorded hashes: a linked directory is edited with no install, so
11
+ // one that moved is the claimant's to digest anew, not a refusal.
12
+
13
+ import {
14
+ DEPENDENCIES_LOCK_NAME,
15
+ type DependenciesLock,
16
+ lockKey,
17
+ parseDependenciesLock,
18
+ recordedDigests,
19
+ } from '../workspace/index.js'
20
+ import { UserError, xxh3hex } from '../util/index.js'
21
+ import type { KeyHookContext, LockedFile } from './plugin.js'
22
+
23
+ const refused = (message: string): UserError => new UserError(message, 'VX_E_LOCK')
24
+
25
+ /**
26
+ * `KeyHookContext.locked` for one run, over the claimed files' bytes as
27
+ * the run read them (`readClaimedFiles`; absent ones are missing) and the
28
+ * lock's bytes (null when there is none). Each recorded claimed file is
29
+ * checked first: one whose bytes moved, or that is gone, throws here.
30
+ */
31
+ export function lockedFiles(
32
+ workspaceRoot: string,
33
+ claimed: readonly string[],
34
+ bytes: ReadonlyMap<string, Uint8Array>,
35
+ lockBytes: Uint8Array | null,
36
+ ): KeyHookContext['locked'] {
37
+ const lock: DependenciesLock | null = parseDependenciesLock(lockBytes)
38
+ const records = new Map<string, LockedFile>()
39
+ for (const file of claimed) {
40
+ const rec = lock === null ? undefined : recordedDigests(lock, file)
41
+ if (rec === undefined) continue
42
+ const now = bytes.get(file)
43
+ if (now === undefined || xxh3hex(now) !== rec.hash) {
44
+ throw refused(`${file} changed since \`vx lock\`: run \`vx lock\``)
45
+ }
46
+ records.set(file, {
47
+ extras: rec.extras,
48
+ digest(projectDir) {
49
+ const dir = lockKey(workspaceRoot, projectDir)
50
+ if (!rec.has(dir))
51
+ throw refused(`${dir} is not in ${DEPENDENCIES_LOCK_NAME}: run \`vx lock\``)
52
+ return rec.of(dir)
53
+ },
54
+ })
55
+ }
56
+ return async (file) => {
57
+ const rec = records.get(file)
58
+ if (rec !== undefined || !bytes.has(file)) return rec
59
+ if (lockBytes === null) throw refused(`no ${DEPENDENCIES_LOCK_NAME}: run \`vx lock\``)
60
+ if (lock === null) throw refused(`${DEPENDENCIES_LOCK_NAME} is invalid, run \`vx lock\``)
61
+ throw refused(`${file} is not in ${DEPENDENCIES_LOCK_NAME}: run \`vx lock\``)
62
+ }
63
+ }
@@ -7,11 +7,18 @@
7
7
  // fallback for a project the file does not list and the `--affected`
8
8
  // diff are one implementation here, so a third lockfile is a parser.
9
9
  //
10
- // The digests are memoised in cache.db (`lockfile_memos`, one row per
11
- // lockfile and claimant) by the file's xxh3, so a warm run pays one read +
12
- // hash + one row read, never a parse; within one process (a run, a watch cycle) the file's size,
13
- // mtime, ctime and inode gate even the read; and per run the read happens once (a WeakMap
14
- // on the context core hands every `key` call of a run).
10
+ // A run's key reads each project's digest from `.vx/dependencies.lock.json`
11
+ // (`KeyHookContext.locked`), never the file: only `vx lock` and
12
+ // `--affected` digest it, and a run whose extra file (a `link:` directory,
13
+ // edited with no install) moved since the lock, as the record is stale
14
+ // for it then. What the `discover` and `project` stages read
15
+ // (the edges, the recorded names) is memoised in cache.db
16
+ // (`lockfile_memos`, one row per lockfile and claimant) by the file's xxh3,
17
+ // so a warm run pays one read + hash + one row read, never a parse; within
18
+ // one process (a run, a watch cycle) the file's size, mtime, ctime and
19
+ // inode gate even the read. A run's read leaves the digests out (most of a
20
+ // parse: 9-25 ms on this repo's bun.lock, against 4-13 for the text), and
21
+ // `vx lock` adds them to the memo.
15
22
 
16
23
  import path from 'node:path'
17
24
  import { stat } from 'node:fs/promises'
@@ -104,21 +111,27 @@ export interface LockfileClaimHooks {
104
111
  }
105
112
 
106
113
  interface Digests {
107
- /** xxh3 of the lockfile bytes; '' when the file is absent. */
114
+ /** xxh3 of the lockfile bytes and the extra files' hashes; '' when the file is absent. */
108
115
  readonly lock: string
109
- readonly importers: ReadonlyMap<string, string>
116
+ /** xxh3 of the lockfile bytes alone. */
117
+ readonly bytes: string
118
+ /** The extra files the digests read. */
119
+ readonly extras: readonly string[]
120
+ /** Each importer's digest; undefined when the read that made this left them out. */
121
+ readonly importers: ReadonlyMap<string, string> | undefined
110
122
  readonly edges: ReadonlyMap<string, LockfileImporter>
111
123
  readonly recorded: ReadonlyMap<string, ReadonlySet<string>> | undefined
112
124
  }
113
125
 
114
126
  /**
115
- * A root-relative extra file's content hash, '' when absent; a path leaving
116
- * the root is never read. One there but unreadable (EACCES, a symlink loop)
117
- * refuses, as a settings file does: folded as '', it keyed as no file at all.
127
+ * A root-relative extra file's content hash, '' when absent. A path above
128
+ * the root is read too: a `file:` or `link:` directory beside the checkout
129
+ * is what a task imports, and left unread an edit in it moved no key. One
130
+ * there but unreadable (EACCES, a symlink loop) refuses, as a settings file
131
+ * does: folded as '', it keyed as no file at all.
118
132
  */
119
- async function hashExtra(workspaceRoot: string, rel: string): Promise<string> {
120
- if (path.isAbsolute(rel) || rel.split(/[\\/]/).includes('..')) return 'outside-root'
121
- const abs = path.join(workspaceRoot, rel)
133
+ export async function hashExtra(workspaceRoot: string, rel: string): Promise<string> {
134
+ const abs = path.resolve(workspaceRoot, rel)
122
135
  try {
123
136
  return xxh3hex(await Bun.file(abs).bytes())
124
137
  } catch (err) {
@@ -168,7 +181,7 @@ function identityOf(st: { size: number; mtimeMs: number; ctimeMs: number; ino: n
168
181
  async function statsOf(workspaceRoot: string, rels: readonly string[]): Promise<string[]> {
169
182
  return Promise.all(
170
183
  rels.map((rel) =>
171
- stat(path.join(workspaceRoot, rel)).then(
184
+ stat(path.resolve(workspaceRoot, rel)).then(
172
185
  (st) => (st.isDirectory() ? hashExtra(workspaceRoot, rel) : identityOf(st)),
173
186
  () => 'gone',
174
187
  ),
@@ -234,8 +247,17 @@ export function lockfileClaim(options: LockfileClaimOptions): LockfileClaimHooks
234
247
  }
235
248
  // Per run: every `key` call of one run gets the same context object.
236
249
  const perRun = new WeakMap<object, Promise<Digests>>()
250
+ const lockedPerRun = new WeakMap<
251
+ object,
252
+ Promise<((dir: string) => string | undefined) | undefined>
253
+ >()
237
254
 
238
- const load = async (workspaceRoot: string, cacheDir: string): Promise<Digests> => {
255
+ /** `withDigests`: each importer's digest too, which only `vx lock` and `--affected` read. */
256
+ const load = async (
257
+ workspaceRoot: string,
258
+ cacheDir: string,
259
+ withDigests: boolean,
260
+ ): Promise<Digests> => {
239
261
  const full = path.join(workspaceRoot, file)
240
262
  let identity: string
241
263
  try {
@@ -243,6 +265,8 @@ export function lockfileClaim(options: LockfileClaimOptions): LockfileClaimHooks
243
265
  } catch {
244
266
  const digests: Digests = {
245
267
  lock: '',
268
+ bytes: '',
269
+ extras: [],
246
270
  importers: new Map(),
247
271
  edges: new Map(),
248
272
  recorded: undefined,
@@ -251,7 +275,11 @@ export function lockfileClaim(options: LockfileClaimOptions): LockfileClaimHooks
251
275
  return digests
252
276
  }
253
277
  const last = seen.get(workspaceRoot)
254
- if (last !== undefined && last.identity === identity) {
278
+ if (
279
+ last !== undefined &&
280
+ last.identity === identity &&
281
+ (!withDigests || last.digests.importers !== undefined)
282
+ ) {
255
283
  const now = await statsOf(workspaceRoot, last.extras)
256
284
  if (now.every((s, i) => s === last.extraStats[last.extras[i]!])) {
257
285
  if (last.unsaved !== undefined && writeLockfileMemo(cacheDir, file, claimant, last.unsaved))
@@ -270,17 +298,19 @@ export function lockfileClaim(options: LockfileClaimOptions): LockfileClaimHooks
270
298
  let edges: ReadonlyMap<string, LockfileImporter> = new Map()
271
299
  let recorded: ReadonlyMap<string, readonly string[]> | undefined
272
300
  let unsaved: LockfileMemo | undefined
273
- if (memo !== undefined) {
301
+ let served = false
302
+ if (memo !== undefined && (!withDigests || memo.importers !== null)) {
274
303
  const now = await hashExtras(workspaceRoot, Object.keys(memo.extras))
275
304
  if (Object.entries(memo.extras).every(([rel, h]) => now.hashes[rel] === h)) {
305
+ served = true
276
306
  extras = memo.extras
277
307
  extraStats = now.stats
278
- importers = new Map(Object.entries(memo.importers))
308
+ importers = memo.importers === null ? undefined : new Map(Object.entries(memo.importers))
279
309
  edges = new Map(Object.entries(memo.edges as Record<string, LockfileImporter>))
280
310
  recorded = memo.recorded === undefined ? undefined : new Map(Object.entries(memo.recorded))
281
311
  }
282
312
  }
283
- if (importers === undefined) {
313
+ if (!served) {
284
314
  const text = decode(bytes)
285
315
  const rels = [...new Set(options.extraFiles?.(text) ?? [])].sort()
286
316
  const now = await hashExtras(workspaceRoot, rels)
@@ -289,7 +319,9 @@ export function lockfileClaim(options: LockfileClaimOptions): LockfileClaimHooks
289
319
  importers =
290
320
  scope === 'workspace'
291
321
  ? new Map<string, string>()
292
- : digest(text, new Map(Object.entries(extras)))
322
+ : withDigests
323
+ ? digest(text, new Map(Object.entries(extras)))
324
+ : undefined
293
325
  edges = options.importers?.(text) ?? new Map()
294
326
  recorded = options.recorded?.(text)
295
327
  if (memoises) {
@@ -297,7 +329,7 @@ export function lockfileClaim(options: LockfileClaimOptions): LockfileClaimHooks
297
329
  version,
298
330
  lock: lockHash,
299
331
  extras,
300
- importers: Object.fromEntries(importers),
332
+ importers: importers === undefined ? null : Object.fromEntries(importers),
301
333
  edges: Object.fromEntries(edges),
302
334
  ...(recorded !== undefined ? { recorded: Object.fromEntries(recorded) } : {}),
303
335
  }
@@ -311,6 +343,8 @@ export function lockfileClaim(options: LockfileClaimOptions): LockfileClaimHooks
311
343
  : xxh3hex([lockHash, ...rels.map((r) => `${r}\0${extras[r]}`)].join('\0'))
312
344
  const digests: Digests = {
313
345
  lock,
346
+ bytes: lockHash,
347
+ extras: rels,
314
348
  importers,
315
349
  edges,
316
350
  recorded:
@@ -327,10 +361,36 @@ export function lockfileClaim(options: LockfileClaimOptions): LockfileClaimHooks
327
361
  })
328
362
  return digests
329
363
  }
364
+ /**
365
+ * Each project's digest for this run: the lock's, unless an extra file
366
+ * it records moved since (then a digest of the file as it is now, as
367
+ * `vx lock` would record it); undefined when the file is absent.
368
+ */
369
+ const lockedOnce = (
370
+ ctx: KeyHookContext,
371
+ ): Promise<((dir: string) => string | undefined) | undefined> => {
372
+ let p = lockedPerRun.get(ctx)
373
+ if (p === undefined) {
374
+ p = (async () => {
375
+ const rec = await ctx.locked(file)
376
+ if (rec === undefined) return undefined
377
+ const rels = Object.keys(rec.extras)
378
+ const now = await Promise.all(rels.map((rel) => hashExtra(ctx.workspaceRoot, rel)))
379
+ if (now.every((h, i) => h === rec.extras[rels[i]!])) {
380
+ return (dir: string) => rec.digest(dir)
381
+ }
382
+ const digests = await load(ctx.workspaceRoot, ctx.cacheDir, true)
383
+ const importers = digests.importers!
384
+ return (dir: string) => digestFor(importers, importerOf(ctx.workspaceRoot, dir))
385
+ })()
386
+ lockedPerRun.set(ctx, p)
387
+ }
388
+ return p
389
+ }
330
390
  const loadOnce = (ctx: KeyHookContext): Promise<Digests> => {
331
391
  let p = perRun.get(ctx)
332
392
  if (p === undefined) {
333
- p = load(ctx.workspaceRoot, ctx.cacheDir)
393
+ p = load(ctx.workspaceRoot, ctx.cacheDir, false)
334
394
  perRun.set(ctx, p)
335
395
  }
336
396
  return p
@@ -378,6 +438,24 @@ export function lockfileClaim(options: LockfileClaimOptions): LockfileClaimHooks
378
438
  }
379
439
  return names
380
440
  },
441
+ // What `vx lock` commits, so `--affected` reads the base's digests
442
+ // instead of parsing the base's file.
443
+ async digests(_file: string, ctx: FingerprintContext) {
444
+ if (scope === 'workspace') return undefined
445
+ const digests = await load(ctx.workspaceRoot, ctx.cacheDir, true)
446
+ if (digests.lock === '') return undefined
447
+ const importers = digests.importers!
448
+ return {
449
+ hash: digests.bytes,
450
+ projects: Object.fromEntries(
451
+ ctx.projects.map((p) => [
452
+ p.name,
453
+ digestFor(importers, importerOf(ctx.workspaceRoot, p.dir)),
454
+ ]),
455
+ ),
456
+ ...(digests.extras.length > 0 ? { extras: digests.extras } : {}),
457
+ }
458
+ },
381
459
  ...(options.extraFiles !== undefined
382
460
  ? {
383
461
  // The memo records which files these exact bytes name, for this
@@ -393,19 +471,20 @@ export function lockfileClaim(options: LockfileClaimOptions): LockfileClaimHooks
393
471
  : {}),
394
472
  },
395
473
  async key(task, ctx) {
396
- const digests = await loadOnce(ctx)
397
- if (digests.lock === '') return undefined
398
- if (scope === 'workspace') return { [part]: digests.lock }
399
- return {
400
- [part]: digestFor(digests.importers, importerOf(ctx.workspaceRoot, task.projectDir)),
474
+ if (scope === 'project') {
475
+ const digestOf = await lockedOnce(ctx)
476
+ const digest = digestOf?.(task.projectDir)
477
+ return digest === undefined ? undefined : { [part]: digest }
401
478
  }
479
+ const digests = await loadOnce(ctx)
480
+ return digests.lock === '' ? undefined : { [part]: digests.lock }
402
481
  },
403
482
  async importers(ctx) {
404
- const digests = await load(ctx.workspaceRoot, ctx.cacheDir)
483
+ const digests = await load(ctx.workspaceRoot, ctx.cacheDir, false)
405
484
  return digests.lock === '' ? undefined : digests.edges
406
485
  },
407
486
  async recorded(ctx) {
408
- const digests = await load(ctx.workspaceRoot, ctx.cacheDir)
487
+ const digests = await load(ctx.workspaceRoot, ctx.cacheDir, false)
409
488
  return digests.lock === '' ? undefined : digests.recorded
410
489
  },
411
490
  }
@@ -109,7 +109,7 @@ export interface SaveMissArgs {
109
109
  workspaceRoot: string
110
110
  nestedProjectDirs: string[]
111
111
  gitFilesCache?: GitFilesCache | undefined
112
- /** Declared `cache.outputs.files` / `.workspaceFiles`. */
112
+ /** Declared `outputs.files` / `.workspaceFiles`. */
113
113
  outputs: readonly string[]
114
114
  wsOutputs: readonly string[]
115
115
  /**
@@ -119,7 +119,7 @@ export interface SaveMissArgs {
119
119
  * would also take the upstream's files the task adds beside.
120
120
  */
121
121
  ownOutputFiles?: string[] | undefined
122
- /** `ownOutputFiles` for `cache.outputs.workspaceFiles` (A-43). */
122
+ /** `ownOutputFiles` for `outputs.workspaceFiles` (A-43). */
123
123
  ownWsOutputFiles?: string[] | undefined
124
124
  /** The Tier-3 input fingerprint rows captured by the pre-exec describe. */
125
125
  captured: readonly TaskInputComponent[]
@@ -146,6 +146,8 @@ export interface SaveMissArgs {
146
146
  export interface SaveFacts {
147
147
  saveMs?: number
148
148
  artifactBytes?: number
149
+ /** The saved files' bytes, as a hit's `outputBytes` counts them: the row's `outputs`. */
150
+ outputBytes?: number
149
151
  /** The entry landed: the outcome's `saved`, set only once it has. */
150
152
  saved?: true
151
153
  }
@@ -160,19 +162,38 @@ export async function saveMiss(a: SaveMissArgs): Promise<{ landed: Promise<SaveF
160
162
  // An additive task owns only the files it wrote (item 588), no directory.
161
163
  const tree =
162
164
  a.ownOutputFiles !== undefined
163
- ? { files: a.ownOutputFiles, emptyDirs: [] }
165
+ ? { files: a.ownOutputFiles, emptyDirs: [], special: [] }
164
166
  : await resolveOutputTree({
165
167
  projectDir: node.projectDir,
166
168
  outputs: a.outputs,
167
169
  nestedProjectDirs: a.nestedProjectDirs,
170
+ special: true,
168
171
  })
169
172
  const outputFiles = tree.files
170
173
  const wsTree =
171
174
  a.ownWsOutputFiles !== undefined
172
- ? { files: a.ownWsOutputFiles, emptyDirs: [] }
173
- : await resolveWorkspaceOutputTree({ workspaceRoot: a.workspaceRoot, outputs: a.wsOutputs })
175
+ ? { files: a.ownWsOutputFiles, emptyDirs: [], special: [] }
176
+ : await resolveWorkspaceOutputTree({
177
+ workspaceRoot: a.workspaceRoot,
178
+ outputs: a.wsOutputs,
179
+ special: true,
180
+ })
174
181
  const wsOutputFiles = wsTree.files
175
182
  endResolve()
183
+ // A FIFO, socket or device holds no bytes to store (opening a FIFO waits
184
+ // for a writer, nx#35228), so it stays out of the entry; said, because a
185
+ // hit restores the tree without it.
186
+ const special = [
187
+ ...tree.special.map((p) => relPosix(node.projectDir, p)),
188
+ ...wsTree.special.map((p) => relPosix(a.workspaceRoot, p)),
189
+ ]
190
+ if (special.length > 0) {
191
+ warnOf(log)(
192
+ `[vx] ${node.id}: ${special.join(', ')} ${special.length === 1 ? 'is' : 'are'} not a regular file or symlink ` +
193
+ `(a FIFO, socket or device): vx does not cache ${special.length === 1 ? 'it' : 'them'}, so a hit restores the outputs without ${special.length === 1 ? 'it' : 'them'} ` +
194
+ `— emit a file there, or take ${special.length === 1 ? 'it' : 'them'} back with a '!' entry`,
195
+ )
196
+ }
176
197
  // The mirror of the input warning: declared outputs that resolve to
177
198
  // nothing save an empty artifact, and the next hit "restores" it —
178
199
  // the build that ran nowhere looks like a build that ran. `files: []`
@@ -189,14 +210,14 @@ export async function saveMiss(a: SaveMissArgs): Promise<{ landed: Promise<SaveF
189
210
  // (project dir === workspace root) the write SUCCEEDS into that
190
211
  // scratch and the task exits 0, so this warning is the only thing that
191
212
  // says the build produced nothing (item 444). A declared
192
- // `cache.outputs` is not a write grant; `exec.sandbox.allow.write` is.
213
+ // `outputs` is not a write grant; `exec.sandbox.allow.write` is.
193
214
  const sandboxed = node.config.exec?.sandbox !== undefined
194
215
  const grantsWrite = (node.config.exec?.sandbox?.allow?.write?.length ?? 0) > 0
195
216
  // A `workspaceFiles` directory linked out of the workspace drops every
196
217
  // file (M-65); a project one is refused by the resolver (X-88).
197
218
  const linkedOut = outputDirLinkedOut(a.workspaceRoot, a.wsOutputs)
198
219
  warnOf(log)(
199
- `[vx] ${node.id}: cache.outputs matched no files (${[...a.outputs, ...a.wsOutputs].join(', ')}) — ` +
220
+ `[vx] ${node.id}: outputs matched no files (${[...a.outputs, ...a.wsOutputs].join(', ')}) — ` +
200
221
  `an empty artifact is saved; a later hit restores nothing` +
201
222
  (linkedOut !== undefined
202
223
  ? ` — ${linkedOut.dir} is a symlink to ${linkedOut.target}, outside ${linkedOut.base}, and vx keeps only outputs inside it: make ${linkedOut.dir} a directory`
@@ -239,6 +260,8 @@ export async function saveMiss(a: SaveMissArgs): Promise<{ landed: Promise<SaveF
239
260
  },
240
261
  })
241
262
  endSave()
263
+ const bytes = cache.outputBytes?.(a.hash)
264
+ if (bytes !== undefined) facts.outputBytes = bytes
242
265
  if (a.measure === true) {
243
266
  facts.saveMs = Math.round(performance.now() - saveStart)
244
267
  // No local artifact (local writes off): no size to say.
@@ -294,9 +317,26 @@ export type UnsavedArgs = Pick<
294
317
  * A miss that ran here and saves nothing (it failed, the policy writes
295
318
  * nothing, an upstream failed, its key no longer held) still wrote its
296
319
  * outputs, and a same-project reader after it must see them as it sees a
297
- * save's: resolved and marked the same way.
320
+ * save's: resolved and marked the same way. Returns their bytes, the
321
+ * row's `outputs`: blank, an unsaved miss read as one that wrote nothing.
298
322
  */
299
- export async function markUnsaved(a: UnsavedArgs): Promise<void> {
323
+ export async function markUnsaved(a: UnsavedArgs): Promise<number> {
324
+ const { outputFiles, wsOutputFiles } = await resolveUnsaved(a)
325
+ markWritten(a, outputFiles, wsOutputFiles)
326
+ return sizeOf(a.node.projectDir, outputFiles) + sizeOf(a.workspaceRoot, wsOutputFiles)
327
+ }
328
+
329
+ /** An unsaved miss nothing reads after: only the bytes of its outputs, its row's `outputs`. */
330
+ export async function unsavedOutputBytes(
331
+ a: Pick<UnsavedArgs, 'node' | 'workspaceRoot' | 'nestedProjectDirs' | 'outputs' | 'wsOutputs'>,
332
+ ): Promise<number> {
333
+ const { outputFiles, wsOutputFiles } = await resolveUnsaved(a)
334
+ return sizeOf(a.node.projectDir, outputFiles) + sizeOf(a.workspaceRoot, wsOutputFiles)
335
+ }
336
+
337
+ async function resolveUnsaved(
338
+ a: Pick<UnsavedArgs, 'node' | 'workspaceRoot' | 'nestedProjectDirs' | 'outputs' | 'wsOutputs'>,
339
+ ): Promise<{ outputFiles: string[]; wsOutputFiles: string[] }> {
300
340
  const endResolve = span('miss: resolve outputs')
301
341
  const outputFiles = await resolveOutputs({
302
342
  projectDir: a.node.projectDir,
@@ -308,7 +348,14 @@ export async function markUnsaved(a: UnsavedArgs): Promise<void> {
308
348
  outputs: a.wsOutputs,
309
349
  })
310
350
  endResolve()
311
- markWritten(a, outputFiles, wsOutputFiles)
351
+ return { outputFiles, wsOutputFiles }
352
+ }
353
+
354
+ /** The bytes of `files` (absolute, or under `base`); one gone since it was listed counts none. */
355
+ function sizeOf(base: string, files: readonly string[]): number {
356
+ let n = 0
357
+ for (const f of files) n += statSync(path.resolve(base, f), { throwIfNoEntry: false })?.size ?? 0
358
+ return n
312
359
  }
313
360
 
314
361
  /**
@@ -319,7 +366,7 @@ export async function markUnsaved(a: UnsavedArgs): Promise<void> {
319
366
  * these paths. On a 1000-package cold run this removes ~one synchronous
320
367
  * `git ls-files` spawn per project (the single largest cold-run cost —
321
368
  * 22% of CPU in profiling). Contract: outputs must be declared — an
322
- * executed task that writes files outside `cache.outputs.files` which a
369
+ * executed task that writes files outside `outputs.files` which a
323
370
  * same-project downstream task reads is undeclared behavior (the restore
324
371
  * path already assumes it).
325
372
  */
@@ -19,6 +19,7 @@ import { warnOf } from '../report/index.js'
19
19
  import type { ExecuteArgs } from './execute-task.js'
20
20
  import {
21
21
  commandWriteReach,
22
+ dependencyReads,
22
23
  mayWriteFingerprint,
23
24
  type Placeholder,
24
25
  placeholderSweeper,
@@ -135,6 +136,7 @@ export async function executePersistentTask(args: ExecuteArgs): Promise<TaskOutc
135
136
  undefined,
136
137
  args.nestedProjectDirs,
137
138
  args.cacheDir,
139
+ await dependencyReads(args.readableDeps?.(node) ?? [], args.workspaceRoot),
138
140
  )
139
141
  placeholders = sb.placeholders
140
142
  serverSandbox = sb.sandbox
@@ -283,7 +285,9 @@ export async function executePersistentTask(args: ExecuteArgs): Promise<TaskOutc
283
285
  const message = err instanceof Error ? err.message : String(err)
284
286
  // The task's OWN stream, not the process's: the frame is where a
285
287
  // reader looks for why a task failed, and a run with a custom logger
286
- // (an embedder) never saw a bare stderr write at all.
288
+ // (an embedder) never saw a bare stderr write at all. What waited on
289
+ // an open line goes first.
290
+ ends.end()
287
291
  log.taskStderr(
288
292
  node,
289
293
  ends.own(`[vx] ${node.id}: persistent task failed to become ready: ${message}\n`),
@@ -337,7 +337,7 @@ export function placeTasks(
337
337
  projectDir: node.projectDir,
338
338
  command: node.config.exec!.command,
339
339
  pinnedLocal: pinned.has(node.id),
340
- cacheable: node.config.cache !== undefined,
340
+ cacheable: node.config.cache === true,
341
341
  },
342
342
  executorLabel,
343
343
  (e) => pinsOf(e).has(node.id) || !routes(e),
@@ -25,7 +25,7 @@ export type CacheStatus =
25
25
  | 'hit-local' // entry exists in local cache
26
26
  | 'hit-remote' // entry exists in the remote layer (would be fetched)
27
27
  | 'miss' // caching enabled but no entry — would execute
28
- | 'no-cache' // task opts out of caching (no `cache` block) or --no-cache
28
+ | 'no-cache' // task opts out of caching (no `cache: true`) or --no-cache
29
29
  | 'group' // no `exec`; just an aggregator
30
30
  | 'not-started' // a server every dependant of which restores early: the run never spawns it
31
31
  | 'skipped' // `exec.platforms` leave this machine out, or a task it depends on is skipped
@@ -186,7 +186,7 @@ export async function plan(args: PlanArgs): Promise<RunPlan> {
186
186
  // so nothing is probed. Whether that run is a miss or no cache at all
187
187
  // is the WRITE axes': `--force` and `--cache-policy=local:w` save what they
188
188
  // run, and the plan called them `no-cache`, the label a task with no
189
- // `cache` block gets (item 991). The probe below routes through the
189
+ // `cache: true` gets (item 991). The probe below routes through the
190
190
  // policy-aware cache layer, which itself respects read gating.
191
191
  const policy = args.cachePolicy ?? FULL_CACHE_POLICY
192
192
  const reads = policy.localRead || policy.remoteRead
@@ -225,7 +225,11 @@ export async function plan(args: PlanArgs): Promise<RunPlan> {
225
225
  )
226
226
  const dependents = new Map<string, string[]>()
227
227
  for (const node of args.nodes.values())
228
- for (const dep of node.deps) dependents.set(dep, [...(dependents.get(dep) ?? []), node.id])
228
+ for (const dep of node.deps) {
229
+ const ds = dependents.get(dep)
230
+ if (ds === undefined) dependents.set(dep, [node.id])
231
+ else ds.push(node.id)
232
+ }
229
233
  for (const id of idleServers(args.nodes, dependents, restoreTier))
230
234
  cacheStatusById.set(id, 'not-started')
231
235
  }
@@ -33,6 +33,7 @@ import type {
33
33
  DiscoverContext,
34
34
  ExecutorContext,
35
35
  FingerprintChange,
36
+ FingerprintDigests,
36
37
  FingerprintContext,
37
38
  GraphHookContext,
38
39
  KeyHookContext,
@@ -44,6 +45,7 @@ import type {
44
45
  ConfigContext,
45
46
  } from './plugin.js'
46
47
  import type { ProjectMeta } from '../workspace/index.js'
48
+ import { hashExtra } from './lockfile-claim.js'
47
49
 
48
50
  /** `a string`, `an array`, `null`, `a number` — for a refusal that names what came back. */
49
51
  function describeValue(v: unknown): string {
@@ -392,6 +394,65 @@ export async function claimedAffected(
392
394
  ctx: FingerprintContext,
393
395
  ): Promise<Set<string> | undefined> {
394
396
  const answer = await safe(plugin, 'fingerprint', () => plugin.fingerprint!.affected(change, ctx))
397
+ return projectNames(plugin, answer, ctx)
398
+ }
399
+
400
+ /**
401
+ * `FingerprintClaim.digests`' answer for `file`, each extra file it names
402
+ * with its hash now (`hashExtra`, as a run checks it); `undefined` when the
403
+ * plugin has none. A digest of a name no project carries, or a value that
404
+ * is not a string, is refused by name: `vx lock` would commit it.
405
+ */
406
+ export async function claimedDigests(
407
+ plugin: VxPlugin,
408
+ file: string,
409
+ ctx: FingerprintContext,
410
+ ): Promise<
411
+ | {
412
+ hash: string
413
+ projects: FingerprintDigests['projects']
414
+ extras?: Readonly<Record<string, string>>
415
+ }
416
+ | undefined
417
+ > {
418
+ const digests = plugin.fingerprint?.digests
419
+ if (digests === undefined) return undefined
420
+ const answer: unknown = await safe(plugin, 'fingerprint', () =>
421
+ digests.call(plugin.fingerprint, file, ctx),
422
+ )
423
+ if (answer === undefined) return undefined
424
+ const { hash, projects, extras } = (answer ?? {}) as Partial<FingerprintDigests>
425
+ if (
426
+ typeof hash !== 'string' ||
427
+ typeof projects !== 'object' ||
428
+ projects === null ||
429
+ Object.values(projects).some((d) => typeof d !== 'string') ||
430
+ (extras !== undefined &&
431
+ (!Array.isArray(extras) || extras.some((rel) => typeof rel !== 'string')))
432
+ ) {
433
+ throw new UserError(
434
+ `plugin '${plugin.name}' failed in fingerprint: digests returned ${describeValue(answer)}, not { hash, projects: { name: digest }, extras?: [path] }`,
435
+ )
436
+ }
437
+ const known = new Set(ctx.projects.map((p) => p.name))
438
+ const stray = Object.keys(projects).find((n) => !known.has(n))
439
+ if (stray !== undefined) {
440
+ throw new UserError(
441
+ `plugin '${plugin.name}' failed in fingerprint: digest of ${JSON.stringify(stray)}, which is not a project in this workspace`,
442
+ )
443
+ }
444
+ if (extras === undefined || extras.length === 0) return { hash, projects }
445
+ const hashed = await Promise.all(
446
+ extras.map(async (rel) => [rel, await hashExtra(ctx.workspaceRoot, rel)] as const),
447
+ )
448
+ return { hash, projects, extras: Object.fromEntries(hashed) }
449
+ }
450
+
451
+ async function projectNames(
452
+ plugin: VxPlugin,
453
+ answer: unknown,
454
+ ctx: FingerprintContext,
455
+ ): Promise<Set<string> | undefined> {
395
456
  if (answer === undefined) return undefined
396
457
  // A plain object is no list: `for…of` threw "is not iterable", naming
397
458
  // neither the plugin nor the hook.
@@ -460,6 +521,8 @@ export async function claimedExtraFiles(
460
521
  export interface ScheduleRun {
461
522
  readonly step?: <T>(id: string, work: () => Promise<T>) => Promise<T>
462
523
  readonly refined?: (merged: ReadonlyMap<string, number>) => void
524
+ /** Where `ctx.flaky` names go; none in a plan. */
525
+ readonly flaky?: (taskIds: Iterable<string>) => void
463
526
  }
464
527
 
465
528
  /**
@@ -472,7 +535,7 @@ export interface ScheduleRun {
472
535
  export async function applyScheduleHooks(
473
536
  plugins: readonly VxPlugin[],
474
537
  nodes: ReadonlyMap<string, TaskNode>,
475
- ctx: Omit<ScheduleHookContext, 'cache' | 'refine' | 'onRefine' | 'refining'>,
538
+ ctx: Omit<ScheduleHookContext, 'cache' | 'refine' | 'onRefine' | 'refining' | 'flaky'>,
476
539
  cacheFor: (plugin: string) => PluginCache = () => absentPluginCache,
477
540
  run: ScheduleRun = {},
478
541
  ): Promise<ReadonlyMap<string, number>> {
@@ -525,7 +588,8 @@ export async function applyScheduleHooks(
525
588
  const onRefine = (listener: () => void): void => {
526
589
  if (run.refined !== undefined) listeners.push({ plugin, i, listener })
527
590
  }
528
- const own = { ...ctx, cache: cacheFor(plugin.name), refine, onRefine, refining }
591
+ const flaky = (taskIds: Iterable<string>): void => run.flaky?.(taskIds)
592
+ const own = { ...ctx, cache: cacheFor(plugin.name), refine, onRefine, refining, flaky }
529
593
  const call = (): Promise<ReadonlyMap<string, number> | undefined> =>
530
594
  safe(plugin, 'schedule', () => plugin.schedule!(nodes, own))
531
595
  const weights = await (run.step === undefined
@@ -827,6 +891,7 @@ const EXECUTOR_HOOKS = [
827
891
  'footer',
828
892
  'machines',
829
893
  'idealMs',
894
+ 'advice',
830
895
  'profile',
831
896
  'running',
832
897
  ] as const