@vzn/vx 0.0.18 → 0.0.21

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 (121) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +99 -65
  3. package/package.json +10 -20
  4. package/src/bin.ts +12 -2
  5. package/src/cache/archive.ts +120 -18
  6. package/src/cache/cache.ts +313 -1324
  7. package/src/cache/chained-cache.ts +0 -8
  8. package/src/cache/config-evals.ts +89 -0
  9. package/src/cache/file-hashes.ts +262 -0
  10. package/src/cache/git-inputs.ts +637 -0
  11. package/src/cache/index.ts +16 -7
  12. package/src/cache/inputs.ts +246 -733
  13. package/src/cache/layer.ts +604 -0
  14. package/src/cache/layered-cache.ts +12 -17
  15. package/src/cache/output-index.ts +224 -0
  16. package/src/cache/policy.ts +84 -0
  17. package/src/cache/run-history.ts +147 -0
  18. package/src/cache/zstd.ts +160 -0
  19. package/src/cli/cache.ts +39 -11
  20. package/src/cli/completions.ts +104 -0
  21. package/src/cli/core-alias.ts +25 -0
  22. package/src/cli/help.ts +64 -36
  23. package/src/cli/index.ts +54 -15
  24. package/src/cli/info.ts +120 -91
  25. package/src/cli/init.ts +75 -0
  26. package/src/cli/last.ts +38 -6
  27. package/src/cli/lock.ts +2 -1
  28. package/src/cli/plan-format.ts +14 -3
  29. package/src/cli/plugin-commands.ts +15 -8
  30. package/src/cli/run.ts +84 -302
  31. package/src/cli/select.ts +332 -0
  32. package/src/cli/show.ts +144 -69
  33. package/src/cli/upgrade.ts +2 -1
  34. package/src/cli/watch.ts +647 -97
  35. package/src/cli/why.ts +44 -14
  36. package/src/cli/workspace-config.ts +126 -0
  37. package/src/config.ts +156 -136
  38. package/src/exec/env.ts +11 -0
  39. package/src/exec/executor.ts +11 -26
  40. package/src/exec/index.ts +6 -2
  41. package/src/{plugins/local-executor/index.ts → exec/local-executor.ts} +12 -16
  42. package/src/exec/runner.ts +57 -18
  43. package/src/exec/sandbox-binds.ts +182 -0
  44. package/src/exec/sandbox-paths.ts +50 -0
  45. package/src/exec/sandbox-runtime.ts +582 -370
  46. package/src/exec/sandbox-violations.ts +280 -0
  47. package/src/graph/index.ts +1 -9
  48. package/src/graph/scheduler.ts +158 -203
  49. package/src/graph/task-graph.ts +2 -2
  50. package/src/index.ts +97 -102
  51. package/src/orchestrator/admission.ts +137 -0
  52. package/src/orchestrator/deferred-outputs.ts +3 -0
  53. package/src/orchestrator/doctor.ts +255 -0
  54. package/src/orchestrator/events.ts +42 -8
  55. package/src/orchestrator/execute-task.ts +145 -554
  56. package/src/orchestrator/failure-mode.ts +169 -28
  57. package/src/orchestrator/framed-output.ts +41 -36
  58. package/src/orchestrator/history.ts +118 -98
  59. package/src/orchestrator/hit-restore.ts +221 -0
  60. package/src/orchestrator/index.ts +35 -4
  61. package/src/orchestrator/lockfile-claim.ts +281 -0
  62. package/src/orchestrator/logger.ts +13 -22
  63. package/src/orchestrator/metrics.ts +53 -20
  64. package/src/orchestrator/miss-save.ts +169 -0
  65. package/src/orchestrator/options.ts +34 -31
  66. package/src/orchestrator/persistent.ts +79 -0
  67. package/src/orchestrator/placement.ts +182 -0
  68. package/src/orchestrator/plugin-host.ts +195 -38
  69. package/src/orchestrator/plugin.ts +162 -9
  70. package/src/orchestrator/prepare.ts +77 -132
  71. package/src/orchestrator/projects.ts +259 -0
  72. package/src/orchestrator/remote-prefetch.ts +1 -1
  73. package/src/orchestrator/run-artifacts.ts +25 -3
  74. package/src/orchestrator/run-context.ts +1 -1
  75. package/src/orchestrator/run-records.ts +149 -0
  76. package/src/orchestrator/run-report.ts +2 -2
  77. package/src/orchestrator/run.ts +246 -597
  78. package/src/orchestrator/sandbox-request.ts +243 -0
  79. package/src/orchestrator/save-lane.ts +47 -0
  80. package/src/orchestrator/signals.ts +111 -0
  81. package/src/orchestrator/stable-keys.ts +60 -3
  82. package/src/orchestrator/status-line.ts +2 -1
  83. package/src/orchestrator/summary.ts +68 -25
  84. package/src/orchestrator/task-hash.ts +20 -18
  85. package/src/orchestrator/task-log-buffer.ts +8 -11
  86. package/src/orchestrator/telemetry-host.ts +11 -1
  87. package/src/orchestrator/telemetry.ts +3 -17
  88. package/src/util/cgroup.ts +160 -0
  89. package/src/util/edit-distance.ts +49 -0
  90. package/src/util/index.ts +11 -3
  91. package/src/util/paths.ts +23 -2
  92. package/src/util/settle.ts +19 -0
  93. package/src/util/verbs.ts +39 -0
  94. package/src/workspace/affected.ts +111 -11
  95. package/src/workspace/config-cache.ts +11 -2
  96. package/src/workspace/config-eval.ts +11 -4
  97. package/src/workspace/config-imports.ts +5 -4
  98. package/src/workspace/config-schema.ts +837 -0
  99. package/src/workspace/filter.ts +43 -6
  100. package/src/workspace/fingerprint.ts +37 -3
  101. package/src/workspace/index.ts +30 -3
  102. package/src/workspace/lockfile.ts +12 -7
  103. package/src/{cli → workspace}/migrate-scripts.ts +26 -19
  104. package/src/{cli/migrate.ts → workspace/migration.ts} +133 -124
  105. package/src/workspace/package-graph.ts +98 -28
  106. package/src/workspace/project-loader.ts +76 -746
  107. package/src/workspace/workspace.ts +98 -13
  108. package/plugins/local-cache/index.ts +0 -2
  109. package/plugins/local-executor/index.ts +0 -2
  110. package/plugins/schedule-history/index.ts +0 -2
  111. package/src/cache/cas-backend.ts +0 -115
  112. package/src/cache/digest.ts +0 -32
  113. package/src/cli/migrate-emit.ts +0 -21
  114. package/src/cli/migrate-nx.ts +0 -473
  115. package/src/cli/migrate-turbo.ts +0 -401
  116. package/src/cli/prune.ts +0 -291
  117. package/src/orchestrator/missing-plugin.ts +0 -9
  118. package/src/orchestrator/resources.ts +0 -45
  119. package/src/orchestrator/verify.ts +0 -174
  120. package/src/plugins/local-cache/index.ts +0 -11
  121. package/src/plugins/schedule-history/index.ts +0 -109
