@vzn/vx 0.0.636 → 0.0.638

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 (151) hide show
  1. package/README.md +39 -11
  2. package/docs/caching.md +246 -213
  3. package/docs/cli.md +791 -705
  4. package/docs/execution.md +68 -76
  5. package/docs/features.md +47 -42
  6. package/docs/patterns.md +26 -25
  7. package/docs/schema.md +392 -205
  8. package/docs/security.md +76 -39
  9. package/launcher.sh +43 -0
  10. package/package.json +5 -5
  11. package/schemas/cache.json +41 -19
  12. package/schemas/info.json +23 -11
  13. package/schemas/last.json +48 -2
  14. package/schemas/lock.json +1 -1
  15. package/schemas/plan.json +4 -4
  16. package/schemas/show.json +5 -3
  17. package/schemas/why.json +15 -1
  18. package/skills/vx/SKILL.md +28 -13
  19. package/src/bin.ts +30 -9
  20. package/src/cache/archive.ts +11 -0
  21. package/src/cache/cache.ts +124 -53
  22. package/src/cache/chained-cache.ts +4 -0
  23. package/src/cache/git-inputs.ts +277 -63
  24. package/src/cache/index.ts +15 -1
  25. package/src/cache/inputs.ts +102 -36
  26. package/src/cache/key-fold.ts +8 -7
  27. package/src/cache/layer.ts +15 -18
  28. package/src/cache/layered-cache.ts +20 -102
  29. package/src/cache/plugin-cache.ts +248 -0
  30. package/src/cache/policy.ts +32 -27
  31. package/src/cache/run-history.ts +104 -14
  32. package/src/cache/run-store.ts +77 -0
  33. package/src/cache/schema.ts +58 -31
  34. package/src/cache/scoped-cache.ts +237 -0
  35. package/src/cli/cache.ts +173 -34
  36. package/src/cli/completions.ts +11 -7
  37. package/src/cli/core-alias.ts +1 -1
  38. package/src/cli/foreign-flags.ts +13 -12
  39. package/src/cli/help.ts +110 -70
  40. package/src/cli/index.ts +67 -24
  41. package/src/cli/info.ts +43 -27
  42. package/src/cli/init.ts +115 -407
  43. package/src/cli/last.ts +157 -9
  44. package/src/cli/lock.ts +356 -122
  45. package/src/cli/plan-format.ts +7 -5
  46. package/src/cli/plugin-commands.ts +81 -14
  47. package/src/cli/plugin-templates.ts +11 -11
  48. package/src/cli/run.ts +116 -160
  49. package/src/cli/select.ts +50 -47
  50. package/src/cli/show.ts +83 -59
  51. package/src/cli/task-verb.ts +6 -13
  52. package/src/cli/upgrade.ts +415 -149
  53. package/src/cli/watch-cycle.ts +9 -2
  54. package/src/cli/watch-filter.ts +39 -61
  55. package/src/cli/watch-fs.ts +29 -16
  56. package/src/cli/watch-judge.ts +18 -4
  57. package/src/cli/watch-set.ts +16 -42
  58. package/src/cli/watch.ts +227 -230
  59. package/src/cli/why.ts +38 -18
  60. package/src/cli/workspace-config.ts +33 -70
  61. package/src/config.ts +169 -70
  62. package/src/exec/env.ts +1 -31
  63. package/src/exec/executor.ts +1 -5
  64. package/src/exec/kill-tree.ts +2 -1
  65. package/src/exec/sandbox-runtime.ts +12 -1
  66. package/src/exec/sandbox-violations.ts +9 -1
  67. package/src/graph/dependency-spec.ts +1 -1
  68. package/src/graph/index.ts +1 -0
  69. package/src/graph/priorities.ts +1 -1
  70. package/src/graph/scheduler.ts +27 -7
  71. package/src/graph/task-graph.ts +76 -24
  72. package/src/handoff.ts +169 -0
  73. package/src/index.ts +35 -58
  74. package/src/orchestrator/affected-tasks.ts +15 -8
  75. package/src/orchestrator/command-windows.ts +44 -0
  76. package/src/orchestrator/deferred-outputs.ts +12 -10
  77. package/src/orchestrator/doctor.ts +98 -110
  78. package/src/orchestrator/download-policy.ts +3 -3
  79. package/src/orchestrator/events.ts +19 -3
  80. package/src/orchestrator/excluded-keys.ts +0 -2
  81. package/src/orchestrator/execute-task.ts +147 -69
  82. package/src/orchestrator/failure-mode.ts +19 -10
  83. package/src/orchestrator/fingerprint-watch.ts +18 -15
  84. package/src/orchestrator/forecast.ts +10 -2
  85. package/src/orchestrator/framed-output.ts +9 -5
  86. package/src/orchestrator/graph-stage.ts +5 -5
  87. package/src/orchestrator/history.ts +126 -30
  88. package/src/orchestrator/hit-restore.ts +15 -3
  89. package/src/orchestrator/index.ts +20 -5
  90. package/src/orchestrator/local-shortcircuit.ts +2 -1
  91. package/src/orchestrator/lockfile-claim.ts +79 -20
  92. package/src/orchestrator/logger.ts +80 -41
  93. package/src/orchestrator/metrics.ts +78 -9
  94. package/src/orchestrator/miss-save.ts +3 -0
  95. package/src/orchestrator/options.ts +42 -29
  96. package/src/orchestrator/output-host.ts +72 -0
  97. package/src/orchestrator/plan.ts +6 -8
  98. package/src/orchestrator/plugin-host.ts +100 -66
  99. package/src/orchestrator/plugin.ts +173 -67
  100. package/src/orchestrator/prepare.ts +163 -78
  101. package/src/orchestrator/projects.ts +105 -99
  102. package/src/orchestrator/remote-prefetch.ts +11 -6
  103. package/src/orchestrator/run-context.ts +25 -6
  104. package/src/orchestrator/run-failures.ts +17 -8
  105. package/src/orchestrator/run-lock.ts +1 -1
  106. package/src/orchestrator/run-records.ts +1 -1
  107. package/src/orchestrator/run-report.ts +9 -9
  108. package/src/orchestrator/run-settings.ts +48 -0
  109. package/src/orchestrator/run.ts +244 -119
  110. package/src/orchestrator/sandbox-fix.ts +39 -20
  111. package/src/orchestrator/sandbox-request.ts +84 -13
  112. package/src/orchestrator/signals.ts +1 -1
  113. package/src/orchestrator/stable-keys.ts +13 -11
  114. package/src/orchestrator/status-line.ts +49 -7
  115. package/src/orchestrator/summary.ts +115 -106
  116. package/src/orchestrator/task-hash.ts +12 -83
  117. package/src/orchestrator/telemetry.ts +4 -4
  118. package/src/settings.ts +428 -0
  119. package/src/{orchestrator → util}/colors.ts +5 -0
  120. package/src/util/config-frame.ts +31 -2
  121. package/src/util/errors.ts +33 -1
  122. package/src/util/essential-env.ts +31 -0
  123. package/src/util/hide-env.ts +107 -0
  124. package/src/util/index.ts +16 -3
  125. package/src/util/paths.ts +10 -0
  126. package/src/util/refusal-notice.ts +111 -0
  127. package/src/util/report.ts +31 -0
  128. package/src/util/settle.ts +49 -30
  129. package/src/util/size.ts +23 -4
  130. package/src/util/timing.ts +3 -2
  131. package/src/util/ui.ts +186 -0
  132. package/src/util/ulid.ts +1 -1
  133. package/src/util/verbs.ts +0 -15
  134. package/src/workspace/affected.ts +84 -232
  135. package/src/workspace/config-eval.ts +15 -20
  136. package/src/workspace/config-imports.ts +78 -3
  137. package/src/workspace/config-schema.ts +209 -268
  138. package/src/workspace/dependency-rules.ts +65 -0
  139. package/src/workspace/filter.ts +2 -2
  140. package/src/workspace/fingerprint.ts +11 -121
  141. package/src/workspace/index.ts +20 -27
  142. package/src/workspace/load-reads.ts +4 -4
  143. package/src/workspace/lockfile.ts +96 -140
  144. package/src/workspace/migration.ts +307 -215
  145. package/src/workspace/package-graph.ts +30 -216
  146. package/src/workspace/project-loader.ts +80 -285
  147. package/src/workspace/repo-id.ts +2 -1
  148. package/src/workspace/workspace.ts +363 -831
  149. package/src/cache/config-evals.ts +0 -130
  150. package/src/workspace/config-cache.ts +0 -661
  151. package/src/workspace/migrate-scripts.ts +0 -1121
