@vzn/vx 0.0.638 → 0.0.640

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 (134) hide show
  1. package/README.md +1 -1
  2. package/docs/caching.md +152 -95
  3. package/docs/cli.md +225 -372
  4. package/docs/execution.md +22 -47
  5. package/docs/features.md +14 -16
  6. package/docs/patterns.md +6 -8
  7. package/docs/schema.md +268 -215
  8. package/docs/security.md +1 -1
  9. package/package.json +5 -5
  10. package/schemas/last.json +7 -4
  11. package/schemas/show.json +14 -14
  12. package/skills/vx/SKILL.md +10 -7
  13. package/src/cache/cache.ts +44 -11
  14. package/src/cache/file-hashes.ts +7 -12
  15. package/src/cache/fs-clock.ts +62 -0
  16. package/src/cache/git-inputs.ts +8 -2
  17. package/src/cache/index.ts +5 -2
  18. package/src/cache/inputs.ts +154 -83
  19. package/src/cache/key-fold.ts +23 -3
  20. package/src/cache/layer.ts +33 -18
  21. package/src/cache/layered-cache.ts +108 -315
  22. package/src/cache/linked-inputs.ts +87 -0
  23. package/src/cache/plugin-cache.ts +2 -10
  24. package/src/cache/remote-lane.ts +383 -0
  25. package/src/cache/run-history.ts +24 -8
  26. package/src/cache/run-store.ts +4 -4
  27. package/src/cache/schema.ts +6 -4
  28. package/src/cache/scoped-cache.ts +7 -0
  29. package/src/cli/completions.ts +2 -2
  30. package/src/cli/foreign-flags.ts +11 -23
  31. package/src/cli/help.ts +6 -31
  32. package/src/cli/info.ts +2 -1
  33. package/src/cli/init.ts +23 -11
  34. package/src/cli/last.ts +30 -4
  35. package/src/cli/plan-format.ts +5 -58
  36. package/src/cli/plugin-commands.ts +2 -2
  37. package/src/cli/plugin-templates.ts +2 -2
  38. package/src/cli/run.ts +23 -185
  39. package/src/cli/select.ts +16 -6
  40. package/src/cli/show.ts +15 -6
  41. package/src/cli/upgrade.ts +215 -55
  42. package/src/cli/watch-cycle.ts +2 -1
  43. package/src/cli/watch-filter.ts +22 -6
  44. package/src/cli/watch-judge.ts +49 -5
  45. package/src/cli/watch-set.ts +18 -8
  46. package/src/cli/watch.ts +103 -16
  47. package/src/cli/why.ts +9 -7
  48. package/src/config.ts +19 -21
  49. package/src/exec/executor.ts +5 -5
  50. package/src/exec/sandbox-runtime.ts +1 -1
  51. package/src/exec/sandbox-violations.ts +3 -1
  52. package/src/graph/dependency-spec.ts +2 -2
  53. package/src/graph/folds.ts +236 -0
  54. package/src/graph/index.ts +17 -2
  55. package/src/graph/priorities.ts +1 -1
  56. package/src/graph/scheduler.ts +45 -31
  57. package/src/graph/task-graph.ts +190 -58
  58. package/src/graph/task-status.ts +95 -0
  59. package/src/{orchestrator → history}/failure-mode.ts +1 -1
  60. package/src/{orchestrator → history}/history.ts +1 -1
  61. package/src/history/index.ts +48 -0
  62. package/src/{orchestrator → history}/metrics.ts +7 -20
  63. package/src/{orchestrator → history}/miss-reason.ts +1 -1
  64. package/src/index.ts +11 -29
  65. package/src/orchestrator/affected-tasks.ts +20 -19
  66. package/src/orchestrator/doctor.ts +1 -1
  67. package/src/orchestrator/download-policy.ts +16 -22
  68. package/src/orchestrator/excluded-keys.ts +4 -5
  69. package/src/orchestrator/execute-args.ts +80 -0
  70. package/src/orchestrator/execute-task.ts +50 -411
  71. package/src/orchestrator/fingerprint-watch.ts +2 -2
  72. package/src/orchestrator/index.ts +2 -73
  73. package/src/orchestrator/keyed-projects.ts +12 -51
  74. package/src/orchestrator/local-shortcircuit.ts +1 -1
  75. package/src/orchestrator/miss-save.ts +33 -3
  76. package/src/orchestrator/options.ts +1 -36
  77. package/src/orchestrator/output-host.ts +4 -3
  78. package/src/orchestrator/persistent-task.ts +321 -0
  79. package/src/orchestrator/persistent.ts +130 -3
  80. package/src/orchestrator/placement.ts +5 -5
  81. package/src/orchestrator/plan.ts +19 -6
  82. package/src/orchestrator/plugin-host.ts +129 -69
  83. package/src/orchestrator/plugin.ts +18 -8
  84. package/src/orchestrator/prepare.ts +40 -34
  85. package/src/orchestrator/projects.ts +7 -10
  86. package/src/orchestrator/remote-prefetch.ts +14 -1
  87. package/src/orchestrator/run-artifacts.ts +5 -177
  88. package/src/orchestrator/run-context.ts +55 -0
  89. package/src/orchestrator/run-failures.ts +15 -3
  90. package/src/orchestrator/run-records.ts +35 -3
  91. package/src/orchestrator/run-report.ts +151 -171
  92. package/src/orchestrator/run-settings.ts +1 -16
  93. package/src/orchestrator/run-steps.ts +57 -0
  94. package/src/orchestrator/run-stop.ts +153 -0
  95. package/src/orchestrator/run.ts +293 -604
  96. package/src/orchestrator/sandbox-fix.ts +1 -17
  97. package/src/orchestrator/sandbox-request.ts +2 -2
  98. package/src/orchestrator/signals.ts +14 -6
  99. package/src/orchestrator/stable-keys.ts +164 -27
  100. package/src/orchestrator/steps.ts +70 -0
  101. package/src/orchestrator/task-hash.ts +34 -25
  102. package/src/orchestrator/task-log-buffer.ts +1 -1
  103. package/src/orchestrator/task-process.ts +94 -0
  104. package/src/orchestrator/telemetry-host.ts +4 -4
  105. package/src/orchestrator/telemetry.ts +14 -86
  106. package/src/orchestrator/upstream.ts +4 -181
  107. package/src/{orchestrator → report}/events.ts +41 -8
  108. package/src/{orchestrator → report}/forecast.ts +35 -2
  109. package/src/{orchestrator → report}/framed-output.ts +40 -34
  110. package/src/report/index.ts +36 -0
  111. package/src/{orchestrator → report}/logger.ts +158 -20
  112. package/src/{orchestrator → report}/status-line.ts +88 -7
  113. package/src/{orchestrator → report}/summary.ts +126 -35
  114. package/src/report/tally.ts +83 -0
  115. package/src/settings.ts +12 -39
  116. package/src/util/colors.ts +44 -4
  117. package/src/util/git-ignored.ts +143 -0
  118. package/src/util/index.ts +19 -4
  119. package/src/util/paths.ts +1 -1
  120. package/src/util/report.ts +13 -5
  121. package/src/util/settle.ts +14 -39
  122. package/src/util/tail.ts +1 -1
  123. package/src/util/ui.ts +17 -6
  124. package/src/workspace/affected.ts +81 -2
  125. package/src/workspace/config-imports.ts +1 -1
  126. package/src/workspace/config-schema.ts +142 -143
  127. package/src/workspace/lockfile.ts +1 -1
  128. package/src/workspace/migration.ts +19 -3
  129. package/schemas/summary.json +0 -133
  130. package/src/cache/chained-cache.ts +0 -280
  131. package/src/orchestrator/tally.ts +0 -112
  132. /package/src/{orchestrator → report}/path-links.ts +0 -0
  133. /package/src/{orchestrator → report}/plain-output.ts +0 -0
  134. /package/src/{orchestrator → report}/terminal-signals.ts +0 -0
