@vzn/vx 0.0.18 → 0.0.22

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 (149) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +107 -70
  3. package/package.json +10 -20
  4. package/src/bin.ts +64 -5
  5. package/src/cache/archive.ts +279 -28
  6. package/src/cache/cache.ts +952 -1584
  7. package/src/cache/chained-cache.ts +14 -10
  8. package/src/cache/config-evals.ts +112 -0
  9. package/src/cache/file-hashes.ts +290 -0
  10. package/src/cache/git-inputs.ts +966 -0
  11. package/src/cache/index.ts +23 -7
  12. package/src/cache/inputs.ts +553 -739
  13. package/src/cache/key-fold.ts +232 -0
  14. package/src/cache/layer.ts +674 -0
  15. package/src/cache/layered-cache.ts +211 -49
  16. package/src/cache/output-index.ts +323 -0
  17. package/src/cache/policy.ts +84 -0
  18. package/src/cache/run-history.ts +152 -0
  19. package/src/cache/zstd.ts +183 -0
  20. package/src/cli/cache.ts +88 -30
  21. package/src/cli/completions.ts +104 -0
  22. package/src/cli/core-alias.ts +25 -0
  23. package/src/cli/format.ts +4 -15
  24. package/src/cli/help.ts +76 -38
  25. package/src/cli/index.ts +82 -17
  26. package/src/cli/info.ts +162 -91
  27. package/src/cli/init.ts +87 -0
  28. package/src/cli/last.ts +130 -23
  29. package/src/cli/lock.ts +36 -18
  30. package/src/cli/plan-format.ts +18 -7
  31. package/src/cli/plugin-commands.ts +34 -12
  32. package/src/cli/run.ts +138 -313
  33. package/src/cli/select.ts +342 -0
  34. package/src/cli/show.ts +151 -71
  35. package/src/cli/upgrade.ts +136 -15
  36. package/src/cli/watch.ts +1175 -116
  37. package/src/cli/why.ts +51 -29
  38. package/src/cli/workspace-config.ts +138 -0
  39. package/src/config.ts +183 -126
  40. package/src/exec/env.ts +11 -0
  41. package/src/exec/executor.ts +62 -31
  42. package/src/exec/index.ts +16 -2
  43. package/src/exec/kill-tree.ts +281 -0
  44. package/src/exec/local-executor.ts +41 -0
  45. package/src/exec/runner.ts +361 -77
  46. package/src/exec/sandbox-binds.ts +211 -0
  47. package/src/exec/sandbox-paths.ts +70 -0
  48. package/src/exec/sandbox-runtime.ts +1152 -415
  49. package/src/exec/sandbox-violations.ts +293 -0
  50. package/src/graph/dependency-spec.ts +14 -14
  51. package/src/graph/index.ts +6 -4
  52. package/src/graph/priorities.ts +130 -0
  53. package/src/graph/scheduler.ts +332 -329
  54. package/src/graph/task-graph.ts +552 -188
  55. package/src/index.ts +111 -102
  56. package/src/orchestrator/admission.ts +161 -0
  57. package/src/orchestrator/colors.ts +8 -3
  58. package/src/orchestrator/deferred-outputs.ts +25 -7
  59. package/src/orchestrator/doctor.ts +363 -0
  60. package/src/orchestrator/events.ts +111 -8
  61. package/src/orchestrator/excluded-keys.ts +155 -0
  62. package/src/orchestrator/execute-task.ts +450 -565
  63. package/src/orchestrator/failure-mode.ts +169 -28
  64. package/src/orchestrator/failure-recap.ts +121 -0
  65. package/src/orchestrator/fingerprint-watch.ts +89 -0
  66. package/src/orchestrator/framed-output.ts +121 -49
  67. package/src/orchestrator/history.ts +123 -104
  68. package/src/orchestrator/hit-restore.ts +285 -0
  69. package/src/orchestrator/index.ts +42 -5
  70. package/src/orchestrator/keyed-projects.ts +89 -0
  71. package/src/orchestrator/local-shortcircuit.ts +83 -23
  72. package/src/orchestrator/lockfile-claim.ts +401 -0
  73. package/src/orchestrator/logger.ts +128 -41
  74. package/src/orchestrator/metrics.ts +201 -72
  75. package/src/orchestrator/miss-save.ts +232 -0
  76. package/src/orchestrator/options.ts +63 -34
  77. package/src/orchestrator/persistent.ts +99 -0
  78. package/src/orchestrator/placement.ts +212 -0
  79. package/src/orchestrator/plan.ts +24 -11
  80. package/src/orchestrator/plugin-host.ts +200 -49
  81. package/src/orchestrator/plugin.ts +190 -14
  82. package/src/orchestrator/prepare.ts +168 -137
  83. package/src/orchestrator/projects.ts +270 -0
  84. package/src/orchestrator/remote-prefetch.ts +22 -2
  85. package/src/orchestrator/run-artifacts.ts +34 -3
  86. package/src/orchestrator/run-context.ts +20 -5
  87. package/src/orchestrator/run-lock.ts +349 -0
  88. package/src/orchestrator/run-records.ts +132 -0
  89. package/src/orchestrator/run-report.ts +6 -4
  90. package/src/orchestrator/run.ts +467 -611
  91. package/src/orchestrator/sandbox-request.ts +569 -0
  92. package/src/orchestrator/save-lane.ts +44 -0
  93. package/src/orchestrator/shell-verdict.ts +117 -0
  94. package/src/orchestrator/signals.ts +167 -0
  95. package/src/orchestrator/stable-keys.ts +259 -10
  96. package/src/orchestrator/status-line.ts +35 -2
  97. package/src/orchestrator/summary.ts +115 -25
  98. package/src/orchestrator/task-hash.ts +103 -21
  99. package/src/orchestrator/task-log-buffer.ts +15 -18
  100. package/src/orchestrator/telemetry-host.ts +27 -8
  101. package/src/orchestrator/telemetry.ts +71 -44
  102. package/src/orchestrator/upstream.ts +91 -45
  103. package/src/util/bun-version.ts +43 -0
  104. package/src/util/cgroup.ts +160 -0
  105. package/src/util/edit-distance.ts +49 -0
  106. package/src/util/errors.ts +86 -0
  107. package/src/util/hash.ts +10 -1
  108. package/src/util/index.ts +42 -5
  109. package/src/util/paths.ts +168 -5
  110. package/src/util/procfs.ts +20 -0
  111. package/src/util/settle.ts +23 -2
  112. package/src/util/size.ts +33 -0
  113. package/src/util/task-id.ts +20 -0
  114. package/src/util/timing.ts +11 -1
  115. package/src/util/verbs.ts +39 -0
  116. package/src/util/which.ts +48 -0
  117. package/src/workspace/affected.ts +368 -60
  118. package/src/workspace/config-cache.ts +271 -32
  119. package/src/workspace/config-eval.ts +60 -16
  120. package/src/workspace/config-imports.ts +131 -9
  121. package/src/workspace/config-schema.ts +1033 -0
  122. package/src/workspace/filter.ts +69 -17
  123. package/src/workspace/fingerprint.ts +59 -7
  124. package/src/workspace/index.ts +37 -3
  125. package/src/workspace/json-data.ts +90 -0
  126. package/src/workspace/load-reads.ts +32 -0
  127. package/src/workspace/lockfile.ts +41 -14
  128. package/src/workspace/migrate-scripts.ts +187 -0
  129. package/src/workspace/migration.ts +353 -0
  130. package/src/workspace/package-graph.ts +239 -31
  131. package/src/workspace/project-loader.ts +295 -815
  132. package/src/workspace/workspace.ts +266 -31
  133. package/plugins/local-cache/index.ts +0 -2
  134. package/plugins/local-executor/index.ts +0 -2
  135. package/plugins/schedule-history/index.ts +0 -2
  136. package/src/cache/cas-backend.ts +0 -115
  137. package/src/cache/digest.ts +0 -32
  138. package/src/cli/migrate-emit.ts +0 -21
  139. package/src/cli/migrate-nx.ts +0 -473
  140. package/src/cli/migrate-scripts.ts +0 -127
  141. package/src/cli/migrate-turbo.ts +0 -401
  142. package/src/cli/migrate.ts +0 -305
  143. package/src/cli/prune.ts +0 -291
  144. package/src/orchestrator/missing-plugin.ts +0 -9
  145. package/src/orchestrator/resources.ts +0 -45
  146. package/src/orchestrator/verify.ts +0 -174
  147. package/src/plugins/local-cache/index.ts +0 -11
  148. package/src/plugins/local-executor/index.ts +0 -45
  149. package/src/plugins/schedule-history/index.ts +0 -109
package/src/cli/prune.ts DELETED
@@ -1,291 +0,0 @@
1
- // `vx prune <project> [--out-dir <dir>] [--docker]` — emit a self-contained
2
- // SUBSET of the workspace containing one project and its transitive
3
- // workspace dependencies, for Docker builds (Turbo `turbo prune` parity,
4
- // comparison gap #10).
5
- //
6
- // What lands in the output:
7
- // <out>/ the pruned workspace (or <out>/full/ with --docker)
8
- // package.json root manifest, copied as-is
9
- // pnpm-workspace.yaml REWRITTEN to the exact subset dirs (a glob that
10
- // matches dirs absent from the subset would make
11
- // pnpm error on install)
12
- // <lockfile> copied UNPRUNED — pnpm/bun/npm/yarn all tolerate
13
- // a superset lockfile; real lockfile pruning is a
14
- // per-format project (Turbo ships a crate per
15
- // format) and a wrong pruned lockfile is worse
16
- // than a big correct one. Documented, deliberate.
17
- // vx.workspace.* copied when present, together with any WORKSPACE
18
- // package it imports (a plugin lives in the subset
19
- // or `vx run` cannot load the config at all)
20
- // .npmrc / .nvmrc copied when present
21
- // <pkg dirs> full source of the project + every transitive
22
- // workspace dep (node_modules / .git / .vx / .turbo
23
- // excluded)
24
- // <out>/json/ with --docker: manifests only (root files + each
25
- // package's package.json) — COPY this layer first
26
- // so `pnpm install` caches independently of source
27
- // edits, then COPY full/ and build.
28
- //
29
- // Boundaries note: a package dir is copied WHOLE (minus the exclusions).
30
- // vx's own nested-project input exclusions do not apply here — prune
31
- // reproduces the tree, it does not hash it.
32
- //
33
- // RUNNABILITY IS NOT GUARANTEED, and this deliberately does not claim it.
34
- // A config may import any path it likes; only imports that name a workspace
35
- // PACKAGE can be resolved and pulled in. A relative import escaping its own
36
- // package dir (`../../src/index.ts`, the shape this repo's own dogfooding
37
- // configs use) reaches a file prune never copies, and no subset short of the
38
- // whole tree would contain it. Those are reported at prune time — a warning
39
- // the user reads now beats a module-not-found inside a docker build.
40
- // The import scan is STATIC and deliberately simple: it sees `from '…'`,
41
- // `import '…'` and `import('…')`, and nothing computed.
42
-
43
- import { cp, mkdir, stat, writeFile } from 'node:fs/promises'
44
- import path from 'node:path'
45
- import {
46
- buildPackageGraph,
47
- findWorkspaceRoot,
48
- loadWorkspace,
49
- listProjects,
50
- } from '../workspace/index.js'
51
- import { UserError } from '../util/index.js'
52
-
53
- interface PruneWorkspaceArgs {
54
- project?: string
55
- outDir: string
56
- docker: boolean
57
- error?: string
58
- }
59
-
60
- export function parsePruneWorkspaceArgs(args: readonly string[]): PruneWorkspaceArgs {
61
- const out: PruneWorkspaceArgs = { outDir: 'out', docker: false }
62
- for (let i = 0; i < args.length; i++) {
63
- const a = args[i]!
64
- if (a === '--out-dir' || a.startsWith('--out-dir=')) {
65
- const v = a === '--out-dir' ? args[++i] : a.slice('--out-dir='.length)
66
- if (v === undefined || v === '') return { ...out, error: 'invalid --out-dir: empty' }
67
- out.outDir = v
68
- continue
69
- }
70
- if (a === '--docker') {
71
- out.docker = true
72
- continue
73
- }
74
- if (a.startsWith('-')) return { ...out, error: `unknown flag: ${a}` }
75
- if (out.project !== undefined) return { ...out, error: `unexpected argument: ${a}` }
76
- out.project = a
77
- }
78
- return out
79
- }
80
-
81
- const COPY_EXCLUDES = new Set(['node_modules', '.git', '.vx', '.turbo'])
82
-
83
- /** Root-level files worth carrying into the subset, when they exist. */
84
- const ROOT_FILES = ['package.json', '.npmrc', '.nvmrc']
85
- const WORKSPACE_CONFIGS = [
86
- 'vx.workspace.ts',
87
- 'vx.workspace.mts',
88
- 'vx.workspace.js',
89
- 'vx.workspace.mjs',
90
- ]
91
- const LOCKFILES = ['pnpm-lock.yaml', 'bun.lock', 'bun.lockb', 'package-lock.json', 'yarn.lock']
92
-
93
- async function exists(p: string): Promise<boolean> {
94
- return stat(p).then(
95
- () => true,
96
- () => false,
97
- )
98
- }
99
-
100
- async function copyDirFiltered(src: string, dest: string): Promise<void> {
101
- await cp(src, dest, {
102
- recursive: true,
103
- filter: (source) => !COPY_EXCLUDES.has(path.basename(source)),
104
- })
105
- }
106
-
107
- const PROJECT_CONFIGS = ['vx.config.ts', 'vx.config.mts', 'vx.config.js', 'vx.config.mjs']
108
- /** `from '…'`, `import '…'`, `import('…')`. Static specifiers only. */
109
- const SPECIFIER_RE = /(?:from|import)\s*\(?\s*['"]([^'"]+)['"]/g
110
-
111
- async function readImports(dir: string, names: readonly string[]): Promise<string[]> {
112
- for (const f of names) {
113
- const p = path.join(dir, f)
114
- if (!(await exists(p))) continue
115
- const text = await Bun.file(p).text()
116
- return [...text.matchAll(SPECIFIER_RE)].map((m) => m[1]!)
117
- }
118
- return []
119
- }
120
-
121
- export async function pruneWorkspaceCmd(args: readonly string[]): Promise<number> {
122
- const parsed = parsePruneWorkspaceArgs(args)
123
- if (parsed.error !== undefined) throw new UserError(`vx prune: ${parsed.error}`)
124
- if (parsed.project === undefined) {
125
- throw new UserError('vx prune: <project> required (e.g. vx prune @acme/api)')
126
- }
127
-
128
- const root = await findWorkspaceRoot(process.cwd())
129
- const workspace = await loadWorkspace(root)
130
- const projects = await listProjects(workspace)
131
- const byName = new Map(projects.map((p) => [p.name, p]))
132
-
133
- const target = byName.get(parsed.project)
134
- if (target === undefined) {
135
- const names = [...byName.keys()].sort()
136
- const q = parsed.project.toLowerCase()
137
- const near = names.filter((n) => n.toLowerCase().includes(q)).slice(0, 3)
138
- throw new UserError(
139
- `vx prune: no project named "${parsed.project}"` +
140
- (near.length > 0 ? ` — did you mean ${near.join(', ')}?` : ''),
141
- )
142
- }
143
-
144
- const graph = buildPackageGraph(projects)
145
- const names = new Set([target.name, ...graph.transitiveDeps(target.name)])
146
-
147
- // The workspace config runs BEFORE any task, so a plugin it imports from a
148
- // workspace package is as load-bearing as a package dependency — without it
149
- // `vx run` in the container fails to load the config at all. Pull those in
150
- // (with their own dep closure). The root member is the one that cannot be:
151
- // copying it would mean copying the whole tree, so it is reported instead.
152
- const projectFor = (spec: string): (typeof projects)[number] | undefined =>
153
- projects.find((p) => spec === p.name || spec.startsWith(`${p.name}/`))
154
- const uncopyable = new Set<string>()
155
- for (const spec of await readImports(root, WORKSPACE_CONFIGS)) {
156
- const owner = projectFor(spec)
157
- if (owner === undefined || names.has(owner.name)) continue
158
- if (owner.dir === root) {
159
- uncopyable.add(`${owner.name} (the workspace root itself)`)
160
- continue
161
- }
162
- names.add(owner.name)
163
- for (const d of graph.transitiveDeps(owner.name)) names.add(d)
164
- }
165
-
166
- const subset = [...names].map((n) => byName.get(n)!).sort((a, b) => a.name.localeCompare(b.name))
167
-
168
- const outAbs = path.resolve(process.cwd(), parsed.outDir)
169
- // Three shapes that would eat their own tail: the out dir IS the root
170
- // (overwrites the workspace), CONTAINS the root (copies land above the
171
- // repo), or sits INSIDE a package being copied (cp would recurse into
172
- // its own output). A root-level ./out is fine — only package dirs are
173
- // copied wholesale, never the root itself.
174
- if (outAbs === root || root.startsWith(outAbs + path.sep)) {
175
- throw new UserError('vx prune: --out-dir must not be, or contain, the workspace root')
176
- }
177
- for (const p of subset) {
178
- if (outAbs === p.dir || outAbs.startsWith(p.dir + path.sep)) {
179
- throw new UserError(
180
- `vx prune: --out-dir is inside ${p.name}, which is being copied — pick a path outside the pruned packages`,
181
- )
182
- }
183
- }
184
- const fullDir = parsed.docker ? path.join(outAbs, 'full') : outAbs
185
- const jsonDir = path.join(outAbs, 'json')
186
- await mkdir(fullDir, { recursive: true })
187
- if (parsed.docker) await mkdir(jsonDir, { recursive: true })
188
-
189
- const rels = subset.map((p) => path.relative(root, p.dir).split(path.sep).join('/'))
190
- // pnpm-workspace.yaml rewritten to the exact subset — globs that match
191
- // nothing in the subset make installs fail.
192
- const workspaceYaml = `packages:\n${rels.map((r) => ` - "${r}"\n`).join('')}`
193
-
194
- // package.json's `workspaces` is where bun/npm/yarn read membership, and it
195
- // needs the SAME rewrite as pnpm-workspace.yaml for the same reason — except
196
- // the failure is sharper: a GLOB matching nothing is tolerated, but an entry
197
- // naming an exact dir that the subset does not contain is fatal
198
- // (`bun install` → `error: Workspace not found "packages/b"`, exit 1), so the
199
- // emitted context would not install at all. A `.` entry is kept when it was
200
- // there: it names the root package, whose manifest is copied.
201
- const rewriteRootManifest = async (dest: string): Promise<boolean> => {
202
- const src = path.join(root, 'package.json')
203
- if (!(await exists(src))) return false
204
- let pkg: Record<string, unknown>
205
- try {
206
- pkg = JSON.parse(await Bun.file(src).text()) as Record<string, unknown>
207
- } catch {
208
- return false // unparseable: copy it verbatim rather than lose it
209
- }
210
- const field = pkg['workspaces']
211
- const list = Array.isArray(field)
212
- ? field
213
- : typeof field === 'object' && field !== null && Array.isArray((field as never)['packages'])
214
- ? ((field as never)['packages'] as string[])
215
- : undefined
216
- if (list === undefined) return false
217
- const next = (list as string[]).includes('.') ? ['.', ...rels] : [...rels]
218
- pkg['workspaces'] = Array.isArray(field) ? next : { ...(field as object), packages: next }
219
- await writeFile(path.join(dest, 'package.json'), `${JSON.stringify(pkg, null, 2)}\n`)
220
- return true
221
- }
222
-
223
- const emitRoots = async (dest: string): Promise<void> => {
224
- await writeFile(path.join(dest, 'pnpm-workspace.yaml'), workspaceYaml)
225
- const manifestWritten = await rewriteRootManifest(dest)
226
- for (const f of [...ROOT_FILES, ...WORKSPACE_CONFIGS, ...LOCKFILES]) {
227
- if (f === 'package.json' && manifestWritten) continue
228
- const src = path.join(root, f)
229
- if (await exists(src)) await cp(src, path.join(dest, f))
230
- }
231
- }
232
-
233
- await emitRoots(fullDir)
234
- for (const [i, p] of subset.entries()) {
235
- await copyDirFiltered(p.dir, path.join(fullDir, rels[i]!))
236
- }
237
-
238
- if (parsed.docker) {
239
- await emitRoots(jsonDir)
240
- for (const [i, p] of subset.entries()) {
241
- const pkgJson = path.join(p.dir, 'package.json')
242
- if (await exists(pkgJson)) {
243
- const destDir = path.join(jsonDir, rels[i]!)
244
- await mkdir(destDir, { recursive: true })
245
- await cp(pkgJson, path.join(destDir, 'package.json'))
246
- }
247
- }
248
- }
249
-
250
- // Every copied config is re-read to report what the subset canNOT satisfy:
251
- // a relative import reaching outside the copied dirs, or a workspace package
252
- // that is not here. Copied root files (the manifest, the lockfile, the
253
- // workspace config) count as present — a config importing one of those is
254
- // fine, and flagging it would be a false alarm.
255
- const copiedRootFiles = new Set([...ROOT_FILES, ...WORKSPACE_CONFIGS, ...LOCKFILES])
256
- const escapes: string[] = []
257
- for (const p of subset) {
258
- for (const spec of await readImports(p.dir, PROJECT_CONFIGS)) {
259
- if (spec.startsWith('.')) {
260
- const resolved = path.resolve(p.dir, spec)
261
- const inSubset = subset.some(
262
- (q) => resolved === q.dir || resolved.startsWith(q.dir + path.sep),
263
- )
264
- const isCopiedRootFile =
265
- path.dirname(resolved) === root && copiedRootFiles.has(path.basename(resolved))
266
- if (!inSubset && !isCopiedRootFile) {
267
- escapes.push(`${p.name}: ${spec} resolves outside the pruned subset`)
268
- }
269
- continue
270
- }
271
- const owner = projectFor(spec)
272
- if (owner !== undefined && !subset.includes(owner)) {
273
- escapes.push(`${p.name}: imports ${owner.name}, which is not in the subset`)
274
- }
275
- }
276
- }
277
-
278
- process.stdout.write(
279
- `pruned ${subset.length} package${subset.length === 1 ? '' : 's'} for ${target.name} → ${parsed.outDir}${parsed.docker ? ' (docker layout: json/ + full/)' : ''}\n` +
280
- subset.map((p) => ` ${p.name}\n`).join(''),
281
- )
282
- for (const u of uncopyable) {
283
- process.stderr.write(
284
- `vx prune: WARNING the workspace config imports ${u} — it cannot be copied into a subset, so \`vx run\` inside the container will not load it\n`,
285
- )
286
- }
287
- for (const e of escapes) {
288
- process.stderr.write(`vx prune: WARNING ${e}\n`)
289
- }
290
- return 0
291
- }
@@ -1,9 +0,0 @@
1
- /** Shown by every "no <capability> plugin declared" error. */
2
- export const MISSING_PLUGIN_HINT = `vx runs nothing it was not told to. Declare the plugins in vx.workspace.ts:
3
-
4
- import { defineWorkspace } from '@vzn/vx'
5
- import { localExecutorPlugin } from '@vzn/vx/plugins/local-executor'
6
- import { localCachePlugin } from '@vzn/vx/plugins/local-cache'
7
- export default defineWorkspace({ plugins: [localExecutorPlugin(), localCachePlugin()] })
8
-
9
- Put a remote executor or cache plugin BEFORE the local one to prefer it.`
@@ -1,45 +0,0 @@
1
- // Resolve `exec.resources` declarations into the absolute per-task costs the
2
- // scheduler's 2-D admission gate packs against. Pure; the loader has already
3
- // validated every form (this module trusts internal code), so an unparseable
4
- // value can't reach it from a real config.
5
- //
6
- // The declared units are CPU CORES and MEGABYTES — the same numbers on this
7
- // machine and on a remote worker, which is why they are also what
8
- // `TaskPlacement.resources` hands an executor verbatim. Percent forms were
9
- // removed on 2026-08-30: a percentage names a fraction of THIS run's budget,
10
- // and an executor placing the task on someone else's machine has no way to
11
- // mean anything by it.
12
-
13
- import type { ResourceCost, TaskNode } from '../graph/index.js'
14
-
15
- const BYTES_PER_MB = 1024 * 1024
16
-
17
- /** `cpus` → CPU units. Absent → 0 = reserve nothing. */
18
- export function resolveCpu(v: number | undefined): number {
19
- return v ?? 0
20
- }
21
-
22
- /** `memory` (megabytes) → bytes, which is what the budget axis counts. */
23
- export function resolveMem(v: number | undefined): number {
24
- return v === undefined ? 0 : v * BYTES_PER_MB
25
- }
26
-
27
- /**
28
- * Resolve the whole graph's declared reservations, OMITTING zero-cost
29
- * tasks: an empty map means "no reservations declared", the single gate
30
- * the scheduler (and run.ts's option threading) keys off to keep every
31
- * current run byte-identical.
32
- */
33
- export function resolveResourceCosts(
34
- nodes: ReadonlyMap<string, TaskNode>,
35
- ): Map<string, ResourceCost> {
36
- const out = new Map<string, ResourceCost>()
37
- for (const node of nodes.values()) {
38
- const declared = node.config.exec?.resources
39
- if (declared === undefined) continue
40
- const cpu = resolveCpu(declared.cpus)
41
- const mem = resolveMem(declared.memory)
42
- if (cpu > 0 || mem > 0) out.set(node.id, { cpu, mem })
43
- }
44
- return out
45
- }
@@ -1,174 +0,0 @@
1
- // Cache-correctness verification (Phase 1: determinism). Pure helpers for the
2
- // `vx run --verify` hook in execute-task.ts: content-fingerprint a task's
3
- // output tree (mtime-independent) and diff two runs' trees, so a task whose
4
- // outputs are NOT a pure function of its declared inputs — a non-hermetic task
5
- // whose cache entry would replay arbitrary past bytes — is caught and named.
6
- // See docs/design/cache-correctness-2026-07.md.
7
-
8
- import path from 'node:path'
9
- import type { OutputFingerprint, TaskOutcome, VerifyVerdict } from '../graph/index.js'
10
- import { xxh3hex } from '../util/index.js'
11
-
12
- /** An output file to fingerprint: absolute path + a stable, run-independent
13
- * key (project-relative, or `workspace-outputs/<rel-to-root>` for workspace
14
- * outputs — matching the cache artifact's namespace so the two never collide). */
15
- export interface OutputRef {
16
- abs: string
17
- key: string
18
- }
19
-
20
- /** Build the fingerprint key set from a task's resolved output paths. */
21
- export function outputRefs(
22
- projectDir: string,
23
- outputFiles: readonly string[],
24
- workspaceRoot: string,
25
- workspaceOutputFiles: readonly string[],
26
- ): OutputRef[] {
27
- const rel = (from: string, abs: string): string =>
28
- path.relative(from, abs).split(path.sep).join('/')
29
- return [
30
- ...outputFiles.map((abs) => ({ abs, key: rel(projectDir, abs) })),
31
- ...workspaceOutputFiles.map((abs) => ({
32
- abs,
33
- key: `workspace-outputs/${rel(workspaceRoot, abs)}`,
34
- })),
35
- ]
36
- }
37
-
38
- /** Content-fingerprint an output tree: key → xxh3 of the file's BYTES, read
39
- * unconditionally from disk. Deliberately NOT `Cache.hashFile`: its
40
- * mtime+size memo would return attempt 1's digest for a re-run output with
41
- * equal size and mtime (e.g. a build that normalizes mtimes — standard
42
- * reproducible-build practice), silently proving a divergent task
43
- * deterministic. fp1/fp2 are only ever compared to each other, so any
44
- * content hash works — and the proof must not trust a cache. */
45
- export async function hashOutputTree(refs: readonly OutputRef[]): Promise<Map<string, string>> {
46
- const out = new Map<string, string>()
47
- for (const r of refs) {
48
- out.set(r.key, xxh3hex(new Uint8Array(await Bun.file(r.abs).arrayBuffer())))
49
- }
50
- return out
51
- }
52
-
53
- /** Per-file map cap on a shipped fingerprint (~40 KB at the cap). The tree
54
- * digest always folds ALL entries, so detection never depends on the map. */
55
- const FP_MAX_FILES = 500
56
-
57
- /** Roll an output-tree fingerprint map into the shippable `OutputFingerprint`
58
- * payload (Phase 4, cross-machine diff): a tree digest over ALL sorted
59
- * entries folded `key\0hash\n` (\0 boundaries so parts can't alias), plus
60
- * the first-`cap` pairs — deterministic truncation, so two machines'
61
- * truncated maps cover the same key subset and stay diffable. */
62
- export function foldFingerprint(fp: Map<string, string>, cap = FP_MAX_FILES): OutputFingerprint {
63
- const keys = [...fp.keys()].sort()
64
- let folded = ''
65
- for (const k of keys) folded += `${k}\0${fp.get(k)!}\n`
66
- return {
67
- tree: xxh3hex(folded),
68
- fileCount: keys.length,
69
- files: keys.slice(0, cap).map((k) => [k, fp.get(k)!] as const),
70
- ...(keys.length > cap ? { truncated: true } : {}),
71
- }
72
- }
73
-
74
- /** The output keys whose content OID differs between two fingerprints
75
- * (added, removed, or changed), sorted. */
76
- export function diffOutputTrees(a: Map<string, string>, b: Map<string, string>): string[] {
77
- const changed: string[] = []
78
- for (const k of new Set([...a.keys(), ...b.keys()])) {
79
- if (a.get(k) !== b.get(k)) changed.push(k)
80
- }
81
- return changed.sort()
82
- }
83
-
84
- /** Turn a determinism diff into a verdict, honoring the `--verify-allow`
85
- * escape hatch (an allowed divergence is reported but never fails the run). */
86
- export function classifyDeterminism(changed: readonly string[], allowed: boolean): VerifyVerdict {
87
- if (changed.length === 0) return { kind: 'proven-deterministic' }
88
- return allowed
89
- ? { kind: 'allowed-nondeterministic', changed }
90
- : { kind: 'nondeterministic', changed }
91
- }
92
-
93
- /** Extract the workspace-relative paths a task read outside its declared inputs
94
- * (Phase 2, `--verify=inputs`). A strace-derived violation line carries the
95
- * offending absolute path in trailing brackets (`… [/abs/path]`); pull it out,
96
- * make it workspace-relative, dedup, and sort. Lines without a bracketed path
97
- * (e.g. macOS syscall lines) fall back to the raw line so nothing is lost. */
98
- export function undeclaredInputPaths(
99
- violations: readonly { line: string }[],
100
- workspaceRoot: string,
101
- ): string[] {
102
- const rels = new Set<string>()
103
- for (const v of violations) {
104
- // Two wire shapes: Linux strace lines carry `[path]`; macOS
105
- // sandbox-exec lines end in a bare absolute path
106
- // (`node(123) deny(1) file-read-data /abs/path`). Fall back to the
107
- // raw line only when neither parses — a path the user can act on
108
- // beats a log line every time.
109
- const m = /\[([^\]]+)\]\s*$/.exec(v.line) ?? /\s(\/\S+)\s*$/.exec(v.line)
110
- const raw = m ? m[1]! : v.line
111
- rels.add(
112
- path.isAbsolute(raw) ? path.relative(workspaceRoot, raw).split(path.sep).join('/') : raw,
113
- )
114
- }
115
- return [...rels].sort()
116
- }
117
-
118
- /** The end-of-run `--verify` summary section: a counts line plus a
119
- * failures block naming each non-hermetic task and its diverging outputs.
120
- * Empty when no task carried a verdict. */
121
- export function formatVerifySection(outcomes: readonly TaskOutcome[]): string[] {
122
- const verdicts = outcomes.filter((o) => o.verify !== undefined)
123
- if (verdicts.length === 0) return []
124
- let proven = 0
125
- let bad = 0
126
- let na = 0
127
- let notVerified = 0
128
- for (const o of verdicts) {
129
- switch (o.verify!.kind) {
130
- case 'proven-deterministic':
131
- case 'proven-complete':
132
- proven++
133
- break
134
- case 'nondeterministic':
135
- case 'rerun-failed':
136
- case 'undeclared-inputs':
137
- bad++
138
- break
139
- case 'allowed-nondeterministic':
140
- case 'no-outputs':
141
- na++
142
- break
143
- case 'not-verified':
144
- notVerified++
145
- break
146
- case 'unverifiable-remote-only':
147
- na++
148
- break
149
- }
150
- }
151
- const lines = [
152
- '',
153
- ` Verify: ${proven} proven · ${bad} unsafe · ${na} n/a · ${notVerified} not-verified`,
154
- ]
155
- for (const o of outcomes) {
156
- const v = o.verify
157
- if (v === undefined) continue
158
- if (v.kind === 'nondeterministic') {
159
- lines.push(` ✗ ${o.node.id} — nondeterministic`)
160
- lines.push(` changed: ${v.changed.join(', ')}`)
161
- } else if (v.kind === 'undeclared-inputs') {
162
- lines.push(` ✗ ${o.node.id} — read undeclared inputs`)
163
- if (v.paths.length > 0) lines.push(` ${v.paths.join(', ')}`)
164
- lines.push(' add them to cache.inputs.files / workspaceFiles')
165
- } else if (v.kind === 'rerun-failed') {
166
- lines.push(` ✗ ${o.node.id} — verify re-run failed (exit ${v.exitCode})`)
167
- } else if (v.kind === 'unverifiable-remote-only') {
168
- lines.push(` ⚠ ${o.node.id} — remote-only: no local execution to prove (unverified)`)
169
- } else if (v.kind === 'allowed-nondeterministic') {
170
- lines.push(` ⚠ ${o.node.id} — nondeterministic (allowed)`)
171
- }
172
- }
173
- return lines
174
- }
@@ -1,11 +0,0 @@
1
- // Core's cache as a plugin: the `.vx/cache` handle the host opened (the run
2
- // index + the local artifact store). Declared like any other cache layer;
3
- // put a remote layer BEFORE it to look there first.
4
-
5
- import type { VxPlugin } from '@vzn/vx'
6
-
7
- export const LOCAL_CACHE_PLUGIN = 'vx/local-cache'
8
-
9
- export function localCachePlugin(): VxPlugin {
10
- return { name: LOCAL_CACHE_PLUGIN, cache: (ctx) => ctx.localCache }
11
- }
@@ -1,45 +0,0 @@
1
- // Core's executor as a plugin: spawn the task's command in-process, exactly
2
- // the `runCommand` / `runSandboxed` call the orchestrator used to make
3
- // directly. Imports core only through the public `@vzn/vx` surface so this
4
- // directory can become its own package unchanged.
5
-
6
- import { runCommand, runSandboxed, type TaskExecutor, type VxPlugin } from '@vzn/vx'
7
-
8
- export const LOCAL_EXECUTOR_PLUGIN = 'vx/local-executor'
9
-
10
- /** The executor itself — for tests and for plugins that wrap local execution. */
11
- export function localExecutor(): TaskExecutor {
12
- return {
13
- name: 'local',
14
- async execute(req) {
15
- const common = {
16
- command: req.command,
17
- cwd: req.cwd,
18
- env: req.env,
19
- forwardArgs: req.forwardArgs,
20
- onStdout: req.onStdout,
21
- onStderr: req.onStderr,
22
- capture: req.capture,
23
- ...(req.liveChildren !== undefined ? { liveChildren: req.liveChildren } : {}),
24
- ...(req.timeoutMs !== undefined ? { timeoutMs: req.timeoutMs } : {}),
25
- }
26
- if (req.sandbox === undefined) {
27
- const res = await runCommand(common)
28
- return { ...res, violations: [] }
29
- }
30
- return await runSandboxed({
31
- ...common,
32
- baseAllowRead: req.sandbox.baseAllowRead,
33
- baseAllowWrite: req.sandbox.baseAllowWrite,
34
- baseDenyRead: req.sandbox.baseDenyRead,
35
- config: req.sandbox.config,
36
- settleOnCleanExit: req.sandbox.settleOnCleanExit,
37
- })
38
- },
39
- }
40
- }
41
-
42
- /** Declare in vx.workspace.ts: `plugins: [localExecutorPlugin(), …]`. Accepts every task. */
43
- export function localExecutorPlugin(): VxPlugin {
44
- return { name: LOCAL_EXECUTOR_PLUGIN, executor: () => localExecutor() }
45
- }