@@ -4,56 +4,31 @@
4
4
  // aren't real work and would inflate "N total" misleadingly.
5
5
 
6
6
  import type { TaskOutcome } from '../graph/index.js'
7
- import { paint, type ColorSupport } from './colors.js'
8
7
  import { tallyOutcomes } from './tally.js'
9
8
  import { isCacheHit } from './telemetry.js'
10
9
  import { outcomeLabel, projectOutcome, ranNoCache } from './events.js'
11
10
  import { isGroupTask } from '../graph/index.js'
12
- import { formatElapsed } from '../util/index.js'
11
+ import {
12
+ ERROR,
13
+ formatElapsed,
14
+ gradientRule,
15
+ paint,
16
+ SUCCESS,
17
+ WARN,
18
+ type ColorSupport,
19
+ } from '../util/index.js'
13
20
 
14
21
  const NO_COLOR: ColorSupport = { enabled: false }
15
22
 
16
- const SUCCESS = '#22c55e'
17
- const WARN = '#eab308'
18
23
  // Cache provenance hues (owner): local light blue, remote dark blue —
19
24
  // yellow is skipped's color on both meters.
20
25
  const LOCAL = '#38bdf8' // sky-400
21
26
  const REMOTE = '#2563eb' // blue-600
22
- const ERROR = '#ef4444'
23
27
  const ABORTED = '#f97316' // orange-500: red's neighbour, not a failure