@@ -21,8 +21,8 @@ const SHELLS: readonly CompletionShell[] = ['bash', 'zsh', 'fish']
21
21
  /**
22
22
  * `--help` and every flag the verb accepts. Not every `--flag` its help cut
23
23
  * mentions: the cut's prose names other verbs' flags, and `vx watch`
24
- * completed only `--dry --graph --summarize --profile`, the four its line
25
- * says it refuses, while `vx show` offered `--run` and `--list`.
24
+ * completed only the flags its help line says it refuses, while `vx show`
25
+ * offered `--run` and `--list`.
26
26
  */
27
27
  export function verbFlags(verb: string): string[] {
28
28
  return [...new Set(['--help', ...acceptedFlags(verb)])]
@@ -22,8 +22,7 @@ interface ForeignFlag {
22
22
  /**
23
23
  * The runner reads `=true` / `=false` on it (Turbo's `--force [FORCE]`,
24
24
  * Nx's yargs booleans): `=false` is the flag left out, `=true` the flag
25
- * bare. `--skip-nx-cache=false` forced every task, `--summarize=true`
26
- * wrote the summary to a file named `true`.
25
+ * bare: `--skip-nx-cache=false` forced every task.
27
26
  */
28
27
  bool?: true
29
28
  /** For an alias: the vx argv. For a refusal: none. */
@@ -47,8 +46,6 @@ const nxPattern = (p: string): string => {
47
46
  return p
48
47
  }
49
48
 
50
- const GRAPH_IMAGE = /\.(svg|png|jpe?g|pdf|json|html|mermaid)$/i
51
-
52
49
  export const FOREIGN_FLAGS: readonly ForeignFlag[] = [
53
50
  {
54
51
  runner: 'turbo',
@@ -98,21 +95,12 @@ export const FOREIGN_FLAGS: readonly ForeignFlag[] = [
98
95
  vx: '`--dry[=text|json]`',
99
96
  to: (v) => [v === undefined ? '--dry' : `--dry=${v}`],
100
97
  },
101
- {
102
- runner: 'turbo',
103
- names: ['--graph'],
104
- value: true,
105
- when: (v) => v !== undefined && GRAPH_IMAGE.test(v),
106
- shown: '`--graph=<file>.svg|png|json|html|…`',
107
- outcome: 'refuse',
108
- vx: 'vx writes Graphviz DOT only: `--graph=<file>.dot`, then `dot -Tsvg`',
109
- },
110
98
  {
111
99
  runner: 'turbo',
112
100
  names: ['--graph'],
113
101
  value: false,
114
- outcome: 'same',
115
- vx: '`--graph[=<file>.dot]`',
102
+ outcome: 'refuse',
103
+ vx: '`--dry=json` prints the planned task graph, each task with its deps',
116
104
  },
117
105
  {
118
106
  runner: 'turbo',
@@ -133,9 +121,8 @@ export const FOREIGN_FLAGS: readonly ForeignFlag[] = [
133
121
  runner: 'turbo',
134
122
  names: ['--summarize'],
135
123
  value: false,
136
- bool: true,
137
- outcome: 'same',
138
- vx: '`--summarize[=<path>]`',
124
+ outcome: 'refuse',
125
+ vx: "use `vx last --format json` for the run's JSON record",
139
126
  },
140
127
  {
141
128
  runner: 'turbo',
@@ -263,7 +250,8 @@ export const FOREIGN_FLAGS: readonly ForeignFlag[] = [
263
250
  runner: 'nx',
264
251
  names: ['--parallel'],
265
252
  value: true,
266
- when: (v) => v !== undefined && v !== 'true',
253
+ // Nx's value is a count or `false`; a word after it is a task name.
254
+ when: (v) => v === 'false' || (v !== undefined && /^\d+$/.test(v)),
267
255
  shown: '`--parallel <n>`',
268
256
  outcome: 'alias',
269
257
  vx: '`--concurrency <n>` (`--parallel=false` is 1)',
@@ -355,21 +343,21 @@ export const FOREIGN_FLAGS: readonly ForeignFlag[] = [
355
343
  names: ['--global-deps'],
356
344
  value: true,
357
345
  outcome: 'refuse',
358
- vx: "declare them in `cache.inputs.workspaceFiles` (under `turbo()`, turbo.json's `globalDependencies`)",
346
+ vx: "declare them in `inputs.workspaceFiles` (under `turbo()`, turbo.json's `globalDependencies`)",
359
347
  },
360
348
  {
361
349
  runner: 'turbo',
362
350
  names: ['--json'],
363
351
  value: false,
364
352
  outcome: 'refuse',
365
- vx: "use `--format json` for the run's result on stdout, `--dry=json` for the plan",
353
+ vx: "use `vx last --format json` for the run's result, `--dry=json` for the plan",
366
354
  },
367
355
  {
368
356
  runner: 'turbo',
369
357
  names: ['--log-file'],
370
358
  value: false,
371
359
  outcome: 'refuse',
372
- vx: "use `--summarize[=<path>]` for the run's JSON record",
360
+ vx: "use `vx last --format json` for the run's JSON record",
373
361
  },
374
362
  {
375
363
  runner: 'turbo',
@@ -664,7 +652,7 @@ export const FOREIGN_VERBS: Readonly<Record<string, string>> = {
664
652
  '`nx run-many` is `vx run <task> --all` here; -t, -p, --exclude and --parallel work as they are',
665
653
  affected:
666
654
  '`nx affected` is `vx run <task> --affected` here; -t, --base and --exclude work as they are',
667
- graph: '`nx graph` is `vx run <task> --graph[=<file>.dot]` here: the task graph as Graphviz DOT',
655
+ graph: '`nx graph` is `vx run <task> --dry=json` here: the planned task graph as JSON',
668
656
  ls: '`turbo ls` is `vx show` here: every project, its directory and its task count',
669
657
  query:
670
658
  '`turbo query` has no vx form: `vx run <task> --dry=json` prints the planned graph as JSON',
package/src/cli/help.ts CHANGED
@@ -171,12 +171,6 @@ export function helpText(pluginCommands: readonly string[] = []): string {
171
171
  " --timeout <duration> A task's time limit (30s, 10m), for tasks",
172
172
  ' without their own exec.timeout. A task past it',
173
173
  ' is killed and fails.',
174
- ' --kill-grace <duration> How long a task that ignores SIGTERM gets',
175
- ' before SIGKILL (default 2s).',
176
- " --teardown-timeout <duration> The bound on each plugin's end-of-run flush",
177
- ' (default 3s).',
178
- ' --ready-notice <duration> When vx says a persistent task still waits for',
179
- ' readyWhen (default 10s).',
180
174
  ' --continue[=<mode>] What a failed task takes down: never | deps-ok',
181
175
  ' (default, skips its dependents) | always (bare',
182
176
  ' form; dependents run, never save).',
@@ -190,18 +184,9 @@ export function helpText(pluginCommands: readonly string[] = []): string {
190
184
  'Planning (for run — skips execution):',
191
185
  " --dry[=text|json] Print the task graph and each task's predicted hit",
192
186
  ' or miss.',
193
- ' --graph[=<path>] Emit Graphviz DOT (stdout if no path).',
194
187
  '',
195
188
  'Artifacts (for run):',
196
- ' --format <fmt> pretty (default) | json: print the --summarize',
197
- ' document on stdout when the run ends; every other',
198
- ' line goes to stderr.',
199
- ' --summarize[=<path>] Write per-run JSON to <cacheDir>/runs/<run_id>.json.',
200
189
  ' --profile[=<path>] Write Chrome-trace JSON (default profile.json).',
201
- ' --report[=markdown] Print a markdown run report to stdout after the run.',
202
- ' --report-file <path> Append that report to <path> ($GITHUB_STEP_SUMMARY).',
203
- ' --tag <k=v> Label this invocation (repeatable); recorded on its',
204
- ' history row.',
205
190
  '',
206
191
  'Extensions (plugins):',
207
192
  ' vx core runs tasks in-process and nothing else. A dashboard, remote',
@@ -223,12 +208,11 @@ export function helpText(pluginCommands: readonly string[] = []): string {
223
208
  'Watch mode:',
224
209
  ' vx watch <task> Initial run, then re-run on filesystem changes.',
225
210
  ' Uses the same flags as `vx run` except --dry /',
226
- ' --graph / --summarize / --profile / --report /',
227
- ' --report-file / --verbosity (they plan or report',
228
- ' one run), plus --poll (watch.poll: poll instead of',
229
- ' the OS watcher). Press Ctrl+C to stop. Cache means most',
230
- ' re-runs are near-zero cost (cache hits restore',
231
- ' outputs + replay logs).',
211
+ ' --profile / --verbosity (they plan or report one',
212
+ ' run), plus --poll (watch.poll: poll instead of',
213
+ ' the OS watcher). Press Ctrl+C to stop.',
214
+ ' Cache means most re-runs are near-zero cost',
215
+ ' (cache hits restore outputs + replay logs).',
232
216
  '',
233
217
  'Cache management:',
234
218
  ' vx cache clean --older-than 30d Evict entries last accessed > 30 days ago.',
@@ -402,16 +386,7 @@ export function documentedFlags(verb: string, text = helpText()): string[] {
402
386
  * The `vx run` flags `vx watch` refuses: each plans or reports ONE run
403
387
  * (`watchRefusal` in watch.ts, held to this list by a test).
404
388
  */
405
- export const WATCH_REFUSED_FLAGS: readonly string[] = [
406
- '--dry',
407
- '--graph',
408
- '--summarize',
409
- '--profile',
410
- '--report',
411
- '--report-file',
412
- '--verbosity',
413
- '--format',
414
- ]
389
+ export const WATCH_REFUSED_FLAGS: readonly string[] = ['--dry', '--profile', '--verbosity']
415
390
 
416
391
  /**
417
392
  * The flags one verb accepts: those on its own Usage line, and for a verb
package/src/cli/info.ts CHANGED
@@ -4,7 +4,8 @@
4
4
  // from the orchestrator's `collectInfo` (doctor.ts); this file parses the flags and renders the rows.
5
5
 
6
6
  import os from 'node:os'
7
- import { collectInfo, type FlakyTask, type InfoFacts } from '../orchestrator/index.js'
7
+ import { collectInfo, type InfoFacts } from '../orchestrator/index.js'
8
+ import { type FlakyTask } from '../history/index.js'
8
9
  import { flagHint, formatValue, refuse, refusedWord, seeHelp } from './help.js'
9
10
  import { namedCacheDir, parseCacheDirFlag, warnToStderr } from './workspace-config.js'
10
11
  import { formatBytes } from './format.js'
package/src/cli/init.ts CHANGED
@@ -13,7 +13,7 @@ import { gitTop, workspaceFileWritten } from '../handoff.js'
13
13
  import { flagHint, formatValue, refuse, seeHelp } from './help.js'
14
14
  import { lockAfterMigration } from './lock.js'
15
15
  import { PLUGIN_TEMPLATES } from './plugin-templates.js'
16
- import { isCompiledBinary } from './upgrade.js'
16
+ import { isCompiledBinary, targetVersion } from './upgrade.js'
17
17
  import { VERSION } from '../version.js'
18
18
  import { DOT_VX_GITATTRIBUTES, DOT_VX_GITIGNORE, ui, UserError, sayNotice } from '../util/index.js'
19
19
  import {
@@ -217,8 +217,11 @@ async function starter(root: string, noGit: boolean, args: InitArgs): Promise<nu
217
217
  }
218
218
 
219
219
  /**
220
- * A repo with a package.json is `@vzn/vx-migrate`'s: the installed one,
221
- * else this version's through `bun x`. It finds the root itself, asks a
220
+ * A repo with a package.json is `@vzn/vx-migrate`'s, at the latest
221
+ * release: it installs @vzn/vx and every @vzn/vx-* plugin the root lists
222
+ * at its own version, so init always lands on latest with all of them
223
+ * together, as `vx upgrade` moves them (M, 2026-10-16: an installed 0.0.637
224
+ * pinned core back from 0.0.639). It finds the root itself, asks a
222
225
  * terminal native or keep in a Turbo or Nx repo, and installs what it writes.
223
226
  */
224
227
  async function migrate(dirs: readonly string[], args: InitArgs): Promise<number> {
@@ -229,21 +232,30 @@ async function migrate(dirs: readonly string[], args: InitArgs): Promise<number>
229
232
  args.mode !== undefined && `--${args.mode}`,
230
233
  args.json === true && '--format=json',
231
234
  ].filter((f): f is string => typeof f === 'string')
232
- return Bun.spawn(migrateCommand(dirs, flags), {
235
+ // An exact version: `bun x` may answer `@latest` from its cache.
236
+ const latest = VERSION === '0.0.0' ? 'latest' : await targetVersion(undefined, 'vx init')
237
+ return Bun.spawn(migrateCommand(dirs, flags, latest), {
233
238
  stdio: ['inherit', 'inherit', 'inherit'],
234
239
  // A compiled vx is a Bun runtime only when told so.
235
240
  env: isCompiledBinary() ? { ...process.env, BUN_BE_BUN: '1' } : process.env,
236
241
  }).exited
237
242
  }
238
243
 
239
- export function migrateCommand(dirs: readonly string[], flags: readonly string[]): string[] {
240
- for (const d of dirs) {
241
- const installed = path.join(d, 'node_modules', '@vzn', 'vx-migrate', 'src', 'bin.ts')
242
- if (existsSync(installed)) return [process.execPath, '--no-install', installed, ...flags]
244
+ export function migrateCommand(
245
+ dirs: readonly string[],
246
+ flags: readonly string[],
247
+ latest: string,
248
+ running = VERSION,
249
+ ): string[] {
250
+ // A source checkout (0.0.0, which npm never had) runs the vx-migrate
251
+ // beside it; a release never runs an installed one.
252
+ if (running === '0.0.0') {
253
+ for (const d of dirs) {
254
+ const installed = path.join(d, 'node_modules', '@vzn', 'vx-migrate', 'src', 'bin.ts')
255
+ if (existsSync(installed)) return [process.execPath, '--no-install', installed, ...flags]
256
+ }
243
257
  }
244
- // A source checkout is 0.0.0, which npm never had.
245
- const version = VERSION === '0.0.0' ? 'latest' : VERSION
246
258
  // Bun's own install output ("Slow filesystem detected…") in a pnpm or npm
247
259
  // repo reads as a stranger speaking (Growth's first-run funnel F2).
248
- return [process.execPath, 'x', '--silent', `@vzn/vx-migrate@${version}`, ...flags]
260
+ return [process.execPath, 'x', '--silent', `@vzn/vx-migrate@${latest}`, ...flags]
249
261
  }
package/src/cli/last.ts CHANGED
@@ -9,21 +9,25 @@ import { Cache } from '../cache/index.js'
9
9
  import { formatBytes } from './format.js'
10
10
  import { flagHint, formatValue, seeHelp } from './help.js'
11
11
  import {
12
- currentBranch,
13
- exitSignal,
14
12
  type FailureMode,
15
13
  getInvocation,
16
14
  getRun,
17
15
  type InvocationDetail,
18
16
  listInvocations,
19
17
  LocalHistoryProvider,
20
- outcomeWord,
21
18
  type RunSummaryRow,
19
+ } from '../history/index.js'
20
+ import {
21
+ currentBranch,
22
+ exitSignal,
22
23
  resolveRunId,
23
24
  runFailures,
25
+ type RunStep,
26
+ runSteps,
24
27
  shortRunId,
25
28
  taskLog,
26
29
  } from '../orchestrator/index.js'
30
+ import { outcomeWord } from '../report/index.js'
27
31
  import { formatElapsed, refusal, splitTaskId, UserError, sayNotice, ui } from '../util/index.js'
28
32
  import { findWorkspaceRoot } from '../workspace/index.js'
29
33
  import { cliCacheDir, parseCacheDirFlag } from './workspace-config.js'
@@ -319,12 +323,29 @@ function reasonParts(t: RunSummaryRow): string {
319
323
  } else if (t.status === 'skipped' && t.blockedBy !== null) {
320
324
  parts.push(`after ${t.blockedBy} failed`)
321
325
  }
326
+ if (t.remoteError !== null) parts.push(`remote \u2717 ${t.remoteError}`)
322
327
  return parts.map((p) => ` ${p}`).join('')
323
328
  }
324
329
 
325
330
  /** Hit rows shown before the rest fold — the slowest restores. */
326
331
  const HITS_SHOWN = 16
327
332
 
333
+ /**
334
+ * The steps the run did not wait on (`vx:prefetch`), which its own output
335
+ * never showed (M, 2026-10-16): here for debugging, marked `internal`.
336
+ */
337
+ function formatStepRows(steps: readonly RunStep[]): string[] {
338
+ if (steps.length === 0) return []
339
+ const idW = Math.max(...steps.map((s) => s.id.length), 4)
340
+ return [
341
+ '',
342
+ ...steps.map(
343
+ (s) =>
344
+ ` ${(s.ok ? 'success' : 'failed').padEnd(17)} ${s.id.padEnd(idW)} ${fmtMs(s.durationMs).padStart(8)} internal`,
345
+ ),
346
+ ]
347
+ }
348
+
328
349
  /**
329
350
  * The per-task rows: failures first, then what executed or was skipped,
330
351
  * then cache hits. Hits are the noise of a warm run — 996 of a thousand
@@ -333,6 +354,7 @@ const HITS_SHOWN = 16
333
354
  * count; the ones shown are the slowest restores, the one thing a hit's
334
355
  * row tells. `--format json` lists every row.
335
356
  */
357
+
336
358
  export function formatTaskRows(tasks: readonly RunSummaryRow[]): string[] {
337
359
  if (tasks.length === 0) return []
338
360
  const failed = tasks.filter((t) => t.status === 'failed')
@@ -542,7 +564,10 @@ export async function lastCmd(args: readonly string[]): Promise<number> {
542
564
  ...(f.fixedIn !== undefined ? { fixedIn: f.fixedIn } : {}),
543
565
  }
544
566
  })
545
- process.stdout.write(`${JSON.stringify({ invocation: inv, tasks })}\n`)
567
+ const steps = runSteps(cacheDir, inv.runId)
568
+ process.stdout.write(
569
+ `${JSON.stringify({ invocation: inv, tasks, ...(steps.length > 0 ? { steps } : {}) })}\n`,
570
+ )
546
571
  return 0
547
572
  }
548
573
 
@@ -560,6 +585,7 @@ export async function lastCmd(args: readonly string[]): Promise<number> {
560
585
  `${inv.failedCount > 0 ? ` · ${inv.failedCount} failed` : ''}`,
561
586
  )
562
587
  lines.push(...formatTaskRows(detail?.tasks ?? []))
588
+ lines.push(...formatStepRows(runSteps(cacheDir, inv.runId)))
563
589
  // The next command after reading a failed run, with the arguments the
564
590
  // run forwarded to the tasks that got them (a failure under `-- --shard
565
591
  // 2` is that shard's); a dependency never got them, so its line has none.
@@ -1,4 +1,4 @@
1
- // Formatters for `--dry-run` (human / JSON) and `--graph` (DOT).
1
+ // Formatters for `--dry` (human / JSON).
2
2
 
3
3
  import type { AffectedReason, CacheStatus, RunPlan } from '../orchestrator/index.js'
4
4
  import { formatElapsed, maskedCommand } from '../util/index.js'
@@ -9,7 +9,7 @@ import { formatElapsed, maskedCommand } from '../util/index.js'
9
9
  * task with recorded history also shows its typical duration (~p50).
10
10
  *
11
11
  * would run:
12
- * ◉ @vzn/vx#lint cache hit (local) abc12345
12
+ * ✓ @vzn/vx#lint cache hit (local) abc12345
13
13
  * ↓ @vzn/vx#test cache hit (remote) def67890
14
14
  * ▶ @vzn/vx#build cache miss — would exec fedcba98 ~1.20s
15
15
  *
@@ -33,7 +33,7 @@ export function formatPlanText(plan: RunPlan): string {
33
33
  for (const t of real) {
34
34
  // `@noop`: the run succeeds it without running, whatever its key says.
35
35
  const skipped = t.executor === 'noop'
36
- const sym = skipped ? '∅' : symbolFor(t.cacheStatus)
36
+ const sym = skipped ? '·' : symbolFor(t.cacheStatus)
37
37
  const desc = skipped ? 'noop — would not run' : describe(t.cacheStatus)
38
38
  const shortHash = t.hash ? t.hash.slice(0, 8) : ''
39
39
  const executes = !skipped && (t.cacheStatus === 'miss' || t.cacheStatus === 'no-cache')
@@ -186,52 +186,16 @@ export function formatPlanJson(plan: RunPlan, affectedBase?: string): string {
186
186
  )
187
187
  }
188
188
 
189
- /**
190
- * Render the task graph as a Graphviz DOT document. Includes group
191
- * nodes — they're real nodes in the graph (just not real units of
192
- * work), and a `--graph` consumer wants to see the whole structure.
193
- *
194
- * Layout: `rankdir=LR`. Node fillcolor varies by predicted cache
195
- * status so a quick visual scan tells you which boxes get to skip
196
- * actual work.
197
- */
198
- export function formatGraphDot(plan: RunPlan): string {
199
- const lines: string[] = ['digraph TaskGraph {', ' rankdir=LR;', ' node [shape=box];']
200
- for (const t of plan.tasks) {
201
- const color = dotColor(t.cacheStatus)
202
- lines.push(
203
- ` ${dotString(t.node.id)} [label=${dotString(`${t.node.id}\n${t.hash.slice(0, 8)}`)}, style="filled", fillcolor="${color}"];`,
204
- )
205
- }
206
- for (const t of plan.tasks) {
207
- for (const dep of t.deps) {
208
- lines.push(` ${dotString(dep)} -> ${dotString(t.node.id)};`)
209
- }
210
- }
211
- lines.push('}')
212
- return lines.join('\n') + '\n'
213
- }
214
-
215
- /**
216
- * A DOT double-quoted ID. A task name is any object key in a config, so
217
- * `"`, `\` and a newline are all reachable; unescaped they end the
218
- * string early and the document stops parsing. `\n` is DOT's own line
219
- * break inside a label.
220
- */
221
- function dotString(s: string): string {
222
- return `"${s.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\r?\n/g, '\\n')}"`
223
- }
224
-
225
189
  function symbolFor(s: CacheStatus): string {
226
190
  switch (s) {
227
191
  case 'hit-local':
228
- return '◉'
192
+ return '✓'
229
193
  case 'hit-remote':
230
194
  return '↓'
231
195
  case 'miss':
232
196
  return '▶'
233
197
  case 'no-cache':
234
- return '·'
198
+ return '∅'
235
199
  case 'group':
236
200
  return '○'
237
201
  case 'not-started':
@@ -255,20 +219,3 @@ function describe(s: CacheStatus): string {
255
219
  return 'not started (unneeded)'
256
220
  }
257
221
  }
258
-
259
- function dotColor(s: CacheStatus): string {
260
- switch (s) {
261
- case 'hit-local':
262
- return '#bbf7d0' // green-200
263
- case 'hit-remote':
264
- return '#bae6fd' // sky-200
265
- case 'miss':
266
- return '#fed7aa' // orange-200
267
- case 'no-cache':
268
- return '#e5e7eb' // gray-200
269
- case 'group':
270
- return '#f5d0fe' // fuchsia-200
271
- case 'not-started':
272
- return '#ffffff'
273
- }
274
- }
@@ -7,7 +7,7 @@
7
7
  // reports an unknown command as it always did. Core's verbs are matched
8
8
  // before this is asked, so a plugin cannot shadow `run`.
9
9
 
10
- import type { CachePolicy, PluginCache, PluginCacheRemote } from '../cache/index.js'
10
+ import type { CachePolicy, PluginCache, ResolvedRemote } from '../cache/index.js'
11
11
  import type { WorkspaceConfig } from '../config.js'
12
12
  import type { VxPlugin, PluginCommand, CommandContext } from '../orchestrator/index.js'
13
13
  import { cacheScopeEnvError, findWorkspaceRoot, isCacheScope } from '../workspace/index.js'
@@ -151,7 +151,7 @@ async function verbCache(
151
151
  'open',
152
152
  await ws.storeRoot,
153
153
  )
154
- const remotes: PluginCacheRemote[] = []
154
+ const remotes: ResolvedRemote[] = []
155
155
  await resolveCache(ws.plugins, {
156
156
  workspaceRoot: ws.workspaceRoot,
157
157
  cacheDir: ws.cacheDir,
@@ -11,7 +11,7 @@ export const PLUGIN_TEMPLATES: Readonly<Record<string, { plugin: string; test: s
11
11
  cache: {
12
12
  plugin:
13
13
  "// `cache`: where artifacts live. A directory stands in for a remote store;\n// core puts it behind the local cache, so a remote error is a miss and a\n// warning, never a failed run.\nimport { link, mkdir, unlink } from 'node:fs/promises'\nimport path from 'node:path'\nimport { definePlugin, type RemoteCacheLayer, type VxPlugin } from '@vzn/vx'\n\nclass DirRemote implements RemoteCacheLayer {\n constructor(readonly endpoint: string) {}\n async has(hash: string): Promise<boolean> {\n return Bun.file(path.join(this.endpoint, hash)).exists()\n }\n async get(hash: string) {\n const file = Bun.file(path.join(this.endpoint, hash))\n return (await file.exists()) ? { body: file, durationMs: undefined } : null\n }\n async put(hash: string, body: Blob): Promise<void> {\n await mkdir(this.endpoint, { recursive: true })\n // Written aside, then linked: on a shared directory a rename can swap\n // the file under a reader, while a link never replaces an existing one.\n // EEXIST is success: the same hash means the same bytes.\n const tmp = path.join(this.endpoint, `.${hash}.${process.pid}`)\n await Bun.write(tmp, body)\n try {\n await link(tmp, path.join(this.endpoint, hash))\n } catch (err) {\n if ((err as NodeJS.ErrnoException).code !== 'EEXIST') throw err\n } finally {\n await unlink(tmp)\n }\n }\n}\n\nexport function dirCache(dir: string): VxPlugin {\n return definePlugin(import.meta, {\n cache() {\n return new DirRemote(dir)\n },\n })\n}\n",
14
- test: "// A test for the example `cache` plugin beside it: a throwaway workspace\n// declares the plugin and vx's own run() drives it. `bun test` runs it.\nimport { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'\nimport os from 'node:os'\nimport path from 'node:path'\nimport { afterEach, beforeEach, expect, it } from 'bun:test'\nimport { run, type Logger } from '@vzn/vx'\n\nlet root: string\nbeforeEach(async () => {\n root = await mkdtemp(path.join(os.tmpdir(), 'vx-plugin-cache-'))\n await writeFile(path.join(root, 'package.json'), JSON.stringify({ name: 'ws', private: true }))\n await writeFile(path.join(root, 'pnpm-workspace.yaml'), 'packages:\\n - \"packages/*\"\\n')\n Bun.spawnSync({ cmd: ['git', 'init', '-q'], cwd: root })\n})\nafterEach(async () => {\n await rm(root, { recursive: true, force: true })\n})\n\n/** A package `name` whose `vx.config.mjs` is `config`. */\nasync function pkg(\n name: string,\n config: string,\n scripts?: Record<string, string>,\n): Promise<string> {\n const dir = path.join(root, 'packages', name)\n await mkdir(dir, { recursive: true })\n await writeFile(\n path.join(dir, 'package.json'),\n JSON.stringify({ name, version: '1.0.0', ...(scripts ? { scripts } : {}) }),\n )\n // Core reads no package.json: the config names the project.\n await writeFile(\n path.join(dir, 'vx.config.mjs'),\n config.replace('export default {', `export default { name: ${JSON.stringify(name)},`),\n )\n return dir\n}\n\n/**\n * A `.vx/workspace.mjs` declaring `call`, e.g. `dirCache()`, from the plugin file\n * beside this test, then `vx lock`: a run reads project configs only from the lock.\n */\nasync function workspace(call: string): Promise<void> {\n const from = JSON.stringify(path.join(import.meta.dir, 'cache.ts'))\n await Bun.write(\n path.join(root, '.vx/workspace.mjs'),\n `import { dirCache } from ${from}\\nexport default { plugins: [${call}] }\\n`,\n )\n const vx = path.join(path.dirname(new URL(import.meta.resolve('@vzn/vx')).pathname), 'bin.ts')\n const lock = Bun.spawnSync({ cmd: [process.execPath, vx, 'lock'], cwd: root })\n expect([lock.exitCode, lock.stderr.toString()]).toEqual([0, ''])\n}\n\nfunction log(order: string[] = []): Logger {\n return {\n status() {},\n taskStart(node) {\n order.push(`start ${node.id}`)\n },\n taskStdout() {},\n taskStderr() {},\n taskComplete(node) {\n order.push(`end ${node.id}`)\n },\n }\n}\n\nit('a run on an empty local cache restores from the directory', async () => {\n const dir = await pkg(\n 'a',\n `export default { tasks: { build: {\n exec: { command: 'mkdir -p dist && echo built > dist/out.txt' },\n cache: { inputs: { files: ['package.json'] }, outputs: { files: ['dist/**'] } },\n } } }`,\n )\n await workspace(`dirCache(${JSON.stringify(path.join(root, 'remote'))})`)\n const first = await run({ cwd: root, tasks: ['build'], log: log(), handleSignals: false })\n expect(first.outcomes.map((o) => o.status)).toEqual(['success'])\n await rm(path.join(root, '.vx', 'cache'), { recursive: true, force: true })\n await rm(path.join(dir, 'dist'), { recursive: true, force: true })\n const second = await run({ cwd: root, tasks: ['build'], log: log(), handleSignals: false })\n expect(second.outcomes.map((o) => o.status)).toEqual(['cache-hit-remote'])\n expect(await Bun.file(path.join(dir, 'dist', 'out.txt')).text()).toBe('built\\n')\n}, 20_000)\n",
14
+ test: "// A test for the example `cache` plugin beside it: a throwaway workspace\n// declares the plugin and vx's own run() drives it. `bun test` runs it.\nimport { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'\nimport os from 'node:os'\nimport path from 'node:path'\nimport { afterEach, beforeEach, expect, it } from 'bun:test'\nimport { run, type Logger } from '@vzn/vx'\n\nlet root: string\nbeforeEach(async () => {\n root = await mkdtemp(path.join(os.tmpdir(), 'vx-plugin-cache-'))\n await writeFile(path.join(root, 'package.json'), JSON.stringify({ name: 'ws', private: true }))\n await writeFile(path.join(root, 'pnpm-workspace.yaml'), 'packages:\\n - \"packages/*\"\\n')\n Bun.spawnSync({ cmd: ['git', 'init', '-q'], cwd: root })\n})\nafterEach(async () => {\n await rm(root, { recursive: true, force: true })\n})\n\n/** A package `name` whose `vx.config.mjs` is `config`. */\nasync function pkg(\n name: string,\n config: string,\n scripts?: Record<string, string>,\n): Promise<string> {\n const dir = path.join(root, 'packages', name)\n await mkdir(dir, { recursive: true })\n await writeFile(\n path.join(dir, 'package.json'),\n JSON.stringify({ name, version: '1.0.0', ...(scripts ? { scripts } : {}) }),\n )\n // Core reads no package.json: the config names the project.\n await writeFile(\n path.join(dir, 'vx.config.mjs'),\n config.replace('export default {', `export default { name: ${JSON.stringify(name)},`),\n )\n return dir\n}\n\n/**\n * A `.vx/workspace.mjs` declaring `call`, e.g. `dirCache()`, from the plugin file\n * beside this test, then `vx lock`: a run reads project configs only from the lock.\n */\nasync function workspace(call: string): Promise<void> {\n const from = JSON.stringify(path.join(import.meta.dir, 'cache.ts'))\n await Bun.write(\n path.join(root, '.vx/workspace.mjs'),\n `import { dirCache } from ${from}\\nexport default { plugins: [${call}] }\\n`,\n )\n const vx = path.join(path.dirname(new URL(import.meta.resolve('@vzn/vx')).pathname), 'bin.ts')\n const lock = Bun.spawnSync({ cmd: [process.execPath, vx, 'lock'], cwd: root })\n expect([lock.exitCode, lock.stderr.toString()]).toEqual([0, ''])\n}\n\nfunction log(order: string[] = []): Logger {\n return {\n status() {},\n taskStart(node) {\n order.push(`start ${node.id}`)\n },\n taskStdout() {},\n taskStderr() {},\n taskComplete(node) {\n order.push(`end ${node.id}`)\n },\n }\n}\n\nit('a run on an empty local cache restores from the directory', async () => {\n const dir = await pkg(\n 'a',\n `export default { tasks: { build: {\n exec: { command: 'mkdir -p dist && echo built > dist/out.txt' },\n inputs: { files: ['package.json'] }, cache: { outputs: { files: ['dist/**'] } },\n } } }`,\n )\n await workspace(`dirCache(${JSON.stringify(path.join(root, 'remote'))})`)\n const first = await run({ cwd: root, tasks: ['build'], log: log(), handleSignals: false })\n expect(first.outcomes.map((o) => o.status)).toEqual(['success'])\n await rm(path.join(root, '.vx', 'cache'), { recursive: true, force: true })\n await rm(path.join(dir, 'dist'), { recursive: true, force: true })\n const second = await run({ cwd: root, tasks: ['build'], log: log(), handleSignals: false })\n expect(second.outcomes.map((o) => o.status)).toEqual(['cache-hit-remote'])\n expect(await Bun.file(path.join(dir, 'dist', 'out.txt')).text()).toBe('built\\n')\n}, 20_000)\n",
15
15
  },
16
16
  commands: {
17
17
  plugin:
@@ -31,7 +31,7 @@ export const PLUGIN_TEMPLATES: Readonly<Record<string, { plugin: string; test: s
31
31
  key: {
32
32
  plugin:
33
33
  "// `key`: extra cache-key material. The value of each named environment\n// variable is folded into every cached task's key and named in `vx why`,\n// so a change to it is a miss. An unset variable folds as ''.\nimport { definePlugin, type VxPlugin } from '@vzn/vx'\n\nexport function envKey(names: readonly string[]): VxPlugin {\n return definePlugin(import.meta, {\n key() {\n return Object.fromEntries(names.map((n) => [n, process.env[n] ?? '']))\n },\n })\n}\n",
34
- test: "// A test for the example `key` plugin beside it: a throwaway workspace\n// declares the plugin and vx's own run() drives it. `bun test` runs it.\nimport { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'\nimport os from 'node:os'\nimport path from 'node:path'\nimport { afterEach, beforeEach, expect, it } from 'bun:test'\nimport { planRun, type Logger } from '@vzn/vx'\n\nlet root: string\nbeforeEach(async () => {\n root = await mkdtemp(path.join(os.tmpdir(), 'vx-plugin-key-'))\n await writeFile(path.join(root, 'package.json'), JSON.stringify({ name: 'ws', private: true }))\n await writeFile(path.join(root, 'pnpm-workspace.yaml'), 'packages:\\n - \"packages/*\"\\n')\n Bun.spawnSync({ cmd: ['git', 'init', '-q'], cwd: root })\n})\nafterEach(async () => {\n await rm(root, { recursive: true, force: true })\n})\n\n/** A package `name` whose `vx.config.mjs` is `config`. */\nasync function pkg(\n name: string,\n config: string,\n scripts?: Record<string, string>,\n): Promise<string> {\n const dir = path.join(root, 'packages', name)\n await mkdir(dir, { recursive: true })\n await writeFile(\n path.join(dir, 'package.json'),\n JSON.stringify({ name, version: '1.0.0', ...(scripts ? { scripts } : {}) }),\n )\n // Core reads no package.json: the config names the project.\n await writeFile(\n path.join(dir, 'vx.config.mjs'),\n config.replace('export default {', `export default { name: ${JSON.stringify(name)},`),\n )\n return dir\n}\n\n/**\n * A `.vx/workspace.mjs` declaring `call`, e.g. `envKey()`, from the plugin file\n * beside this test, then `vx lock`: a run reads project configs only from the lock.\n */\nasync function workspace(call: string): Promise<void> {\n const from = JSON.stringify(path.join(import.meta.dir, 'key.ts'))\n await Bun.write(\n path.join(root, '.vx/workspace.mjs'),\n `import { envKey } from ${from}\\nexport default { plugins: [${call}] }\\n`,\n )\n const vx = path.join(path.dirname(new URL(import.meta.resolve('@vzn/vx')).pathname), 'bin.ts')\n const lock = Bun.spawnSync({ cmd: [process.execPath, vx, 'lock'], cwd: root })\n expect([lock.exitCode, lock.stderr.toString()]).toEqual([0, ''])\n}\n\nfunction log(order: string[] = []): Logger {\n return {\n status() {},\n taskStart(node) {\n order.push(`start ${node.id}`)\n },\n taskStdout() {},\n taskStderr() {},\n taskComplete(node) {\n order.push(`end ${node.id}`)\n },\n }\n}\n\nit(\"the variable's value moves the key; the same value keeps it\", async () => {\n await pkg(\n 'a',\n `export default { tasks: { build: {\n exec: { command: 'true' },\n cache: { inputs: { files: ['package.json'] }, outputs: { files: [] } },\n } } }`,\n )\n await workspace(`envKey(['EXAMPLE_FLAG'])`)\n const hash = async (value: string): Promise<string | undefined> => {\n process.env['EXAMPLE_FLAG'] = value\n try {\n const plan = await planRun({ cwd: root, tasks: ['build'], log: log() })\n return plan.tasks[0]!.hash\n } finally {\n delete process.env['EXAMPLE_FLAG']\n }\n }\n const one = await hash('1')\n expect(one).toBeDefined()\n expect(await hash('1')).toBe(one)\n expect(await hash('2')).not.toBe(one)\n}, 20_000)\n",
34
+ test: "// A test for the example `key` plugin beside it: a throwaway workspace\n// declares the plugin and vx's own run() drives it. `bun test` runs it.\nimport { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'\nimport os from 'node:os'\nimport path from 'node:path'\nimport { afterEach, beforeEach, expect, it } from 'bun:test'\nimport { planRun, type Logger } from '@vzn/vx'\n\nlet root: string\nbeforeEach(async () => {\n root = await mkdtemp(path.join(os.tmpdir(), 'vx-plugin-key-'))\n await writeFile(path.join(root, 'package.json'), JSON.stringify({ name: 'ws', private: true }))\n await writeFile(path.join(root, 'pnpm-workspace.yaml'), 'packages:\\n - \"packages/*\"\\n')\n Bun.spawnSync({ cmd: ['git', 'init', '-q'], cwd: root })\n})\nafterEach(async () => {\n await rm(root, { recursive: true, force: true })\n})\n\n/** A package `name` whose `vx.config.mjs` is `config`. */\nasync function pkg(\n name: string,\n config: string,\n scripts?: Record<string, string>,\n): Promise<string> {\n const dir = path.join(root, 'packages', name)\n await mkdir(dir, { recursive: true })\n await writeFile(\n path.join(dir, 'package.json'),\n JSON.stringify({ name, version: '1.0.0', ...(scripts ? { scripts } : {}) }),\n )\n // Core reads no package.json: the config names the project.\n await writeFile(\n path.join(dir, 'vx.config.mjs'),\n config.replace('export default {', `export default { name: ${JSON.stringify(name)},`),\n )\n return dir\n}\n\n/**\n * A `.vx/workspace.mjs` declaring `call`, e.g. `envKey()`, from the plugin file\n * beside this test, then `vx lock`: a run reads project configs only from the lock.\n */\nasync function workspace(call: string): Promise<void> {\n const from = JSON.stringify(path.join(import.meta.dir, 'key.ts'))\n await Bun.write(\n path.join(root, '.vx/workspace.mjs'),\n `import { envKey } from ${from}\\nexport default { plugins: [${call}] }\\n`,\n )\n const vx = path.join(path.dirname(new URL(import.meta.resolve('@vzn/vx')).pathname), 'bin.ts')\n const lock = Bun.spawnSync({ cmd: [process.execPath, vx, 'lock'], cwd: root })\n expect([lock.exitCode, lock.stderr.toString()]).toEqual([0, ''])\n}\n\nfunction log(order: string[] = []): Logger {\n return {\n status() {},\n taskStart(node) {\n order.push(`start ${node.id}`)\n },\n taskStdout() {},\n taskStderr() {},\n taskComplete(node) {\n order.push(`end ${node.id}`)\n },\n }\n}\n\nit(\"the variable's value moves the key; the same value keeps it\", async () => {\n await pkg(\n 'a',\n `export default { tasks: { build: {\n exec: { command: 'true' },\n inputs: { files: ['package.json'] }, cache: { outputs: { files: [] } },\n } } }`,\n )\n await workspace(`envKey(['EXAMPLE_FLAG'])`)\n const hash = async (value: string): Promise<string | undefined> => {\n process.env['EXAMPLE_FLAG'] = value\n try {\n const plan = await planRun({ cwd: root, tasks: ['build'], log: log() })\n return plan.tasks[0]!.hash\n } finally {\n delete process.env['EXAMPLE_FLAG']\n }\n }\n const one = await hash('1')\n expect(one).toBeDefined()\n expect(await hash('1')).toBe(one)\n expect(await hash('2')).not.toBe(one)\n}, 20_000)\n",
35
35
  },
36
36
  project: {
37
37
  plugin: