@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.
- package/LICENSE +1 -1
- package/README.md +107 -70
- package/package.json +10 -20
- package/src/bin.ts +64 -5
- package/src/cache/archive.ts +279 -28
- package/src/cache/cache.ts +952 -1584
- package/src/cache/chained-cache.ts +14 -10
- package/src/cache/config-evals.ts +112 -0
- package/src/cache/file-hashes.ts +290 -0
- package/src/cache/git-inputs.ts +966 -0
- package/src/cache/index.ts +23 -7
- package/src/cache/inputs.ts +553 -739
- package/src/cache/key-fold.ts +232 -0
- package/src/cache/layer.ts +674 -0
- package/src/cache/layered-cache.ts +211 -49
- package/src/cache/output-index.ts +323 -0
- package/src/cache/policy.ts +84 -0
- package/src/cache/run-history.ts +152 -0
- package/src/cache/zstd.ts +183 -0
- package/src/cli/cache.ts +88 -30
- package/src/cli/completions.ts +104 -0
- package/src/cli/core-alias.ts +25 -0
- package/src/cli/format.ts +4 -15
- package/src/cli/help.ts +76 -38
- package/src/cli/index.ts +82 -17
- package/src/cli/info.ts +162 -91
- package/src/cli/init.ts +87 -0
- package/src/cli/last.ts +130 -23
- package/src/cli/lock.ts +36 -18
- package/src/cli/plan-format.ts +18 -7
- package/src/cli/plugin-commands.ts +34 -12
- package/src/cli/run.ts +138 -313
- package/src/cli/select.ts +342 -0
- package/src/cli/show.ts +151 -71
- package/src/cli/upgrade.ts +136 -15
- package/src/cli/watch.ts +1175 -116
- package/src/cli/why.ts +51 -29
- package/src/cli/workspace-config.ts +138 -0
- package/src/config.ts +183 -126
- package/src/exec/env.ts +11 -0
- package/src/exec/executor.ts +62 -31
- package/src/exec/index.ts +16 -2
- package/src/exec/kill-tree.ts +281 -0
- package/src/exec/local-executor.ts +41 -0
- package/src/exec/runner.ts +361 -77
- package/src/exec/sandbox-binds.ts +211 -0
- package/src/exec/sandbox-paths.ts +70 -0
- package/src/exec/sandbox-runtime.ts +1152 -415
- package/src/exec/sandbox-violations.ts +293 -0
- package/src/graph/dependency-spec.ts +14 -14
- package/src/graph/index.ts +6 -4
- package/src/graph/priorities.ts +130 -0
- package/src/graph/scheduler.ts +332 -329
- package/src/graph/task-graph.ts +552 -188
- package/src/index.ts +111 -102
- package/src/orchestrator/admission.ts +161 -0
- package/src/orchestrator/colors.ts +8 -3
- package/src/orchestrator/deferred-outputs.ts +25 -7
- package/src/orchestrator/doctor.ts +363 -0
- package/src/orchestrator/events.ts +111 -8
- package/src/orchestrator/excluded-keys.ts +155 -0
- package/src/orchestrator/execute-task.ts +450 -565
- package/src/orchestrator/failure-mode.ts +169 -28
- package/src/orchestrator/failure-recap.ts +121 -0
- package/src/orchestrator/fingerprint-watch.ts +89 -0
- package/src/orchestrator/framed-output.ts +121 -49
- package/src/orchestrator/history.ts +123 -104
- package/src/orchestrator/hit-restore.ts +285 -0
- package/src/orchestrator/index.ts +42 -5
- package/src/orchestrator/keyed-projects.ts +89 -0
- package/src/orchestrator/local-shortcircuit.ts +83 -23
- package/src/orchestrator/lockfile-claim.ts +401 -0
- package/src/orchestrator/logger.ts +128 -41
- package/src/orchestrator/metrics.ts +201 -72
- package/src/orchestrator/miss-save.ts +232 -0
- package/src/orchestrator/options.ts +63 -34
- package/src/orchestrator/persistent.ts +99 -0
- package/src/orchestrator/placement.ts +212 -0
- package/src/orchestrator/plan.ts +24 -11
- package/src/orchestrator/plugin-host.ts +200 -49
- package/src/orchestrator/plugin.ts +190 -14
- package/src/orchestrator/prepare.ts +168 -137
- package/src/orchestrator/projects.ts +270 -0
- package/src/orchestrator/remote-prefetch.ts +22 -2
- package/src/orchestrator/run-artifacts.ts +34 -3
- package/src/orchestrator/run-context.ts +20 -5
- package/src/orchestrator/run-lock.ts +349 -0
- package/src/orchestrator/run-records.ts +132 -0
- package/src/orchestrator/run-report.ts +6 -4
- package/src/orchestrator/run.ts +467 -611
- package/src/orchestrator/sandbox-request.ts +569 -0
- package/src/orchestrator/save-lane.ts +44 -0
- package/src/orchestrator/shell-verdict.ts +117 -0
- package/src/orchestrator/signals.ts +167 -0
- package/src/orchestrator/stable-keys.ts +259 -10
- package/src/orchestrator/status-line.ts +35 -2
- package/src/orchestrator/summary.ts +115 -25
- package/src/orchestrator/task-hash.ts +103 -21
- package/src/orchestrator/task-log-buffer.ts +15 -18
- package/src/orchestrator/telemetry-host.ts +27 -8
- package/src/orchestrator/telemetry.ts +71 -44
- package/src/orchestrator/upstream.ts +91 -45
- package/src/util/bun-version.ts +43 -0
- package/src/util/cgroup.ts +160 -0
- package/src/util/edit-distance.ts +49 -0
- package/src/util/errors.ts +86 -0
- package/src/util/hash.ts +10 -1
- package/src/util/index.ts +42 -5
- package/src/util/paths.ts +168 -5
- package/src/util/procfs.ts +20 -0
- package/src/util/settle.ts +23 -2
- package/src/util/size.ts +33 -0
- package/src/util/task-id.ts +20 -0
- package/src/util/timing.ts +11 -1
- package/src/util/verbs.ts +39 -0
- package/src/util/which.ts +48 -0
- package/src/workspace/affected.ts +368 -60
- package/src/workspace/config-cache.ts +271 -32
- package/src/workspace/config-eval.ts +60 -16
- package/src/workspace/config-imports.ts +131 -9
- package/src/workspace/config-schema.ts +1033 -0
- package/src/workspace/filter.ts +69 -17
- package/src/workspace/fingerprint.ts +59 -7
- package/src/workspace/index.ts +37 -3
- package/src/workspace/json-data.ts +90 -0
- package/src/workspace/load-reads.ts +32 -0
- package/src/workspace/lockfile.ts +41 -14
- package/src/workspace/migrate-scripts.ts +187 -0
- package/src/workspace/migration.ts +353 -0
- package/src/workspace/package-graph.ts +239 -31
- package/src/workspace/project-loader.ts +295 -815
- package/src/workspace/workspace.ts +266 -31
- package/plugins/local-cache/index.ts +0 -2
- package/plugins/local-executor/index.ts +0 -2
- package/plugins/schedule-history/index.ts +0 -2
- package/src/cache/cas-backend.ts +0 -115
- package/src/cache/digest.ts +0 -32
- package/src/cli/migrate-emit.ts +0 -21
- package/src/cli/migrate-nx.ts +0 -473
- package/src/cli/migrate-scripts.ts +0 -127
- package/src/cli/migrate-turbo.ts +0 -401
- package/src/cli/migrate.ts +0 -305
- package/src/cli/prune.ts +0 -291
- package/src/orchestrator/missing-plugin.ts +0 -9
- package/src/orchestrator/resources.ts +0 -45
- package/src/orchestrator/verify.ts +0 -174
- package/src/plugins/local-cache/index.ts +0 -11
- package/src/plugins/local-executor/index.ts +0 -45
- 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
|
-
}
|