28
+ const NOT_RUN = '#a855f7' // purple-500: stopped before it began, apart from every other state
24
29
  const ACCENT = '#06b6d4' // cyan-500: the live region's running word
25
30
 
26
31
  const BAR_WIDTH = 50
27
- // Brand gradient for the rule: identity violet → pink (the project /
28
- // task hues), faded across the dash run. Plain dashes when colors
29
- // are off.
30
- const GRADIENT_FROM = [0xa7, 0x8b, 0xfa] as const
31
- const GRADIENT_TO = [0xf4, 0x72, 0xb6] as const
32
- // Rule ends flush with the bars: 2 indent + 8 label + 2 gap + 50 cells.
33
- const RULE_DASHES = 57
34
-
35
- /** Total visible rule width: `\u2500 <mark> ` + dashes. Shared by summary + header. */
36
- function gradientRule(colors: ColorSupport, mark = 'vx'): string {
37
- const head = `\u2500 `
38
- const dashes = '\u2500'.repeat(Math.max(8, RULE_DASHES + 2 - mark.length))
39
- if (!colors.enabled) return `${head}${mark} ${dashes}`
40
- const chunks = 8
41
- const per = Math.ceil(RULE_DASHES / chunks)
42
- let out = ''
43
- for (let i = 0; i < chunks; i++) {
44
- const seg = dashes.slice(i * per, (i + 1) * per)
45
- if (seg.length === 0) break
46
- const f = i / (chunks - 1)
47
- const hex = `#${GRADIENT_FROM.map((c, j) =>
48
- Math.round(c + (GRADIENT_TO[j]! - c) * f)
49
- .toString(16)
50
- .padStart(2, '0'),
51
- ).join('')}`
52
- out += paint(hex, seg, colors)
53
- }
54
- return `${paint(`#${GRADIENT_FROM.map((c) => c.toString(16).padStart(2, '0')).join('')}`, head, colors)}${paint('', mark, colors, { bold: true })} ${out}`
55
- }
56
-
57
32
  /**
58
33
  * Stacked state meter: one ▰ run per bucket, proportional cells via
59
34
  * largest-remainder, every non-zero bucket guaranteed at least one
@@ -110,6 +85,10 @@ export interface SummaryStats {
110
85
  upToDate: number
111
86
  restoredLocal: number
112
87
  restoredRemote: number
88
+ /** Ran here and saved to the cache: the legend's `↑`. */
89
+ saved?: number
90
+ /** Ran here with a `cache` block, succeeded, and saved nothing. */
91
+ notSaved?: number
113
92
  /** Tasks that executed (success + failed) with a `cache` block — the misses. */
114
93
  miss: number
115
94
  /** Executed with no `cache` block: never consulted the cache, not a miss. */
@@ -137,11 +116,26 @@ export interface SummaryStats {
137
116
  */
138
117
  export interface RunContext {
139
118
  version: string
140
- /** Projects covered by the graph — the "in run" half of the bar. */
119
+ /** Projects covered by the graph — the "affected" half of the bar. */
141
120
  packageCount: number
142
121
  /** Worker-pool size for this run; shown on the `info` row. */
143
122
  concurrency?: number
144
123
  remoteCacheEnabled: boolean
124
+ /**
125
+ * The cache layers the run used, so the legend reads `local 1= 2↓ 4↑ ·
126
+ * remote 3↓ 4↑`. Absent: the plain `N miss` legend.
127
+ */
128
+ cache?: {
129
+ /** The run's policy reads or writes the local layer. */
130
+ local: boolean
131
+ localWrite: boolean
132
+ /** A remote layer exists and the policy reads or writes it. */
133
+ remote: boolean
134
+ /** The run's uploads, filled as each lands (a live ref). */
135
+ uploads: { count: number; failed: number }
136
+ /** Tasks whose outputs are still in the remote store. */
137
+ notDownloaded: () => number
138
+ }
145
139
  /** Total projects discovery found — the bar's denominator. */
146
140
  workspaceProjectCount?: number
147
141
  }
@@ -164,25 +158,25 @@ export function formatSummarySection(
164
158
  const row = (label: string, value: string): string => ` ${dim(label.padEnd(8))} ${value}`
165
159
  const join = (parts: string[]): string => parts.join(` ${dim('\u00b7')} `)
166
160
 
167
- // Tasks meter + numbers, fixed order: failed · success · skipped,
168
- // then a dim total so the legend reads against the whole graph.
161
+ // Tasks meter + numbers, fixed order: failed · success · skipped ·
162
+ // aborted · not run, then a dim total, last on its line. Every task in
163
+ // the run counts, each state its own segment (M, 2026-10-14).
164
+ const runTotal = stats.total + aborted + notRun
169
165
  const taskParts: string[] = []
170
166
  if (stats.failed > 0)
171
167
  taskParts.push(paint(ERROR, `${stats.failed} failed`, colors, { bold: true }))
172
168
  if (stats.successful > 0) taskParts.push(paint(SUCCESS, `${stats.successful} success`, colors))
173
169
  if (stats.skipped > 0) taskParts.push(paint(WARN, `${stats.skipped} skipped`, colors))
174
- if (stats.total > 0) taskParts.push(dim(`${stats.total} total`))
175
- // Rows in the task list, but outside `total`: history and telemetry count
176
- // the same tasks the total does, and an aborted task did no work.
177
- const uncounted: string[] = []
178
- if (aborted > 0) uncounted.push(paint(ABORTED, `${aborted} aborted`, colors))
179
- if (notRun > 0) uncounted.push(dim(`${notRun} not run`))
180
- if (uncounted.length > 0) taskParts.push(`${dim('not counted:')} ${uncounted.join(dim(', '))}`)
170
+ if (aborted > 0) taskParts.push(paint(ABORTED, `${aborted} aborted`, colors))
171
+ if (notRun > 0) taskParts.push(paint(NOT_RUN, `${notRun} not run`, colors))
172
+ if (runTotal > 0) taskParts.push(dim(`${runTotal} total`))
181
173
  const taskBar = segmentBar(
182
174
  [
183
175
  { n: stats.failed, color: ERROR },
184
176
  { n: stats.successful, color: SUCCESS },
185
177
  { n: stats.skipped, color: WARN },
178
+ { n: aborted, color: ABORTED },
179
+ { n: notRun, color: NOT_RUN },
186
180
  remainder,
187
181
  ],
188
182
  colors,
@@ -211,7 +205,7 @@ export function formatSummarySection(
211
205
  paint('', '▱'.repeat(BAR_WIDTH - cells), colors, { dim: true })
212
206
  lines.push(
213
207
  row('projects', bar),
214
- legend([paint(WARN, `${context.packageCount} in run`, colors), dim(`${wsTotal} total`)]),
208
+ legend([paint(WARN, `${context.packageCount} affected`, colors), dim(`${wsTotal} total`)]),
215
209
  )
216
210
  }
217
211
  }
@@ -233,8 +227,8 @@ export function formatSummarySection(
233
227
  if (taskParts.length > 0) lines.push(legend(taskParts))
234
228
  }
235
229
 
236
- // Cache meter + numbers, fixed order: miss · up-to-date · local ·
237
- // remote.
230
+ // Cache meter + numbers: per layer when the run context names its
231
+ // layers, else miss · no-cache · up-to-date · local · remote.
238
232
  if (miss + noCache + hits + left + stats.skipped > 0) {
239
233
  const cacheBar = segmentBar(
240
234
  [
@@ -249,34 +243,80 @@ export function formatSummarySection(
249
243
  colors,
250
244
  )
251
245
  const cacheParts: string[] = []
252
- if (miss > 0) cacheParts.push(paint(ERROR, `${miss} miss`, colors))
253
- if (noCache > 0) cacheParts.push(paint('', `${noCache} no-cache`, colors, { dim: true }))
254
- if (stats.upToDate > 0) cacheParts.push(paint(SUCCESS, `${stats.upToDate} up-to-date`, colors))
255
- if (stats.restoredLocal > 0)
256
- cacheParts.push(paint(LOCAL, `${stats.restoredLocal} local`, colors))
257
- if (stats.restoredRemote > 0)
258
- cacheParts.push(paint(REMOTE, `${stats.restoredRemote} remote`, colors))
259
- // Skipped tasks have no cache provenance, but omitting them made
260
- // the cache legend sum below the tasks legend (owner-reported
261
- // confusion) — on the bar and in the legend, yellow like the
262
- // tasks meter.
263
- if (stats.skipped > 0) cacheParts.push(paint(WARN, `${stats.skipped} skipped`, colors))
246
+ const layers = context?.cache
247
+ if (layers !== undefined) {
248
+ // Per layer (M, 2026-10-15): `●` fresh (outputs already on disk, from
249
+ // either layer) and `○` no cache, both local; `↓` restored from a
250
+ // layer, `↑` saved to it.
251
+ const n = (count: number, glyph: string): string[] => (count > 0 ? [`${count}${glyph}`] : [])
252
+ const local = [
253
+ ...(layers.local ? n(stats.upToDate, '\u25cf') : []),
254
+ ...n(noCache, '\u25cb'),
255
+ ...(layers.local ? n(stats.restoredLocal, '\u2193') : []),
256
+ ...(layers.localWrite ? n(stats.saved ?? 0, '\u2191') : []),
257
+ ]
258
+ if (local.length > 0) cacheParts.push(paint(LOCAL, `local ${local.join(' ')}`, colors))
259
+ const remote = [...n(stats.restoredRemote, '\u2193'), ...n(layers.uploads.count, '\u2191')]
260
+ if (layers.remote && remote.length > 0)
261
+ cacheParts.push(paint(REMOTE, `remote ${remote.join(' ')}`, colors))
262
+ if (layers.uploads.failed > 0)
263
+ cacheParts.push(paint(ERROR, `${layers.uploads.failed} \u2191 failed`, colors))
264
+ if ((stats.notSaved ?? 0) > 0)
265
+ cacheParts.push(paint(WARN, `${stats.notSaved} not saved`, colors))
266
+ const notDownloaded = layers.notDownloaded()
267
+ if (notDownloaded > 0) cacheParts.push(paint(WARN, `${notDownloaded} not downloaded`, colors))
268
+ } else {
269
+ if (miss > 0) cacheParts.push(paint(ERROR, `${miss} miss`, colors))
270
+ if (noCache > 0) cacheParts.push(paint('', `${noCache} no-cache`, colors, { dim: true }))
271
+ if (stats.upToDate > 0)
272
+ cacheParts.push(paint(SUCCESS, `${stats.upToDate} up-to-date`, colors))
273
+ if (stats.restoredLocal > 0)
274
+ cacheParts.push(paint(LOCAL, `${stats.restoredLocal} local`, colors))
275
+ if (stats.restoredRemote > 0)
276
+ cacheParts.push(paint(REMOTE, `${stats.restoredRemote} remote`, colors))
277
+ }
278
+ // Skipped tasks ride the bar, yellow like the tasks meter, so the two
279
+ // bars fill alike; their count is the tasks legend's alone (the footer
280
+ // never says a fact twice, M, 2026-10-14).
264
281
  lines.push(row('cache', cacheBar))
265
282
  if (cacheParts.length > 0) lines.push(legend(cacheParts))
266
283
  }
267
284
 
268
285
  // Cache-miss duration spread (owner: hits' restore times polluted
269
286
  // it — only tasks that actually executed count). Dim: context.
270
- let spread = ''
271
- if (stats.spread !== null && stats.spread.count > 0) {
287
+ // A value already on the row is not said again: one executed task read
288
+ // as its own max, avg and min (M, 2026-10-14). Tasks that all took the
289
+ // same time have no spread to show.
290
+ const total = formatElapsed(totalMs)
291
+ const said = new Set([total])
292
+ const spreadParts: string[] = []
293
+ if (
294
+ stats.spread !== null &&
295
+ formatElapsed(stats.spread.maxMs) !== formatElapsed(stats.spread.minMs)
296
+ ) {
272
297
  const { maxMs, minMs, sumMs, count } = stats.spread
273
- spread = ` ${dim(`\u00b7 max ${formatElapsed(maxMs)} \u00b7 avg ${formatElapsed(sumMs / count)} \u00b7 min ${formatElapsed(minMs)}`)}`
298
+ for (const [label, ms] of [
299
+ ['max', maxMs],
300
+ ['avg', sumMs / count],
301
+ ['min', minMs],
302
+ ] as const) {
303
+ const v = formatElapsed(ms)
304
+ if (said.has(v)) continue
305
+ said.add(v)
306
+ spreadParts.push(`${label} ${v}`)
307
+ }
274
308
  }
309
+ const timeParts = [total]
310
+ // What the cache paid back, beside what the run cost: the stored exec
311
+ // time of every hit, never the restore this run spent on it.
312
+ if ((stats.savedMs ?? 0) >= 1)
313
+ timeParts.push(paint(SUCCESS, `${formatElapsed(stats.savedMs!)} saved`, colors))
275
314
  // The live run's forecast leads the spread: the one number on the row
276
315
  // that is about the future.
277
- if (stats.etaMs !== undefined) {
278
- spread = ` ${dim('\u00b7')} ${paint(ACCENT, `${formatEta(stats.etaMs)} left`, colors)}${spread}`
279
- }
316
+ if (stats.etaMs !== undefined)
317
+ timeParts.push(paint(ACCENT, `${formatEta(stats.etaMs)} left`, colors))
318
+ for (const p of spreadParts) timeParts.push(dim(p))
319
+ const timeRow = row('time', join(timeParts))
280
320
  // Run-shape footer (final summary only): worker pool + cache mode,
281
321
  // grouped with time under a blank line below the meters.
282
322
  if (context !== undefined) {
@@ -294,12 +334,9 @@ export function formatSummarySection(
294
334
  `admit held ${stats.held.count} task${stats.held.count === 1 ? '' : 's'}, ${formatElapsed(stats.held.sumMs)} in all`,
295
335
  )
296
336
  }
297
- lines.push('', row('info', join(info)), row('time', `${formatElapsed(totalMs)}${spread}`))
298
- // The run in one line, last, where the eye lands: what a Turbo user
299
- // reads first in its own summary (tasks, cached, time), in vx's words.
300
- if (stats.total > 0) lines.push(row('result', resultLine(stats, hits, totalMs, colors)))
337
+ lines.push('', row('info', join(info)), timeRow)
301
338
  } else {
302
- lines.push('', row('time', `${formatElapsed(totalMs)}${spread}`))
339
+ lines.push('', timeRow)
303
340
  }
304
341
  return lines
305
342
  }
@@ -315,41 +352,6 @@ function formatEta(ms: number): string {
315
352
  return `~${Math.floor(s / 60)}m ${String(s % 60).padStart(2, '0')}s`
316
353
  }
317
354
 
318
- /**
319
- * `42 tasks · 38 cached (90%) · 3.2s`, `2 tasks · all cached · 23ms`,
320
- * with the failures first when there are any. Cached is every hit (up to
321
- * date, restored locally or remotely) over every task that consulted a
322
- * cache: a skipped or not-yet-run task never did.
323
- */
324
- function resultLine(
325
- stats: SummaryStats,
326
- hits: number,
327
- totalMs: number,
328
- colors: ColorSupport,
329
- ): string {
330
- const parts = [`${stats.total} task${stats.total === 1 ? '' : 's'}`]
331
- if (stats.failed > 0) parts.push(paint(ERROR, `${stats.failed} failed`, colors, { bold: true }))
332
- // A task with no `cache` block could never hit: it is counted apart, not
333
- // as a miss, or `vx run dev` would read "0 cached (0%)". A skipped task
334
- // never asked either, and counted as a miss it put "0 cached (0%)" on a
335
- // run in which no task has a cache.
336
- const noCache = stats.noCache ?? 0
337
- const cacheable = stats.total - noCache - stats.skipped - (stats.left ?? 0)
338
- if (cacheable > 0)
339
- parts.push(
340
- hits === cacheable
341
- ? paint(SUCCESS, 'all cached', colors, { bold: true })
342
- : `${hits} cached (${Math.floor((hits / cacheable) * 100)}%)`,
343
- )
344
- if (noCache > 0) parts.push(paint('', `${noCache} no-cache`, colors, { dim: true }))
345
- // What the cache paid back, beside what the run cost: the stored exec
346
- // time of every hit, never the restore this run spent on it.
347
- if ((stats.savedMs ?? 0) >= 1)
348
- parts.push(paint(SUCCESS, `${formatElapsed(stats.savedMs!)} saved`, colors))
349
- parts.push(formatElapsed(totalMs))
350
- return parts.join(` ${paint('', '\u00b7', colors, { dim: true })} `)
351
- }
352
-
353
355
  /**
354
356
  * The `--verbosity 1` per-task table (`RunOptions.summaryTable`). Groups are
355
357
  * left out as the footer and `--report` leave them: a group ran no command.
@@ -380,6 +382,11 @@ export function formatOutcomeTable(outcomes: readonly TaskOutcome[]): string[] {
380
382
 
381
383
  // Local byte formatter — the orchestrator can't import cli/format.ts
382
384
  // (module boundary), and the footer only needs one compact form.
385
+ /** Ran here with a `cache` block, succeeded, and its result was not saved. */
386
+ export function ranUnsaved(o: TaskOutcome): boolean {
387
+ return o.status === 'success' && !ranNoCache(o) && o.saved !== true && o.where === undefined
388
+ }
389
+
383
390
  export function formatRunSummary(
384
391
  outcomes: readonly TaskOutcome[],
385
392
  totalMs: number,
@@ -418,6 +425,8 @@ export function formatRunSummary(
418
425
  upToDate: t.upToDate,
419
426
  restoredLocal: t.restoredLocal,
420
427
  restoredRemote: t.restoredRemote,
428
+ saved: outcomes.filter((o) => o.saved === true && !isGroupTask(o.node)).length,
429
+ notSaved: outcomes.filter((o) => !isGroupTask(o.node) && ranUnsaved(o)).length,
421
430
  miss: t.total - t.skipped - (t.cachedLocal + t.cachedRemote) - noCache,
422
431
  noCache,
423
432
  savedMs,
@@ -21,12 +21,8 @@ import { isUserError, relPosix, secretMask, span, UserError, xxh3hex } from '../
21
21
  import { expandGroupUpstream, filterUpstreamHashes } from './upstream.js'
22
22
 
23
23
  /**
24
- * Per-run memoization caches for the two derived hashes that don't
25
- * change across tasks within one run:
26
- * - `packageJson`: keyed by absolute projectDir. Every task in a
27
- * project re-reads the same `package.json`; without this, a
28
- * monorepo with N projects × M tasks per project does N×M reads
29
- * of the same bytes.
24
+ * Per-run memoization caches for what doesn't change across tasks within
25
+ * one run:
30
26
  * - `projectFiles`: keyed by the project and its input DECLARATION.
31
27
  * Tasks of one project that declare the same inputs and outputs
32
28
  * resolve the same file list; see `ProjectFilesCache`.
@@ -35,11 +31,10 @@ import { expandGroupUpstream, filterUpstreamHashes } from './upstream.js'
35
31
  * JSON.stringify + xxh3 of it is deterministic. WeakMap so
36
32
  * entries free when the orchestrator is done.
37
33
  *
38
- * Both fields are optional — the helpers fall back to computing
39
- * fresh when the cache is missing.
34
+ * The whole memo is optional — the helpers fall back to computing
35
+ * fresh when it is missing.
40
36
  */
41
37
  export interface HashCache {
42
- packageJson: Map<string, Promise<string>>
43
38
  taskConfig: WeakMap<TaskConfig, string>
44
39
  runtime: Map<string, Promise<string>>
45
40
  workspaceRuntime: Map<string, Promise<string>>
@@ -49,7 +44,6 @@ export interface HashCache {
49
44
 
50
45
  export function createHashCache(): HashCache {
51
46
  return {
52
- packageJson: new Map(),
53
47
  taskConfig: new WeakMap(),
54
48
  runtime: new Map(),
55
49
  workspaceRuntime: new Map(),
@@ -76,7 +70,6 @@ export interface ComputeHashArgs {
76
70
  node: TaskNode
77
71
  upstream: TaskOutcome[]
78
72
  workspaceRoot: string
79
- workspaceFingerprint: string
80
73
  cache: CacheLayer
81
74
  forwardArgs?: readonly string[] | undefined
82
75
  nestedProjectDirs: string[]
@@ -100,8 +93,7 @@ export interface ComputeHashArgs {
100
93
 
101
94
  /**
102
95
  * Resolve every input the cache key depends on (file content, env
103
- * values, upstream hashes, config bytes, project package.json bytes,
104
- * forwardArgs) and ask the cache layer to combine them. Used by both
96
+ * values, upstream hashes, config bytes, forwardArgs) and ask the cache layer to combine them. Used by both
105
97
  * `executeTask` for the real run and `plan()` for `--dry-run` /
106
98
  * `--graph` previews.
107
99
  *
@@ -161,22 +153,12 @@ export async function describeTaskInputs(args: ComputeHashArgs): Promise<{
161
153
  ? []
162
154
  : [{ path: relPosix(input.workspaceRoot, f), digest: digests[i]! }],
163
155
  )
164
- // The package.json digest is a per-run memo that may predate this
165
- // describe, so it is dated from the enumeration, the earliest a run
166
- // learns anything.
167
156
  const indexedAt = args.gitFilesCache?.enumeratedAtMs ?? 0
168
157
  const facts: InputFact[] = sorted.map((f, i) => ({
169
158
  path: f,
170
159
  digest: digests[i]!,
171
160
  since: input.fileHashes?.has(f) === true ? indexedAt : hashedAt,
172
161
  }))
173
- if (input.projectPackageJsonHash !== '') {
174
- facts.push({
175
- path: path.join(args.node.projectDir, 'package.json'),
176
- digest: input.projectPackageJsonHash,
177
- since: indexedAt,
178
- })
179
- }
180
162
  // Groups expanded: this list describes the INPUT ROOT, not the key, and a
181
163
  // group produces nothing of its own (see `CacheKeyInput.upstreamGraft`).
182
164
  // Sorted by hash so the list — and any action digest derived from it — is
@@ -220,9 +202,7 @@ export async function describeTaskInputs(args: ComputeHashArgs): Promise<{
220
202
  get upstream() {
221
203
  return (upstream ??= listUpstream())
222
204
  },
223
- packageJsonDigest: input.projectPackageJsonHash,
224
205
  configDigest: input.taskConfigHash,
225
- workspaceFingerprint: input.workspaceFingerprint,
226
206
  },
227
207
  }
228
208
  }
@@ -238,15 +218,17 @@ export async function describeTaskInputs(args: ComputeHashArgs): Promise<{
238
218
  * input changed and changed BACK while the command ran matched its digest
239
219
  * again, and the entry saved the other content's output under the key (item
240
220
  * 1015). A write before it — an upstream's — is judged by content, as before.
241
- * `writesByBytes` (`inputStability.strategy: 'mutable'`) judges a write
242
- * during the command by content too: the task says the same bytes are its
243
- * own identical rewrite (tsdown's package.json), not an edit undone.
221
+ * `byBytes` (`inputStability.strategy: 'mutable'`) judges a write during
222
+ * the command by content too, when it answers true for that file and its
223
+ * ctime: the task says the same bytes are its own identical rewrite
224
+ * (tsdown's package.json), not an edit undone, unless another command that
225
+ * may write the file ran until then (command-windows.ts).
244
226
  */
245
227
  export async function movedInput(
246
228
  facts: readonly InputFact[],
247
229
  cache: CacheLayer,
248
230
  commandFrom?: number,
249
- writesByBytes = false,
231
+ byBytes?: (file: string, ctimeMs: number) => boolean,
250
232
  ): Promise<string | undefined> {
251
233
  const suspects: InputFact[] = []
252
234
  for (const f of facts) {
@@ -264,7 +246,7 @@ export async function movedInput(
264
246
  // an even one, FAT's tick), one during the command included
265
247
  // (`racyWindowMs`).
266
248
  if (commandFrom !== undefined && ctimeMs >= commandFrom - racyWindowMs(ctimeMs, 0)) {
267
- if (!writesByBytes) return f.path
249
+ if (byBytes?.(f.path, ctimeMs) !== true) return f.path
268
250
  suspects.push(f)
269
251
  continue
270
252
  }
@@ -344,12 +326,6 @@ async function resolveKeyInput(
344
326
  }
345
327
  }
346
328
  const taskConfigHash = hashTaskConfig(cfg, args.hashCache)
347
- const projectPackageJsonHash = await hashProjectPackageJson(
348
- args.node.projectDir,
349
- args.cache,
350
- args.hashCache,
351
- fileHashes,
352
- )
353
329
 
354
330
  // Args after `--` reach a requested command; the default build (a keyed
355
331
  // group) runs none, and folded, they moved every key that folds its own.
@@ -359,7 +335,6 @@ async function resolveKeyInput(
359
335
  const input: CacheKeyInput = {
360
336
  taskId: args.node.id,
361
337
  taskConfigHash,
362
- projectPackageJsonHash,
363
338
  envValues: resolved.envValues,
364
339
  runtimeValues: resolved.runtimeValues,
365
340
  workspaceRuntimeValues: resolved.workspaceRuntimeValues,
@@ -368,7 +343,6 @@ async function resolveKeyInput(
368
343
  upstreamHashes,
369
344
  upstreamIds,
370
345
  upstreamGraft,
371
- workspaceFingerprint: args.workspaceFingerprint,
372
346
  forwardArgs: effectiveForwardArgs,
373
347
  ...(fileHashes !== undefined ? { fileHashes } : {}),
374
348
  ...(args.node.keyParts !== undefined ? { pluginParts: args.node.keyParts } : {}),
@@ -462,48 +436,3 @@ export function computeGroupHash(upstream: TaskOutcome[]): string {
462
436
  .join('|')
463
437
  return xxh3hex(`group|${ids}`)
464
438
  }
465
-
466
- /**
467
- * Hash the project's `package.json` bytes. Folded into every task's
468
- * cache key in that project so dep changes (devDependencies, scripts,
469
- * version bumps) invalidate the project's tasks even when
470
- * `cache.inputs.files` is narrow and doesn't cover the file.
471
- *
472
- * Matches Turbo and Nx's "implicit dependencies" behavior. Returns
473
- * '' for the edge case of a project without a package.json (impossible
474
- * in practice — workspace discovery requires one).
475
- *
476
- * Two optimizations: routed through `Cache.hashFile` so the mtime+size
477
- * fast path applies (an unchanged pkg.json takes a stat + SQLite SELECT
478
- * instead of a full file read). And memoized within-run per projectDir
479
- * so a monorepo with 100 projects × 3 tasks each does 100 lookups, not
480
- * 300.
481
- */
482
- async function hashProjectPackageJson(
483
- projectDir: string,
484
- cache: CacheLayer,
485
- hashCache?: HashCache,
486
- fileHashes?: ReadonlyMap<string, string>,
487
- ): Promise<string> {
488
- const cached = hashCache?.packageJson.get(projectDir)
489
- if (cached !== undefined) return cached
490
- const promise = doHashProjectPackageJson(projectDir, cache, fileHashes)
491
- hashCache?.packageJson.set(projectDir, promise)
492
- return promise
493
- }
494
-
495
- async function doHashProjectPackageJson(
496
- projectDir: string,
497
- cache: CacheLayer,
498
- fileHashes?: ReadonlyMap<string, string>,
499
- ): Promise<string> {
500
- const filePath = path.join(projectDir, 'package.json')
501
- // Clean tracked package.json: its index OID is already in hand —
502
- // no exists probe, no stat, no SQLite.
503
- const oid = fileHashes?.get(filePath)
504
- if (oid !== undefined) return oid
505
- if (!(await Bun.file(filePath).exists())) return ''
506
- // Route through the cache layer's mtime+size fast path so the
507
- // typical re-run sees a stat + SQLite lookup instead of a file read.
508
- return cache.hashFile(filePath)
509
- }
@@ -117,7 +117,7 @@ export interface RunContextRecord {
117
117
  /** The invocation command line (process.argv-derived), what follows `--` counted, not quoted. */
118
118
  command: string
119
119
  requestedTasks: readonly string[]
120
- /** Compact cache-policy flags, e.g. `'lR,lW,rR,rW'`. */
120
+ /** The `--cache-policy` spec, e.g. `'local:rw,remote:r'`. */
121
121
  cachePolicy: string
122
122
  concurrency: number
123
123
  flow: 'focused' | 'broad' | null
@@ -191,11 +191,11 @@ export interface TaskTelemetry {
191
191
  /** Executor-reported placement (a worker id) — absent for local runs.
192
192
  * Additive-optional, no schema bump (the `attempts` precedent). */
193
193
  where?: string
194
- /** `'deferred'` when the outputs stayed remote (`--download=none`).
194
+ /** `'deferred'` when the outputs stayed remote (`--cache-download=none`).
195
195
  * Additive-optional, same no-bump precedent as `where`. */
196
196
  outputs?: 'deferred'
197
197
  /** Total attempts when the task RETRIED (>1) — set only when `exec.retries`
198
- * / `--retry` produced more than one attempt. A retried-then-passed task is
198
+ * / `--retries` produced more than one attempt. A retried-then-passed task is
199
199
  * flaky by definition; this is the telemetry-side flaky signal. */
200
200
  attempts?: number
201
201
  /**
@@ -340,7 +340,7 @@ export interface RunSummaryRecord {
340
340
  exitOk: boolean
341
341
  tasks: readonly TaskTelemetry[]
342
342
  /**
343
- * The run's stages as `VX_TIMING` marks them (startup, load configs,
343
+ * The run's stages as `VX_DEBUG_TIMING` marks them (startup, load configs,
344
344
  * classify + probe, run graph, …), each a wall window in epoch ms. The
345
345
  * stages ahead of the run lock end before `startedAt`. Additive.
346
346
  */