@vzn/vx 0.0.639 → 0.0.641

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 (88) hide show
  1. package/README.md +1 -1
  2. package/docs/caching.md +158 -106
  3. package/docs/cli.md +80 -45
  4. package/docs/execution.md +11 -10
  5. package/docs/features.md +9 -8
  6. package/docs/patterns.md +1 -1
  7. package/docs/schema.md +252 -190
  8. package/docs/security.md +5 -4
  9. package/launcher.sh +9 -2
  10. package/package.json +5 -5
  11. package/schemas/show.json +14 -14
  12. package/skills/vx/SKILL.md +4 -3
  13. package/src/bin.ts +8 -6
  14. package/src/cache/archive.ts +248 -41
  15. package/src/cache/cache.ts +46 -19
  16. package/src/cache/file-hashes.ts +7 -12
  17. package/src/cache/fs-clock.ts +62 -0
  18. package/src/cache/git-inputs.ts +2 -2
  19. package/src/cache/index.ts +6 -0
  20. package/src/cache/inputs.ts +228 -111
  21. package/src/cache/key-fold.ts +23 -3
  22. package/src/cache/layer.ts +15 -8
  23. package/src/cache/layered-cache.ts +8 -7
  24. package/src/cache/linked-inputs.ts +87 -0
  25. package/src/cache/scoped-cache.ts +8 -2
  26. package/src/cli/foreign-flags.ts +1 -1
  27. package/src/cli/help.ts +7 -1
  28. package/src/cli/init.ts +23 -11
  29. package/src/cli/last.ts +24 -1
  30. package/src/cli/plugin-templates.ts +2 -2
  31. package/src/cli/select.ts +16 -6
  32. package/src/cli/show.ts +9 -6
  33. package/src/cli/upgrade.ts +132 -21
  34. package/src/cli/watch-filter.ts +23 -12
  35. package/src/cli/watch-fs.ts +2 -1
  36. package/src/cli/watch-judge.ts +49 -5
  37. package/src/cli/watch-set.ts +18 -8
  38. package/src/cli/watch.ts +74 -2
  39. package/src/cli/why.ts +5 -3
  40. package/src/config.ts +19 -15
  41. package/src/exec/executor.ts +4 -4
  42. package/src/graph/dependency-spec.ts +2 -2
  43. package/src/graph/folds.ts +236 -0
  44. package/src/graph/index.ts +8 -1
  45. package/src/graph/scheduler.ts +41 -15
  46. package/src/graph/task-graph.ts +135 -73
  47. package/src/handoff.ts +5 -0
  48. package/src/index.ts +2 -2
  49. package/src/orchestrator/affected-tasks.ts +20 -19
  50. package/src/orchestrator/download-policy.ts +16 -22
  51. package/src/orchestrator/excluded-keys.ts +4 -5
  52. package/src/orchestrator/execute-task.ts +29 -25
  53. package/src/orchestrator/hit-restore.ts +101 -27
  54. package/src/orchestrator/index.ts +1 -0
  55. package/src/orchestrator/keyed-projects.ts +12 -51
  56. package/src/orchestrator/local-shortcircuit.ts +9 -1
  57. package/src/orchestrator/persistent-task.ts +3 -3
  58. package/src/orchestrator/placement.ts +2 -2
  59. package/src/orchestrator/plan.ts +1 -1
  60. package/src/orchestrator/plugin-host.ts +66 -27
  61. package/src/orchestrator/plugin.ts +12 -2
  62. package/src/orchestrator/prepare.ts +12 -5
  63. package/src/orchestrator/projects.ts +7 -10
  64. package/src/orchestrator/run-report.ts +145 -46
  65. package/src/orchestrator/run-steps.ts +57 -0
  66. package/src/orchestrator/run.ts +76 -44
  67. package/src/orchestrator/sandbox-fix.ts +1 -1
  68. package/src/orchestrator/sandbox-request.ts +2 -2
  69. package/src/orchestrator/stable-keys.ts +59 -26
  70. package/src/orchestrator/steps.ts +26 -6
  71. package/src/orchestrator/task-hash.ts +31 -9
  72. package/src/orchestrator/telemetry-host.ts +3 -3
  73. package/src/orchestrator/telemetry.ts +9 -4
  74. package/src/orchestrator/upstream.ts +4 -181
  75. package/src/report/framed-output.ts +1 -1
  76. package/src/report/logger.ts +5 -2
  77. package/src/report/status-line.ts +7 -3
  78. package/src/report/summary.ts +6 -4
  79. package/src/settings.ts +11 -17
  80. package/src/util/index.ts +3 -0
  81. package/src/util/paths.ts +39 -5
  82. package/src/workspace/affected.ts +81 -2
  83. package/src/workspace/config-eval.ts +2 -2
  84. package/src/workspace/config-imports.ts +1 -1
  85. package/src/workspace/config-schema.ts +142 -143
  86. package/src/workspace/lockfile.ts +1 -1
  87. package/src/workspace/migration.ts +19 -3
  88. package/src/workspace/project-loader.ts +5 -4
@@ -5,13 +5,14 @@
5
5
  // reference to `Cache` below is a type (the local handle a layer may wrap).
6
6
 
7
7
  import type { Cache } from './cache.js'
8
+ import { fsClockLag, stampGrainMs } from './fs-clock.js'
8
9
 
9
10
  export interface CacheKeyInput {
10
11
  taskId: string
11
12
  /**
12
13
  * Hash of the resolved task config (post-evaluation). Folds in everything
13
14
  * the user wrote — command, env declarations (passThrough names + define
14
- * key/value pairs), dependsOn, cache.inputs declarations, outputs — including
15
+ * key/value pairs), dependsOn, inputs declarations, outputs — including
15
16
  * values that arrived via `import` at config-load time.
16
17
  */
17
18
  taskConfigHash: string
@@ -22,12 +23,12 @@ export interface CacheKeyInput {
22
23
  */
23
24
  envValues: Array<[name: string, value: string | undefined]>
24
25
  /**
25
- * Resolved `cache.inputs.runtime` commands as [command, output] pairs
26
+ * Resolved `inputs.runtime` commands as [command, output] pairs
26
27
  * (output = trimmed stdout+stderr, resolved live at hash time). Folded
27
28
  * into the key in a namespace distinct from workspaceRuntimeValues.
28
29
  */
29
30
  runtimeValues?: Array<[command: string, output: string]>
30
- /** Resolved `cache.inputs.workspaceRuntime` pairs (root-cwd commands). */
31
+ /** Resolved `inputs.workspaceRuntime` pairs (root-cwd commands). */
31
32
  workspaceRuntimeValues?: Array<[command: string, output: string]>
32
33
  /** Absolute paths to input files. */
33
34
  inputFiles: string[]
@@ -51,7 +52,7 @@ export interface CacheKeyInput {
51
52
  * - It expands groups, which contribute a synthetic roll-up hash and no
52
53
  * outputs of their own, so a dependent of one would otherwise describe
53
54
  * an empty closure.
54
- * - It ignores `cache.inputs.tasks`. That filter says which upstream KEYS
55
+ * - It ignores `inputs.tasks`. That filter says which upstream KEYS
55
56
  * this task's key folds; what the task may READ is `dependsOn`, and
56
57
  * locally every dependency's outputs are on disk before the command runs
57
58
  * however the filter is written.
@@ -411,11 +412,11 @@ export const FILE_HASH_RACY_MS = 50
411
412
  * field (A-38). Pass the UNFLOORED stamp: a kernel's nanosecond stamp lands
412
413
  * on a whole second about once in 10^9, one set through a millisecond
413
414
  * `utimes` once in a thousand, and either is then trusted up to two seconds
414
- * later — never wrongly.
415
+ * later — never wrongly. And every window is widened by how far the file
416
+ * system's clock runs behind this process's (`fsClockLag`, fs-clock.ts).
415
417
  */
416
418
  export function racyWindowMs(stampMs: number, windowMs: number): number {
417
- if (stampMs % 2000 === 0) return windowMs + 2000
418
- return stampMs % 1000 === 0 ? windowMs + 1000 : windowMs
419
+ return windowMs + stampGrainMs(stampMs) + fsClockLag()
419
420
  }
420
421
 
421
422
  /**
@@ -676,7 +677,13 @@ export interface CacheLayer {
676
677
  * into the workspace root. Callers restoring entries that may carry
677
678
  * workspace outputs must pass `workspaceRoot`.
678
679
  */
679
- restoreOutputs(hash: string, projectDir: string, workspaceRoot?: string): Promise<void>
680
+ restoreOutputs(
681
+ hash: string,
682
+ projectDir: string,
683
+ workspaceRoot?: string,
684
+ /** The task's declared outputs: an entry outside them fails the restore, nothing written. */
685
+ outputs?: DeclaredOutputs,
686
+ ): Promise<void>
680
687
  save(args: {
681
688
  hash: string
682
689
  /**
@@ -21,6 +21,7 @@ import type { Cache } from './cache.js'
21
21
  import type {
22
22
  CacheEntry,
23
23
  CacheGetContext,
24
+ DeclaredOutputs,
24
25
  CacheKeyInput,
25
26
  CacheLayer,
26
27
  CacheStats,
@@ -61,11 +62,6 @@ export interface RemoteCacheLayer {
61
62
  * dropped before it is printed.
62
63
  */
63
64
  readonly endpoint?: string
64
- /**
65
- * What the wire's credential allows, when less than both (a read-only
66
- * token): core clears the run's remote axis it lacks. Omitted → both.
67
- */
68
- readonly access?: { readonly read?: boolean; readonly write?: boolean }
69
65
  /** Existence probe: `--dry` and `--force` ask it per key when the remote cannot batch. */
70
66
  has(hash: string): Promise<boolean>
71
67
  /**
@@ -330,8 +326,13 @@ export class LayeredCache implements CacheLayer {
330
326
  return this.local.hashFile(filePath)
331
327
  }
332
328
 
333
- async restoreOutputs(hash: string, projectDir: string, workspaceRoot?: string): Promise<void> {
334
- await this.local.restoreOutputs(hash, projectDir, workspaceRoot)
329
+ async restoreOutputs(
330
+ hash: string,
331
+ projectDir: string,
332
+ workspaceRoot?: string,
333
+ outputs?: DeclaredOutputs,
334
+ ): Promise<void> {
335
+ await this.local.restoreOutputs(hash, projectDir, workspaceRoot, outputs)
335
336
  }
336
337
 
337
338
  async save(args: SaveArgs): Promise<void> {
@@ -0,0 +1,87 @@
1
+ // A symlinked input keys the content behind it, as Nx reads a linked file
2
+ // where it points (owner, 2026-10-16: "Do like nx"). Its own fold stays the
3
+ // blob of its target STRING (file-hashes.ts), so a retarget to equal bytes
4
+ // moves the key; this is what follows it. The target string alone let an
5
+ // edit behind the link replay the old output (Growth, 2026-10-16), wherever
6
+ // the target lies: in the project, elsewhere in the workspace, outside it.
7
+ // A directory link keys every file beneath it, tracked or not, where Nx
8
+ // does not enter one: an edit there is an edit to what the task reads.
9
+ // `--affected` (workspace/affected.ts) and `vx watch` (cli/watch.ts) read
10
+ // the same targets.
11
+
12
+ import { readdirSync, realpathSync, statSync } from 'node:fs'
13
+ import path from 'node:path'
14
+ import { xxh3hex } from '../util/index.js'
15
+ import { absentOr } from './key-fold.js'
16
+
17
+ /** What a dangling link, or a loop of links, holds behind it: nothing to read. */
18
+ const NOTHING_BEHIND = ''
19
+
20
+ /**
21
+ * Directories a linked directory's walk never enters, as Nx's walker never
22
+ * does (its HARDCODED_IGNORE_PATTERNS): a link to a workspace would
23
+ * otherwise key the cache each run writes.
24
+ */
25
+ const SKIPPED = ['.git', 'node_modules', '.vx/cache', '.yarn/cache']
26
+ const skipped = (rel: string): boolean => SKIPPED.some((s) => rel === s || rel.endsWith(`/${s}`))
27
+
28
+ /**
29
+ * The files under `dir` a linked directory keys, relative and `/`-separated,
30
+ * sorted: every regular file and link at any depth. A link is listed, never
31
+ * entered (`behindLink` follows it), so a loop of directories ends.
32
+ */
33
+ export function filesBehind(dir: string): string[] {
34
+ const out: string[] = []
35
+ const walk = (abs: string, rel: string): void => {
36
+ let entries
37
+ try {
38
+ entries = readdirSync(abs, { withFileTypes: true })
39
+ } catch {
40
+ return
41
+ }
42
+ for (const e of entries) {
43
+ const childRel = rel === '' ? e.name : `${rel}/${e.name}`
44
+ if (e.isDirectory()) {
45
+ if (!skipped(childRel)) walk(path.join(abs, e.name), childRel)
46
+ } else if (e.isFile() || e.isSymbolicLink()) out.push(childRel)
47
+ }
48
+ }
49
+ walk(dir, '')
50
+ return out.sort()
51
+ }
52
+
53
+ /**
54
+ * The content behind the symlink at `link`, as one string: a file's identity
55
+ * (`hashFile` of its real path), a directory's files and theirs, or
56
+ * `NOTHING_BEHIND` for a dangling link or a loop. A special file (a FIFO, a
57
+ * device) is named, never read: a read can block. Never memoized: an upstream
58
+ * task may write behind the link between two keys.
59
+ */
60
+ export async function behindLink(
61
+ link: string,
62
+ hashFile: (file: string) => Promise<string>,
63
+ seen: ReadonlySet<string> = new Set(),
64
+ ): Promise<string> {
65
+ let real: string
66
+ let st
67
+ try {
68
+ real = realpathSync(link)
69
+ st = statSync(real)
70
+ } catch {
71
+ return NOTHING_BEHIND
72
+ }
73
+ if (seen.has(real)) return NOTHING_BEHIND
74
+ if (st.isFile()) return `file\0${await hashFile(real).catch(absentOr)}`
75
+ if (!st.isDirectory()) return 'special'
76
+ const inner = new Set(seen).add(real)
77
+ const parts = await Promise.all(
78
+ filesBehind(real).map(async (rel) => {
79
+ const abs = path.join(real, rel)
80
+ const id = await hashFile(abs).catch(absentOr)
81
+ return id.startsWith('120000:')
82
+ ? `${rel}\0${id}\0${await behindLink(abs, hashFile, inner)}`
83
+ : `${rel}\0${id}`
84
+ }),
85
+ )
86
+ return `dir\0${xxh3hex(parts.join('\n'))}`
87
+ }
@@ -8,6 +8,7 @@ import type {
8
8
  CacheEntry,
9
9
  CacheGetContext,
10
10
  CacheLayer,
11
+ DeclaredOutputs,
11
12
  IngestMeta,
12
13
  InvocationRecord,
13
14
  OutputDirRow,
@@ -194,8 +195,13 @@ export class ScopedCache implements CacheLayer {
194
195
  return (await this.inner.outputDirsCurrent?.(projectDir, rows)) ?? false
195
196
  }
196
197
 
197
- restoreOutputs(hash: string, projectDir: string, workspaceRoot?: string): Promise<void> {
198
- return this.inner.restoreOutputs(this.keyOf(hash), projectDir, workspaceRoot)
198
+ restoreOutputs(
199
+ hash: string,
200
+ projectDir: string,
201
+ workspaceRoot?: string,
202
+ outputs?: DeclaredOutputs,
203
+ ): Promise<void> {
204
+ return this.inner.restoreOutputs(this.keyOf(hash), projectDir, workspaceRoot, outputs)
199
205
  }
200
206
 
201
207
  save(args: SaveArgs): Promise<void> {
@@ -343,7 +343,7 @@ export const FOREIGN_FLAGS: readonly ForeignFlag[] = [
343
343
  names: ['--global-deps'],
344
344
  value: true,
345
345
  outcome: 'refuse',
346
- 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`)",
347
347
  },
348
348
  {
349
349
  runner: 'turbo',
package/src/cli/help.ts CHANGED
@@ -117,7 +117,7 @@ export function helpText(pluginCommands: readonly string[] = []): string {
117
117
  ' vx cache clean [--older-than <duration>] [--max-size <size>] [--global] [--dry-run] [--format pretty|json] [--cache-dir <path>]',
118
118
  ' vx lock [--check] [--format pretty|json]',
119
119
  ' vx init [--dry [--format pretty|json]] [--force] [--mjs] [--native|--keep] [--plugin <seam>]',
120
- ' vx upgrade [TAG]',
120
+ ' vx upgrade [TAG] [-g|--global]',
121
121
  ' vx show [PROJECT[#TASK] | TASK] [--filter <pattern>] [--affected[=<ref>]] [--format pretty|json]',
122
122
  ' vx info [--format pretty|json] [--cache-dir <path>]',
123
123
  ' vx why (TASK | PKG#TASK) [--run RUNID] [--format pretty|json] [--cache-dir <path>]',
@@ -274,6 +274,12 @@ export function helpText(pluginCommands: readonly string[] = []): string {
274
274
  ' test (executor, cache, telemetry, schedule, admit, commands,',
275
275
  ' project, graph, key).',
276
276
  '',
277
+ 'Upgrade:',
278
+ ' vx upgrade [TAG] Bring vx to TAG (default: latest) the way it was installed.',
279
+ " In a project: the project's vx, its @vzn/vx-* plugins and",
280
+ ' the global vx that ran it, to one release.',
281
+ ' -g, --global Only the global vx: no project, no plugin.',
282
+ '',
277
283
  'Shell completions:',
278
284
  ' vx completions <shell> Print a completion script for bash, zsh or fish: the',
279
285
  ' verbs (plugin verbs of this workspace included) and',
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
@@ -22,6 +22,8 @@ import {
22
22
  exitSignal,
23
23
  resolveRunId,
24
24
  runFailures,
25
+ type RunStep,
26
+ runSteps,
25
27
  shortRunId,
26
28
  taskLog,
27
29
  } from '../orchestrator/index.js'
@@ -328,6 +330,22 @@ function reasonParts(t: RunSummaryRow): string {
328
330
  /** Hit rows shown before the rest fold — the slowest restores. */
329
331
  const HITS_SHOWN = 16
330
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
+
331
349
  /**
332
350
  * The per-task rows: failures first, then what executed or was skipped,
333
351
  * then cache hits. Hits are the noise of a warm run — 996 of a thousand
@@ -336,6 +354,7 @@ const HITS_SHOWN = 16
336
354
  * count; the ones shown are the slowest restores, the one thing a hit's
337
355
  * row tells. `--format json` lists every row.
338
356
  */
357
+
339
358
  export function formatTaskRows(tasks: readonly RunSummaryRow[]): string[] {
340
359
  if (tasks.length === 0) return []
341
360
  const failed = tasks.filter((t) => t.status === 'failed')
@@ -545,7 +564,10 @@ export async function lastCmd(args: readonly string[]): Promise<number> {
545
564
  ...(f.fixedIn !== undefined ? { fixedIn: f.fixedIn } : {}),
546
565
  }
547
566
  })
548
- 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
+ )
549
571
  return 0
550
572
  }
551
573
 
@@ -563,6 +585,7 @@ export async function lastCmd(args: readonly string[]): Promise<number> {
563
585
  `${inv.failedCount > 0 ? ` · ${inv.failedCount} failed` : ''}`,
564
586
  )
565
587
  lines.push(...formatTaskRows(detail?.tasks ?? []))
588
+ lines.push(...formatStepRows(runSteps(cacheDir, inv.runId)))
566
589
  // The next command after reading a failed run, with the arguments the
567
590
  // run forwarded to the tasks that got them (a failure under `-- --shard
568
591
  // 2` is that shard's); a dependency never got them, so its line has none.
@@ -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:
package/src/cli/select.ts CHANGED
@@ -51,7 +51,7 @@ import {
51
51
  import { resolveSetting } from '../settings.js'
52
52
 
53
53
  /**
54
- * The projects whose tasks declare a `cache.inputs.workspaceFiles` glob
54
+ * The projects whose tasks declare an `inputs.workspaceFiles` glob
55
55
  * matching a changed path, one inside another project included (item
56
56
  * 954): `--affected` selects them, since the glob is that task's input. Through the run
57
57
  * path's staged load, so a glob a `project` plugin gave a config-less
@@ -71,10 +71,8 @@ export async function workspaceGlobOwners(
71
71
  ): Promise<string[]> {
72
72
  const declaresMatch = (config: ProjectConfig): boolean => {
73
73
  for (const task of Object.values(config.tasks ?? {})) {
74
- const cache = task.cache
75
- if (cache === undefined) continue
76
- if (changed.some((rel) => declaresInput(cache, null, rel))) return true
77
- if (nested.some((dir) => workspaceFilesReachInto(cache, dir))) return true
74
+ if (changed.some((rel) => declaresInput(task, null, rel))) return true
75
+ if (nested.some((dir) => workspaceFilesReachInto(task, dir))) return true
78
76
  }
79
77
  return false
80
78
  }
@@ -335,7 +333,19 @@ export async function resolveFilters(
335
333
  workspaceGlobOwners: (changed: readonly string[], nested: readonly string[]) =>
336
334
  workspaceGlobOwners(root, projects, changed, load, stagedOnce, nested),
337
335
  fingerprintClaims: () => workspaceFingerprintClaims(root, projects, load),
338
- ...(git !== undefined ? { untracked: async () => (await git.start()).untracked } : {}),
336
+ ...(git !== undefined
337
+ ? {
338
+ untracked: async () => (await git.start()).untracked,
339
+ // A scoped enumeration lists only its projects: null asks git.
340
+ links: async () => {
341
+ const g = await git.start()
342
+ if (!g.scopes.includes('.')) return null
343
+ const out: string[] = []
344
+ for (const [rel, oid] of g.trusted) if (oid.startsWith('120000:')) out.push(rel)
345
+ return out
346
+ },
347
+ }
348
+ : {}),
339
349
  }
340
350
  let names: Set<string>
341
351
  if (f.raw === affected) {
package/src/cli/show.ts CHANGED
@@ -466,14 +466,17 @@ function taskRows(raw: TaskConfig): ui.FieldRow[] {
466
466
  )
467
467
  }
468
468
  add('interactive', exec?.interactive === undefined ? undefined : exec.interactive ? 'yes' : 'no')
469
+ const inputs = task.inputs
470
+ if (inputs !== undefined) {
471
+ add('inputs.files', list(inputs.files))
472
+ add('inputs.workspaceFiles', list(inputs.workspaceFiles))
473
+ add('inputs.env', list(inputs.env))
474
+ add('inputs.tasks', list(inputs.tasks))
475
+ add('inputs.runtime', list(inputs.runtime))
476
+ add('inputs.workspaceRuntime', list(inputs.workspaceRuntime))
477
+ }
469
478
  const cache = task.cache
470
479
  if (cache !== undefined) {
471
- add('inputs.files', list(cache.inputs.files))
472
- add('inputs.workspaceFiles', list(cache.inputs.workspaceFiles))
473
- add('inputs.env', list(cache.inputs.env))
474
- add('inputs.tasks', list(cache.inputs.tasks))
475
- add('inputs.runtime', list(cache.inputs.runtime))
476
- add('inputs.workspaceRuntime', list(cache.inputs.workspaceRuntime))
477
480
  add('outputs.files', list(cache.outputs.files))
478
481
  add('outputs.workspaceFiles', list(cache.outputs.workspaceFiles))
479
482
  }