@@ -27,6 +27,25 @@ export function teardownTimeoutMs(): number {
27
27
  return DEFAULT_TIMEOUT_MS
28
28
  }
29
29
 
30
+ /**
31
+ * The SIGTERM→SIGKILL grace a run grants a child that ignores SIGTERM: the
32
+ * one-shot timeout escalation (`exec/runner.ts`) and the end-of-run
33
+ * persistent shutdown (`orchestrator/persistent.ts`) share it. Two seconds
34
+ * is generous for a real server's cleanup and is what every run waits when
35
+ * a child wedges. `VX_KILL_GRACE_MS` overrides it, read per call and bounded
36
+ * like the teardown deadline (out of range falls back to the default): the
37
+ * tests that prove the escalation used to wait the full two seconds each,
38
+ * a third of the suite's wall time for a claim a 200 ms grace proves too.
39
+ */
40
+ export function killGraceMs(defaultMs: number): number {
41
+ const raw = process.env['VX_KILL_GRACE_MS']
42
+ if (raw !== undefined && /^[0-9]+$/.test(raw)) {
43
+ const n = Number(raw)
44
+ if (n > 0 && n <= MAX_TIMEOUT_MS) return n
45
+ }
46
+ return defaultMs
47
+ }
48
+
30
49
  /**
31
50
  * Await `p`, giving up after `ms`. Returns true when `p` settled first,
32
51
  * false when the deadline won — the caller decides whether a lost result
@@ -0,0 +1,39 @@
1
+ /**
2
+ * The verbs the CLI dispatcher (`cli/index.ts`) owns; `stats` is a
3
+ * deprecated alias of `info` and stays out of hints but is still matched
4
+ * first. Lives in util rather than cli because the workspace VALIDATOR
5
+ * refuses a plugin verb that names one of these — a verb core matches
6
+ * first could never run — and the workspace module cannot import cli.
7
+ */
8
+ export const CORE_VERBS = [
9
+ 'run',
10
+ 'watch',
11
+ 'cache',
12
+ 'lock',
13
+ 'init',
14
+ 'upgrade',
15
+ 'show',
16
+ 'info',
17
+ 'why',
18
+ 'last',
19
+ 'completions',
20
+ 'help',
21
+ 'version',
22
+ ] as const
23
+
24
+ /** Every word the dispatcher matches before consulting plugins. */
25
+ export const DISPATCHED_VERBS: readonly string[] = [...CORE_VERBS, 'stats']
26
+
27
+ /**
28
+ * Verbs core owned once and a package owns now. Not core verbs: a plugin
29
+ * may declare them, and the dispatcher prints the
30
+ * pointer only when no declared plugin claimed the name.
31
+ */
32
+ export const MOVED_VERBS: Readonly<Record<string, string>> = {
33
+ migrate:
34
+ 'vx migrate moved to @vzn/vx-migrate: run `bunx @vzn/vx-migrate` (turbo.json or an Nx ' +
35
+ 'project graph → vx.config.ts); `vx init` reads package.json scripts',
36
+ prune:
37
+ 'vx prune was removed (2026-09-11): the Docker-subset verb is not shipped; copy the ' +
38
+ 'workspace and `--filter` the build instead',
39
+ }
@@ -1,13 +1,10 @@
1
1
  // `--affected` — git-relative project selection.
2
2
  //
3
3
  // Resolves the set of project names whose files have changed since a
4
- // given git ref. The diff is `<since>...HEAD` plus working-tree
5
- // changes (uncommitted edits), so it captures everything you've
6
- // touched on the current branch.
7
- //
8
- // We use `git diff --name-only <since>` which by default compares the
9
- // `<since>` commit to the working tree — i.e. commits-from-`<since>`
10
- // + index + unstaged. Matches Turbo's `[<since>]` filter semantics.
4
+ // given git ref: `git diff --name-only <merge-base(since, HEAD)>`, which
5
+ // compares that commit to the working tree — commits on this branch +
6
+ // index + unstaged — so it captures everything you touched and nothing
7
+ // the base branch moved on with. Matches Turbo's `[<since>]` semantics.
11
8
 
12
9
  import path from 'node:path'
13
10
  import { UserError } from '../util/index.js'
@@ -37,6 +34,23 @@ export interface AffectedArgs {
37
34
  * — never invokes it and pays nothing.
38
35
  */
39
36
  workspaceGlobOwners?: (orphanPaths: readonly string[]) => Promise<Iterable<string>>
37
+ /**
38
+ * The fingerprint files a plugin claims (`VxPlugin.fingerprint`) and its
39
+ * answer for a change to one. Resolved lazily: loading the workspace
40
+ * file costs an evaluation, and a diff that touches no fingerprint file
41
+ * — the common one — never needs it.
42
+ */
43
+ fingerprintClaims?: () => Promise<FingerprintClaims>
44
+ }
45
+
46
+ export interface FingerprintClaims {
47
+ readonly files: ReadonlySet<string>
48
+ /** Project names a change to a claimed file affects; `undefined` = all. */
49
+ affected(change: {
50
+ file: string
51
+ before: Uint8Array | null
52
+ after: Uint8Array | null
53
+ }): Promise<ReadonlySet<string> | undefined>
40
54
  }
41
55
 
42
56
  /**
@@ -45,7 +59,26 @@ export interface AffectedArgs {
45
59
  * or `<since>` doesn't resolve to a commit.
46
60
  */
47
61
  export async function affectedProjects(args: AffectedArgs): Promise<Set<string>> {
62
+ // The base reaches git as an argument, never through a shell, so `$(…)`
63
+ // is opaque — but an option-like value is not: `--output=<path>` is a
64
+ // real `git diff` option and an arbitrary file write. This is a security
65
+ // boundary, so it is a check that knows it is one, before any spawn, and
66
+ // every git call below also ends its options (`--end-of-options`) so a
67
+ // second caller cannot lose the guard by accident.
68
+ if (args.since.length === 0 || args.since.startsWith('-')) {
69
+ throw new UserError(
70
+ `git ref "${args.since}" is not a ref: a base cannot be empty or start with "-".`,
71
+ )
72
+ }
48
73
  await verifyRef(args.workspaceRoot, args.since)
74
+ // Diff from the MERGE BASE of `since` and HEAD, not from `since` itself:
75
+ // on a branch whose base has moved on, `git diff <base>` reports every
76
+ // file OTHER people changed on the base (over-selection that defeats a
77
+ // CI `--affected`), and hides your own edit when the base later landed
78
+ // byte-identical content. Turbo and Nx both diff from the merge base;
79
+ // when there is none (unrelated histories, a detached probe) the ref
80
+ // itself is the base, as before.
81
+ const base = await mergeBase(args.workspaceRoot, args.since)
49
82
 
50
83
  const [diffed, untracked] = await Promise.all([
51
84
  // `--no-renames` is crucial for project-affected detection: with
@@ -73,7 +106,8 @@ export async function affectedProjects(args: AffectedArgs): Promise<Set<string>>
73
106
  '--relative',
74
107
  '--name-only',
75
108
  '-z',
76
- args.since,
109
+ '--end-of-options',
110
+ base,
77
111
  ]),
78
112
  // `git diff` never reports untracked-but-not-ignored files, but input
79
113
  // enumeration does (`git ls-files --cached --others --exclude-standard`),
@@ -96,11 +130,31 @@ export async function affectedProjects(args: AffectedArgs): Promise<Set<string>>
96
130
  // principle: "input hashing sees it, so `--affected` must too."
97
131
  //
98
132
  // Selection is not hashed, so widening it here changes no cache key.
99
- if (changed.some((p) => FINGERPRINT_SET.has(p))) {
100
- return new Set(args.projects.map((p) => p.name))
133
+ //
134
+ // A file a plugin CLAIMS is the exception: the key folds what the plugin
135
+ // says per project, so selection asks the plugin the same question, with
136
+ // the bytes at the base ref and in the working tree. Its answer is
137
+ // unioned with the path-owned projects below; only "cannot tell" widens.
138
+ const fingerprintChanged = changed.filter((p) => FINGERPRINT_SET.has(p))
139
+ const claimedOwned = new Set<string>()
140
+ if (fingerprintChanged.length > 0) {
141
+ const claims = args.fingerprintClaims === undefined ? undefined : await args.fingerprintClaims()
142
+ for (const file of fingerprintChanged) {
143
+ if (claims === undefined || !claims.files.has(file)) {
144
+ return new Set(args.projects.map((p) => p.name))
145
+ }
146
+ const answer = await claims.affected({
147
+ file,
148
+ before: await gitBytesAt(args.workspaceRoot, base, file),
149
+ after: await bytesOrNull(path.join(args.workspaceRoot, file)),
150
+ })
151
+ if (answer === undefined) return new Set(args.projects.map((p) => p.name))
152
+ for (const name of answer) claimedOwned.add(name)
153
+ }
101
154
  }
102
155
 
103
156
  const { owned, orphans } = projectsContaining(args.workspaceRoot, changed, args.projects)
157
+ for (const name of claimedOwned) owned.add(name)
104
158
 
105
159
  // THIRD CHANNEL: a project whose `vx.config.*` IMPORTS a changed file.
106
160
  // Resolved-config hashing folds those values into the key, so the same
@@ -149,6 +203,39 @@ export function workspaceGlobsMatch(globs: readonly string[], rel: string): bool
149
203
  */
150
204
  const FINGERPRINT_SET: ReadonlySet<string> = new Set(WORKSPACE_FINGERPRINT_FILES)
151
205
 
206
+ /**
207
+ * A root file's bytes at `ref`, or null when the ref has no such file.
208
+ * `./` anchors the path to the cwd (the workspace root) rather than the
209
+ * repository root, for a workspace that is a subdirectory of its repo.
210
+ */
211
+ async function gitBytesAt(
212
+ workspaceRoot: string,
213
+ ref: string,
214
+ file: string,
215
+ ): Promise<Uint8Array | null> {
216
+ const proc = Bun.spawn({
217
+ cmd: ['git', 'show', '--end-of-options', `${ref}:./${file}`],
218
+ cwd: workspaceRoot,
219
+ stdout: 'pipe',
220
+ stderr: 'pipe',
221
+ })
222
+ const [bytes, stderr, exit] = await Promise.all([
223
+ new Response(proc.stdout).bytes(),
224
+ new Response(proc.stderr).text(),
225
+ proc.exited,
226
+ ])
227
+ if (exit === 0) return bytes
228
+ // "exists on disk but not in <ref>" and "path does not exist in <ref>"
229
+ // are the file being absent at the ref; anything else is git failing.
230
+ if (/does not exist in|exists on disk, but not in/.test(stderr)) return null
231
+ throw new UserError(`git show ${ref}:./${file} failed (exit ${exit}): ${stderr.trim()}`)
232
+ }
233
+
234
+ async function bytesOrNull(file: string): Promise<Uint8Array | null> {
235
+ const f = Bun.file(file)
236
+ return (await f.exists()) ? await f.bytes() : null
237
+ }
238
+
152
239
  /** Run a NUL-separated path-listing git command from the workspace root. */
153
240
  async function gitPaths(workspaceRoot: string, cmd: string[]): Promise<string[]> {
154
241
  const proc = Bun.spawn({
@@ -184,9 +271,22 @@ export async function defaultAffectedBase(workspaceRoot: string): Promise<string
184
271
  return 'HEAD~1'
185
272
  }
186
273
 
274
+ /** `git merge-base <ref> HEAD`, or `ref` itself when the two share no ancestor. */
275
+ async function mergeBase(workspaceRoot: string, ref: string): Promise<string> {
276
+ const proc = Bun.spawn({
277
+ cmd: ['git', 'merge-base', '--end-of-options', ref, 'HEAD'],
278
+ cwd: workspaceRoot,
279
+ stdout: 'pipe',
280
+ stderr: 'pipe',
281
+ })
282
+ const [out, exit] = await Promise.all([new Response(proc.stdout).text(), proc.exited])
283
+ const sha = out.trim()
284
+ return exit === 0 && sha.length > 0 ? sha : ref
285
+ }
286
+
187
287
  async function verifyRef(workspaceRoot: string, ref: string): Promise<void> {
188
288
  const proc = Bun.spawnSync({
189
- cmd: ['git', 'rev-parse', '--verify', '--quiet', ref],
289
+ cmd: ['git', 'rev-parse', '--verify', '--quiet', '--end-of-options', ref],
190
290
  cwd: workspaceRoot,
191
291
  stdout: 'pipe',
192
292
  stderr: 'pipe',
@@ -52,6 +52,12 @@ export interface ConfigEvalStore {
52
52
  * shadow, is never indexed.
53
53
  */
54
54
  hashFile?(file: string): Promise<string>
55
+ /**
56
+ * `hashFile` over many paths with one memo query; a path that cannot be
57
+ * stat'ed is absent. The warm load identifies every indexed closure's
58
+ * files through this in a single call when the store offers it.
59
+ */
60
+ hashFiles?(files: readonly string[]): Promise<Map<string, string>>
55
61
  getConfigClosures?(configPaths: readonly string[]): Map<string, string[]>
56
62
  putConfigClosure?(configPath: string, files: readonly string[]): void
57
63
  getConfigEval(key: string): string | null
@@ -95,9 +101,12 @@ const MAX_CLOSURE_FILES = 32
95
101
  // `global` and `self` are live objects in Bun (aliases of `globalThis`), so
96
102
  // a computed `global['proc' + 'ess']` reaches process without ever
97
103
  // spelling it; `Temporal` is a clock. All three were CACHED AS PURE before
98
- // they were listed (2026-09-03).
104
+ // they were listed (2026-09-03). `constructor` reaches `Function` through a
105
+ // property name (`({}).constructor.constructor('return process')()`, the
106
+ // body hidden in a literal the strip removes) and `localeCompare` answers
107
+ // by the host locale; both listed 2026-09-09.
99
108
  const IMPURE_RE =
100
- /\b(?:process|Bun|globalThis|global|self|fetch|Date|Temporal|Intl|crypto|performance|navigator|require|eval|Function|await|toLocale\w*)\b|import\s*\.\s*meta|Math\s*\.\s*random|\bimport\s*\(/
109
+ /\b(?:process|Bun|globalThis|global|self|fetch|Date|Temporal|Intl|crypto|performance|navigator|require|eval|Function|constructor|localeCompare|await|toLocale\w*)\b|import\s*\.\s*meta|Math\s*\.\s*random|\bimport\s*\(/
101
110
 
102
111
  // Static `import … from '…'` / `export … from '…'` / `import '…'` forms.
103
112
  // `[^;'"]*?` spans newlines, so multi-line specifier lists match.
@@ -52,6 +52,10 @@ self.onmessage = async (e) => {
52
52
  name: err?.name ?? 'Error',
53
53
  message: err?.message ?? String(err),
54
54
  stack: err?.stack ?? null,
55
+ position:
56
+ err?.position && typeof err.position === 'object'
57
+ ? { file: err.position.file, line: err.position.line, column: err.position.column }
58
+ : null,
55
59
  })
56
60
  }
57
61
  }
@@ -66,6 +70,8 @@ interface WorkerReply {
66
70
  name: string
67
71
  message: string
68
72
  stack: string | null
73
+ /** A transpile error's location — `BuildMessage.position`, trimmed to what the loader reads. */
74
+ position: { file?: string; line?: number; column?: number } | null
69
75
  }
70
76
 
71
77
  interface Pending {
@@ -136,12 +142,13 @@ function acquireWorker(): Worker {
136
142
  p.resolve(msg.json)
137
143
  return
138
144
  }
139
- // Rebuild the error the config actually threw. Name, message and
140
- // stack all survive the hop, so a broken config reports the same
141
- // text it would from an in-process import.
142
- const err = new Error(msg.message)
145
+ // Rebuild the error the config actually threw. Name, message, stack
146
+ // and a transpile error's position all survive the hop, so a broken
147
+ // config reports the same text it would from an in-process import.
148
+ const err = new Error(msg.message) as Error & { position?: WorkerReply['position'] }
143
149
  err.name = msg.name
144
150
  if (msg.stack !== null) err.stack = msg.stack
151
+ if (msg.position !== null) err.position = msg.position
145
152
  p.reject(err)
146
153
  }
147
154
  w.onerror = (event: ErrorEvent): void => {
@@ -19,10 +19,11 @@
19
19
  // - RELATIVE specifiers only. A bare specifier is a package; it moves when
20
20
  // the lockfile moves, which the workspace fingerprint already covers.
21
21
  // - Descend only through files owned by NO project. A config reaching into
22
- // another project (this repo's `apps/docs/vx.config.ts` imports
23
- // `../../src/index.ts`) records that edge and STOPS there — following it
24
- // would drag substantially all of core `src/` into the closure, and the
25
- // containment channel already selects the project that owns it.
22
+ // another project (say a site's `vx.config.ts` importing
23
+ // `../core/src/index.ts`) records that edge and STOPS there — following
24
+ // it would drag substantially all of that project's `src/` into the
25
+ // closure, and the containment channel already selects the project that
26
+ // owns it.
26
27
 
27
28
  import { realpath } from 'node:fs/promises'
28
29
  import path from 'node:path'