@vzn/vx 0.0.644 → 0.0.645
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 +15 -23
- package/package.json +5 -5
- package/schemas/init.json +5 -0
- package/src/cache/archive.ts +12 -76
- package/src/cache/cache.ts +6 -26
- package/src/cache/index.ts +1 -2
- package/src/cache/layer.ts +5 -6
- package/src/cache/layered-cache.ts +106 -42
- package/src/cache/plugin-cache.ts +9 -14
- package/src/cache/remote-lane.ts +47 -18
- package/src/cache/scoped-cache.ts +3 -80
- package/src/cli/help.ts +4 -4
- package/src/cli/init.ts +121 -20
- package/src/cli/plan-format.ts +6 -2
- package/src/cli/plugin-commands.ts +3 -4
- package/src/cli/plugin-templates.ts +1 -1
- package/src/cli/run.ts +0 -1
- package/src/cli/upgrade.ts +1 -1
- package/src/config.ts +2 -4
- package/src/index.ts +1 -1
- package/src/orchestrator/affected-tasks.ts +0 -18
- package/src/orchestrator/index.ts +1 -0
- package/src/orchestrator/options.ts +0 -8
- package/src/orchestrator/plan.ts +7 -1
- package/src/orchestrator/plugin-host.ts +2 -5
- package/src/orchestrator/plugin.ts +11 -7
- package/src/orchestrator/prepare.ts +4 -47
- package/src/orchestrator/run-context.ts +39 -1
- package/src/orchestrator/run.ts +2 -0
- package/src/orchestrator/task-hash.ts +1 -3
- package/src/report/summary.ts +14 -2
- package/src/settings.ts +1 -7
- package/src/workspace/affected.ts +0 -39
- package/src/workspace/index.ts +0 -1
package/src/cli/init.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
// `vx init [--dry [--format json]] [--force] [--mjs] [--native|--keep]` — a workspace from nowhere.
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
// what it writes (`migrate`);
|
|
5
|
-
//
|
|
2
|
+
// It installs vx globally and, where a package.json is, as its devDependency
|
|
3
|
+
// (M, 2026-10-20). A Turbo or Nx repo is `@vzn/vx-migrate`'s, which maps it
|
|
4
|
+
// and installs what it writes (`migrate`); any other gets the workspace file
|
|
5
|
+
// and an empty root project, then the lock (`starter`).
|
|
6
6
|
// `vx init --plugin <seam>` writes a runnable plugin for one seam and its
|
|
7
7
|
// test instead (plugin-templates.ts, the examples the gate runs).
|
|
8
8
|
|
|
@@ -13,7 +13,14 @@ 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 {
|
|
16
|
+
import {
|
|
17
|
+
isCompiledBinary,
|
|
18
|
+
projectManager,
|
|
19
|
+
startedVersion,
|
|
20
|
+
targetVersion,
|
|
21
|
+
upgradeArgv,
|
|
22
|
+
type ManagedInstall,
|
|
23
|
+
} from './upgrade.js'
|
|
17
24
|
import { VERSION } from '../version.js'
|
|
18
25
|
import { DOT_VX_GITATTRIBUTES, DOT_VX_GITIGNORE, ui, UserError, sayNotice } from '../util/index.js'
|
|
19
26
|
import {
|
|
@@ -107,13 +114,96 @@ export async function initCmd(args: readonly string[]): Promise<number> {
|
|
|
107
114
|
dirs.push(d)
|
|
108
115
|
if (d === top || path.dirname(d) === d) break
|
|
109
116
|
}
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
117
|
+
if (dirs.some((d) => TOOL_CONFIGS.some((f) => existsSync(path.join(d, f))))) {
|
|
118
|
+
return migrate(dirs, parsed)
|
|
119
|
+
}
|
|
120
|
+
// The topmost package.json: a monorepo's root, wherever below it init ran.
|
|
121
|
+
const pkgRoot = dirs.findLast((d) => existsSync(path.join(d, 'package.json'))) ?? null
|
|
122
|
+
return starter(marked ?? pkgRoot ?? cwd, pkgRoot, top === null, parsed)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** What makes a repo vx-migrate's to map: a Turbo or Nx config. */
|
|
126
|
+
const TOOL_CONFIGS = ['turbo.json', 'turbo.jsonc', 'nx.json']
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* The installs `vx init` runs (M, 2026-10-20): vx at `version` globally,
|
|
130
|
+
* with the manager that started init, so a bare `vx` is on PATH, unless
|
|
131
|
+
* the global vx already answers `version`; and, where `pkgRoot` has a
|
|
132
|
+
* package.json, as its devDependency with the repo's own manager.
|
|
133
|
+
*/
|
|
134
|
+
export function initInstalls(
|
|
135
|
+
version: string,
|
|
136
|
+
userAgent: string | undefined,
|
|
137
|
+
pkgRoot: string | null,
|
|
138
|
+
globalAt: string | undefined,
|
|
139
|
+
execPath = process.execPath,
|
|
140
|
+
): { argv: string[]; cwd: string | null }[] {
|
|
141
|
+
const out: { argv: string[]; cwd: string | null }[] = []
|
|
142
|
+
if (globalAt !== version) {
|
|
143
|
+
const runner = /^(npm|pnpm|yarn|bun)\//.exec(userAgent ?? '')?.[1] ?? 'npm'
|
|
144
|
+
const manager = runner as ManagedInstall['manager']
|
|
145
|
+
out.push({ argv: upgradeArgv({ manager, scope: 'global', cwd: '' }, version), cwd: null })
|
|
146
|
+
}
|
|
147
|
+
if (pkgRoot !== null) {
|
|
148
|
+
const install: ManagedInstall = {
|
|
149
|
+
manager: projectManager(pkgRoot, execPath),
|
|
150
|
+
scope: 'project',
|
|
151
|
+
cwd: pkgRoot,
|
|
152
|
+
field: 'devDependencies',
|
|
153
|
+
}
|
|
154
|
+
out.push({ argv: upgradeArgv(install, version), cwd: pkgRoot })
|
|
155
|
+
}
|
|
156
|
+
return out
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** The version the global `vx` on PATH answers: not a runner's cache, not a project's bin. */
|
|
160
|
+
function globalVersion(): string | undefined {
|
|
161
|
+
for (const dir of (process.env['PATH'] ?? '').split(path.delimiter)) {
|
|
162
|
+
const p = dir.split(path.sep).join('/')
|
|
163
|
+
if (p.includes('/_npx/') || /\/(bunx|dlx)-/.test(p) || p.includes('/node_modules/.bin'))
|
|
164
|
+
continue
|
|
165
|
+
const bin = path.join(dir, 'vx')
|
|
166
|
+
if (existsSync(bin)) return startedVersion(bin)?.split(' ')[1]
|
|
167
|
+
}
|
|
168
|
+
return undefined
|
|
113
169
|
}
|
|
114
170
|
|
|
115
|
-
/**
|
|
116
|
-
|
|
171
|
+
/**
|
|
172
|
+
* Run `initInstalls`, in order. A source checkout (0.0.0, which npm never
|
|
173
|
+
* had) installs nothing: the release would replace the code under test.
|
|
174
|
+
*/
|
|
175
|
+
async function install(pkgRoot: string | null, dry: boolean): Promise<string[]> {
|
|
176
|
+
if (VERSION === '0.0.0') return []
|
|
177
|
+
const version = await targetVersion(undefined, 'vx init')
|
|
178
|
+
const steps = initInstalls(
|
|
179
|
+
version,
|
|
180
|
+
process.env['npm_config_user_agent'],
|
|
181
|
+
pkgRoot,
|
|
182
|
+
globalVersion(),
|
|
183
|
+
)
|
|
184
|
+
if (dry) return steps.map((s) => s.argv.join(' '))
|
|
185
|
+
for (const s of steps) {
|
|
186
|
+
const bin = Bun.which(s.argv[0]!, { PATH: process.env['PATH'] ?? '' })
|
|
187
|
+
if (bin === null) {
|
|
188
|
+
throw new UserError(
|
|
189
|
+
`vx init: ${s.argv[0]} is not on PATH; run it yourself: ${s.argv.join(' ')}`,
|
|
190
|
+
)
|
|
191
|
+
}
|
|
192
|
+
ui.print(
|
|
193
|
+
'out',
|
|
194
|
+
ui.text([ui.cmd(s.argv.join(' ')), ui.dim(s.cwd === null ? '' : ` in ${s.cwd}`)]),
|
|
195
|
+
)
|
|
196
|
+
const proc = Bun.spawnSync({
|
|
197
|
+
cmd: [bin, ...s.argv.slice(1)],
|
|
198
|
+
cwd: s.cwd ?? process.cwd(),
|
|
199
|
+
stdio: ['inherit', 'inherit', 'inherit'],
|
|
200
|
+
})
|
|
201
|
+
if (proc.exitCode !== 0) {
|
|
202
|
+
throw new UserError(`vx init: ${s.argv.join(' ')} failed (exit ${proc.exitCode})`)
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
return steps.map((s) => s.argv.join(' '))
|
|
206
|
+
}
|
|
117
207
|
|
|
118
208
|
/** Write `plugins/<seam>.ts` and its test under `root`: the workspace root, else the cwd. */
|
|
119
209
|
async function scaffoldPlugin(seam: string, root: string, args: InitArgs): Promise<number> {
|
|
@@ -151,17 +241,23 @@ const PROJECT =
|
|
|
151
241
|
"import { defineProject } from '@vzn/vx'\n\nexport default defineProject({ tasks: {} })\n"
|
|
152
242
|
|
|
153
243
|
/**
|
|
154
|
-
* No
|
|
155
|
-
* `vx.config.ts` with no task, then the lock.
|
|
156
|
-
* running vx (core-alias.ts), so the two load
|
|
157
|
-
* A file already here is kept; `--force` replaces it.
|
|
244
|
+
* No Turbo or Nx config: the installs, then `.vx/workspace.ts` declaring
|
|
245
|
+
* no plugin and a root `vx.config.ts` with no task, then the lock.
|
|
246
|
+
* `@vzn/vx` resolves to the running vx (core-alias.ts), so the two load
|
|
247
|
+
* whatever got installed. A file already here is kept; `--force` replaces it.
|
|
158
248
|
*/
|
|
159
|
-
async function starter(
|
|
249
|
+
async function starter(
|
|
250
|
+
root: string,
|
|
251
|
+
pkgRoot: string | null,
|
|
252
|
+
noGit: boolean,
|
|
253
|
+
args: InitArgs,
|
|
254
|
+
): Promise<number> {
|
|
160
255
|
if (args.mode !== undefined) {
|
|
161
256
|
throw new UserError(
|
|
162
|
-
`vx init: --${args.mode} answers a Turbo or Nx repo's question, and there is no
|
|
257
|
+
`vx init: --${args.mode} answers a Turbo or Nx repo's question, and there is no turbo.json or nx.json here`,
|
|
163
258
|
)
|
|
164
259
|
}
|
|
260
|
+
const installs = args.json === true || args.dry ? await install(pkgRoot, true) : []
|
|
165
261
|
const ext = args.mjs ? 'mjs' : 'ts'
|
|
166
262
|
const config = `vx.config.${ext}`
|
|
167
263
|
const files: { path: string; contents: string }[] = []
|
|
@@ -185,16 +281,17 @@ async function starter(root: string, noGit: boolean, args: InitArgs): Promise<nu
|
|
|
185
281
|
] as const) {
|
|
186
282
|
if (!existsSync(path.join(root, name))) files.push({ path: name, contents })
|
|
187
283
|
}
|
|
188
|
-
const vx = vxInvocation(process.env['npm_config_user_agent'], false)
|
|
284
|
+
const vx = VERSION === '0.0.0' ? vxInvocation(process.env['npm_config_user_agent'], false) : 'vx'
|
|
189
285
|
const next = `${noGit ? 'git init, ' : ''}declare a task in ${kept.find((f) => f.startsWith('vx.config.')) ?? config}, then ${vx} lock && ${vx} run <task>`
|
|
190
286
|
if (args.json === true) {
|
|
191
287
|
process.stdout.write(
|
|
192
|
-
`${JSON.stringify({ dry: true, source: 'no
|
|
288
|
+
`${JSON.stringify({ dry: true, source: 'no turbo.json or nx.json', files, kept, replaced, todos: [], notes: [], next, installs })}\n`,
|
|
193
289
|
)
|
|
194
290
|
return 0
|
|
195
291
|
}
|
|
196
|
-
const rows: ui.FieldRow[] = [['from', 'no
|
|
292
|
+
const rows: ui.FieldRow[] = [['from', 'no turbo.json or nx.json: one project, the root']]
|
|
197
293
|
if (args.dry) rows.push(['dry run', 'nothing written'])
|
|
294
|
+
if (installs.length > 0) rows.push(['install', ...installs])
|
|
198
295
|
if (files.length > 0) rows.push(['files', ...files.map((f) => f.path)])
|
|
199
296
|
if (kept.length > 0) rows.push(['kept', ...kept])
|
|
200
297
|
if (replaced.length > 0) rows.push(['replaced', ...replaced])
|
|
@@ -203,6 +300,8 @@ async function starter(root: string, noGit: boolean, args: InitArgs): Promise<nu
|
|
|
203
300
|
ui.print('out', ui.report(`vx init ${VERSION}`, [...rows, null, ['next', next]]))
|
|
204
301
|
return 0
|
|
205
302
|
}
|
|
303
|
+
const ran = await install(pkgRoot, false)
|
|
304
|
+
if (ran.length > 0) rows.push(['installed', ...ran])
|
|
206
305
|
for (const f of files) await Bun.write(path.join(root, f.path), f.contents)
|
|
207
306
|
for (const f of replaced) await unlink(path.join(root, f))
|
|
208
307
|
// The lock that follows loads the workspace file just written.
|
|
@@ -217,7 +316,7 @@ async function starter(root: string, noGit: boolean, args: InitArgs): Promise<nu
|
|
|
217
316
|
}
|
|
218
317
|
|
|
219
318
|
/**
|
|
220
|
-
* A
|
|
319
|
+
* A Turbo or Nx repo is `@vzn/vx-migrate`'s, after the global install, at the latest
|
|
221
320
|
* release: it installs @vzn/vx and every @vzn/vx-* plugin the root lists
|
|
222
321
|
* at its own version, so init always lands on latest with all of them
|
|
223
322
|
* together, as `vx upgrade` moves them (M, 2026-10-16: an installed 0.0.637
|
|
@@ -232,6 +331,8 @@ async function migrate(dirs: readonly string[], args: InitArgs): Promise<number>
|
|
|
232
331
|
args.mode !== undefined && `--${args.mode}`,
|
|
233
332
|
args.json === true && '--format=json',
|
|
234
333
|
].filter((f): f is string => typeof f === 'string')
|
|
334
|
+
// vx-migrate installs the project's own vx.
|
|
335
|
+
if (!args.dry) await install(null, false)
|
|
235
336
|
// An exact version: `bun x` may answer `@latest` from its cache.
|
|
236
337
|
const latest = VERSION === '0.0.0' ? 'latest' : await targetVersion(undefined, 'vx init')
|
|
237
338
|
return Bun.spawn(migrateCommand(dirs, flags, latest), {
|
package/src/cli/plan-format.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Formatters for `--dry` (human / JSON).
|
|
2
2
|
|
|
3
3
|
import type { AffectedReason, CacheStatus, RunPlan } from '../orchestrator/index.js'
|
|
4
|
-
import { formatElapsed, maskedCommand } from '../util/index.js'
|
|
4
|
+
import { formatBytes, formatElapsed, maskedCommand } from '../util/index.js'
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
7
|
* Human-readable preview. One line per real task (groups hidden, same
|
|
@@ -65,7 +65,11 @@ export function formatPlanText(plan: RunPlan): string {
|
|
|
65
65
|
|
|
66
66
|
const hitParts: string[] = []
|
|
67
67
|
if (local > 0) hitParts.push(`${local} local`)
|
|
68
|
-
if (remote > 0)
|
|
68
|
+
if (remote > 0) {
|
|
69
|
+
// The remote's sizes, summed: what restoring the hits would download.
|
|
70
|
+
const bytes = real.reduce((sum, t) => sum + (t.remoteBytes ?? 0), 0)
|
|
71
|
+
hitParts.push(bytes > 0 ? `${remote} remote, ${formatBytes(bytes)}` : `${remote} remote`)
|
|
72
|
+
}
|
|
69
73
|
const hits = local + remote
|
|
70
74
|
const summary: string[] = [`${real.length} task(s) planned`]
|
|
71
75
|
if (hits > 0) summary.push(`${hits} cache hit${hits === 1 ? '' : 's'} (${hitParts.join(', ')})`)
|
|
@@ -138,10 +138,8 @@ async function verbCache(
|
|
|
138
138
|
ws: Exclude<Awaited<ReturnType<typeof workspacePlugins>>, UnresolvedPluginCommand | null>,
|
|
139
139
|
plugin: string,
|
|
140
140
|
): Promise<PluginCache> {
|
|
141
|
-
const [{ Cache, pluginCache }, { resolveCache, resolveCacheScope }] =
|
|
142
|
-
import('../cache/index.js'),
|
|
143
|
-
import('../orchestrator/index.js'),
|
|
144
|
-
])
|
|
141
|
+
const [{ Cache, pluginCache }, { cacheRefs, resolveCache, resolveCacheScope }] =
|
|
142
|
+
await Promise.all([import('../cache/index.js'), import('../orchestrator/index.js')])
|
|
145
143
|
const scope = resolveCacheScope(
|
|
146
144
|
ws.workspaceRoot,
|
|
147
145
|
ws.branch.trusted === true ? null : ws.branch.scope,
|
|
@@ -162,6 +160,7 @@ async function verbCache(
|
|
|
162
160
|
cacheDir: ws.cacheDir,
|
|
163
161
|
warn,
|
|
164
162
|
scope,
|
|
163
|
+
refs: cacheRefs(ws.workspaceRoot, configuredBaseBranch(ws.workspaceConfig)),
|
|
165
164
|
localCache,
|
|
166
165
|
policy: VERB_POLICY,
|
|
167
166
|
remotes,
|
|
@@ -10,7 +10,7 @@ export const PLUGIN_TEMPLATES: Readonly<Record<string, { plugin: string; test: s
|
|
|
10
10
|
},
|
|
11
11
|
cache: {
|
|
12
12
|
plugin:
|
|
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<
|
|
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 ArtifactInfo, type RemoteCacheLayer, type VxPlugin } from '@vzn/vx'\n\nclass DirRemote implements RemoteCacheLayer {\n constructor(readonly endpoint: string) {}\n async has(hash: string): Promise<ArtifactInfo | null> {\n const file = Bun.file(path.join(this.endpoint, hash))\n return (await file.exists()) ? { size: file.size } : null\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
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: {
|
package/src/cli/run.ts
CHANGED
|
@@ -518,7 +518,6 @@ export async function resolveRunOptions(
|
|
|
518
518
|
}
|
|
519
519
|
if (parsed.profile !== undefined) opts.profile = parsed.profile
|
|
520
520
|
// `--affected`'s base is the branch's for the cache scope too.
|
|
521
|
-
if (affectedFilter !== undefined) opts.scopeBase = affectedFilter.slice(4, -1)
|
|
522
521
|
|
|
523
522
|
return opts
|
|
524
523
|
}
|
package/src/cli/upgrade.ts
CHANGED
|
@@ -475,7 +475,7 @@ function installedVersion(dir: string, pkg: string): string | undefined {
|
|
|
475
475
|
}
|
|
476
476
|
|
|
477
477
|
/** The project's package manager: its lockfile, then `packageManager`, then the tree's shape. */
|
|
478
|
-
function projectManager(dir: string, execPath: string): ManagedInstall['manager'] {
|
|
478
|
+
export function projectManager(dir: string, execPath: string): ManagedInstall['manager'] {
|
|
479
479
|
for (let d = dir; ; d = path.dirname(d)) {
|
|
480
480
|
if (existsSync(path.join(d, 'pnpm-lock.yaml'))) return 'pnpm'
|
|
481
481
|
if (existsSync(path.join(d, 'bun.lock')) || existsSync(path.join(d, 'bun.lockb'))) return 'bun'
|
package/src/config.ts
CHANGED
|
@@ -102,11 +102,9 @@ export interface CacheSettings {
|
|
|
102
102
|
* How the local cache keeps an entry. `embeddings` (default `'32KiB'`):
|
|
103
103
|
* an entry up to this size inside the index; `false`, none. `decompress` (default true): a larger one as its
|
|
104
104
|
* plain files, which a hit copies or clones out with no decode; off, as
|
|
105
|
-
* one `.tar.zst`.
|
|
106
|
-
* checks first, each file's size and mtime, every byte (`'hash'`), or
|
|
107
|
-
* nothing (`'none'`).
|
|
105
|
+
* one `.tar.zst`.
|
|
108
106
|
*/
|
|
109
|
-
store?: { embeddings?: false | string; decompress?: boolean
|
|
107
|
+
store?: { embeddings?: false | string; decompress?: boolean }
|
|
110
108
|
/**
|
|
111
109
|
* Cache transfers at once. `download` and `upload` (per remote) default
|
|
112
110
|
* to min(96, max(cores × 3, 64)); `restore`, local hits writing their
|
package/src/index.ts
CHANGED
|
@@ -229,24 +229,6 @@ export function affectedRoots(
|
|
|
229
229
|
return kept
|
|
230
230
|
}
|
|
231
231
|
|
|
232
|
-
/**
|
|
233
|
-
* The tasks of `nodes` whose key the change can move (a branch's cache
|
|
234
|
-
* scope, owner 2026-10-17): a changed path one of its inputs declares, a
|
|
235
|
-
* whole project, or a task its closure holds. Unlike `--affected`, a path
|
|
236
|
-
* no task declares (a README) reaches none: its key is the default
|
|
237
|
-
* branch's, so it reads the default key.
|
|
238
|
-
*/
|
|
239
|
-
export function keyedByChange(
|
|
240
|
-
nodes: ReadonlyMap<string, TaskNode>,
|
|
241
|
-
changes: AffectedChanges,
|
|
242
|
-
projects: ReadonlyMap<string, ProjectEntry>,
|
|
243
|
-
packageGraph: PackageGraph,
|
|
244
|
-
): Set<string> {
|
|
245
|
-
return new Set(
|
|
246
|
-
affectedRoots(nodes, [...nodes.keys()], changes, projects, packageGraph, undefined, true),
|
|
247
|
-
)
|
|
248
|
-
}
|
|
249
|
-
|
|
250
232
|
/**
|
|
251
233
|
* The requested tasks `--affected` keeps, in their order: one the user
|
|
252
234
|
* named (`pkg#task`), one in a project another include selected outright
|
|
@@ -92,14 +92,6 @@ export interface RunOptions {
|
|
|
92
92
|
remoteRequested?: boolean
|
|
93
93
|
/** This run's cache scope, above `VX_CACHE_SCOPE`, a plugin's and the branch's (an embedder's). */
|
|
94
94
|
cacheScope?: string
|
|
95
|
-
/**
|
|
96
|
-
* What the branch changed, for a scoped run: the tasks whose key it can
|
|
97
|
-
* move read the scope's key, every other task the default key (one
|
|
98
|
-
* lookup each). Absent, the run diffs the branch from `scopeBase`.
|
|
99
|
-
*/
|
|
100
|
-
scopeChanges?: AffectedChanges
|
|
101
|
-
/** The commit a scoped run diffs the branch from (`--affected`'s base); absent, a plugin's, else the merge base with the default branch. */
|
|
102
|
-
scopeBase?: string
|
|
103
95
|
/**
|
|
104
96
|
* The default policy's remote write is off (`remote:r`): the CLI passes it
|
|
105
97
|
* off CI (`CI` unset, `0` or `false`) unless `--cache-policy` or
|
package/src/orchestrator/plan.ts
CHANGED
|
@@ -54,6 +54,8 @@ export interface PlannedTask {
|
|
|
54
54
|
* eager task is the default and saying so on every line says nothing.
|
|
55
55
|
*/
|
|
56
56
|
download?: 'deferred'
|
|
57
|
+
/** A remote hit's stored size, when the remote said it: what restoring it downloads. */
|
|
58
|
+
remoteBytes?: number
|
|
57
59
|
/** Why `--affected` kept this requested task; absent without `--affected` and on a dependency it pulled in. */
|
|
58
60
|
affected?: AffectedReason
|
|
59
61
|
}
|
|
@@ -139,6 +141,7 @@ export interface PlanArgs {
|
|
|
139
141
|
*/
|
|
140
142
|
export async function plan(args: PlanArgs): Promise<RunPlan> {
|
|
141
143
|
const cacheStatusById = new Map<string, CacheStatus>()
|
|
144
|
+
const remoteBytesById = new Map<string, number>()
|
|
142
145
|
// Keyed first, probed after: a remote that can batch (`hasMany`) is
|
|
143
146
|
// asked once for every key, one that cannot is asked per key.
|
|
144
147
|
const probed = new Map<string, string>()
|
|
@@ -191,13 +194,15 @@ export async function plan(args: PlanArgs): Promise<RunPlan> {
|
|
|
191
194
|
},
|
|
192
195
|
})
|
|
193
196
|
if (probed.size > 0) {
|
|
194
|
-
await args.cache.remoteHasMany?.([...new Set(probed.values())])
|
|
197
|
+
const infos = await args.cache.remoteHasMany?.([...new Set(probed.values())])
|
|
195
198
|
for (const [id, hash] of probed) {
|
|
196
199
|
const where = await args.cache.has(hash)
|
|
197
200
|
cacheStatusById.set(
|
|
198
201
|
id,
|
|
199
202
|
where === null ? 'miss' : where === 'remote' ? 'hit-remote' : 'hit-local',
|
|
200
203
|
)
|
|
204
|
+
const size = where === 'remote' ? infos?.get(hash)?.size : undefined
|
|
205
|
+
if (size !== undefined) remoteBytesById.set(id, size)
|
|
201
206
|
}
|
|
202
207
|
}
|
|
203
208
|
|
|
@@ -225,6 +230,7 @@ export async function plan(args: PlanArgs): Promise<RunPlan> {
|
|
|
225
230
|
deps: node.deps,
|
|
226
231
|
...(executor !== undefined ? { executor } : {}),
|
|
227
232
|
...(args.downloadOf?.(id) === 'deferred' ? { download: 'deferred' as const } : {}),
|
|
233
|
+
...(remoteBytesById.has(id) ? { remoteBytes: remoteBytesById.get(id)! } : {}),
|
|
228
234
|
})
|
|
229
235
|
}
|
|
230
236
|
|
|
@@ -9,7 +9,6 @@
|
|
|
9
9
|
|
|
10
10
|
import path from 'node:path'
|
|
11
11
|
import {
|
|
12
|
-
type BranchScope,
|
|
13
12
|
type Cache,
|
|
14
13
|
type CacheLayer,
|
|
15
14
|
type CachePolicy,
|
|
@@ -556,8 +555,6 @@ export interface CacheHost extends CacheContext {
|
|
|
556
555
|
readonly policy: CachePolicy
|
|
557
556
|
/** Filled with each remote and what its policy lets it do, for the plugins' `ctx.cache`. */
|
|
558
557
|
readonly remotes?: ResolvedRemote[]
|
|
559
|
-
/** The run's branch scope, which picks each remote key. */
|
|
560
|
-
readonly branch?: BranchScope
|
|
561
558
|
/** Uploads at once, per remote (`cache.concurrency.upload`). */
|
|
562
559
|
readonly uploadConcurrency?: number
|
|
563
560
|
}
|
|
@@ -574,7 +571,7 @@ export async function resolveCache(
|
|
|
574
571
|
plugins: readonly VxPlugin[],
|
|
575
572
|
host: CacheHost,
|
|
576
573
|
): Promise<CacheLayer> {
|
|
577
|
-
const { localCache, policy, remotes,
|
|
574
|
+
const { localCache, policy, remotes, uploadConcurrency, ...ctx } = host
|
|
578
575
|
const resolved: ResolvedRemote[] = []
|
|
579
576
|
for (const plugin of plugins) {
|
|
580
577
|
if (plugin.cache === undefined) continue
|
|
@@ -593,7 +590,7 @@ export async function resolveCache(
|
|
|
593
590
|
}
|
|
594
591
|
return resolved.length === 0
|
|
595
592
|
? localCache
|
|
596
|
-
: new LayeredCache(localCache, resolved,
|
|
593
|
+
: new LayeredCache(localCache, resolved, uploadConcurrency)
|
|
597
594
|
}
|
|
598
595
|
|
|
599
596
|
/** The plugin each resolved executor came from, so a hook of it that throws is named. */
|
|
@@ -346,10 +346,9 @@ export interface WorkspaceHookContext {
|
|
|
346
346
|
* What a CI knows of the run's branch (owner, 2026-10-17): `scope`, the
|
|
347
347
|
* cache scope (`'vznjs/vx:feat-x'`; `VX_CACHE_SCOPE` beats it, and it
|
|
348
348
|
* beats the one git names), and `base`, the commit the branch is compared
|
|
349
|
-
* with (a pull request's base), for a bare `--affected`
|
|
350
|
-
*
|
|
351
|
-
*
|
|
352
|
-
* owner 2026-10-18). A later plugin's field wins.
|
|
349
|
+
* with (a pull request's base), for a bare `--affected`. `trusted`: the
|
|
350
|
+
* run has no scope, whatever git names (a merge queue's run, owner
|
|
351
|
+
* 2026-10-18). The scope keys plugin entries only. A later plugin's field wins.
|
|
353
352
|
*/
|
|
354
353
|
export interface BranchFacts {
|
|
355
354
|
scope?: string
|
|
@@ -506,11 +505,16 @@ export interface ScheduleHookContext extends BaseContext {
|
|
|
506
505
|
/** A `cache` hook's context: the workspace and the run's scope, never the local store or the policy, which core owns. */
|
|
507
506
|
export interface CacheContext extends BaseContext {
|
|
508
507
|
/**
|
|
509
|
-
* The run's cache scope (`'vznjs/vx:feat-x'`), `undefined` when none
|
|
510
|
-
*
|
|
511
|
-
* claims (an OIDC branch) for every call it makes.
|
|
508
|
+
* The run's cache scope (`'vznjs/vx:feat-x'`), `undefined` when none: what
|
|
509
|
+
* plugins' `ctx.cache` entries and stored runs are keyed apart by.
|
|
512
510
|
*/
|
|
513
511
|
readonly scope: string | undefined
|
|
512
|
+
/**
|
|
513
|
+
* The refs the run reads, in order (`['refs/heads/feat-x', 'refs/heads/main']`):
|
|
514
|
+
* its own branch, the branch it merges into, the default branch. A remote
|
|
515
|
+
* keys or scopes its entries by them; core keys task entries by none.
|
|
516
|
+
*/
|
|
517
|
+
readonly refs: readonly string[]
|
|
514
518
|
}
|
|
515
519
|
|
|
516
520
|
export interface OutputContext extends BaseContext {
|
|
@@ -26,7 +26,6 @@ import {
|
|
|
26
26
|
writesRunStore,
|
|
27
27
|
type PluginCache,
|
|
28
28
|
type ResolvedRemote,
|
|
29
|
-
BranchScope,
|
|
30
29
|
applyGitEnumeration,
|
|
31
30
|
gitPathspecs,
|
|
32
31
|
MAX_SCOPED_PATHSPECS,
|
|
@@ -34,14 +33,11 @@ import {
|
|
|
34
33
|
lazyGitEnumeration,
|
|
35
34
|
measureFsClockLag,
|
|
36
35
|
DEFAULT_STORE,
|
|
37
|
-
type StoreOptions,
|
|
38
36
|
} from '../cache/index.js'
|
|
39
37
|
import {
|
|
40
38
|
buildPackageGraph,
|
|
41
39
|
computeNestedProjectDirs,
|
|
42
40
|
findWorkspaceRoot,
|
|
43
|
-
type AffectedChanges,
|
|
44
|
-
branchChanges,
|
|
45
41
|
configuredBaseBranch,
|
|
46
42
|
type LoadReads,
|
|
47
43
|
PROJECT_CONFIG_FILENAMES,
|
|
@@ -81,13 +77,13 @@ import {
|
|
|
81
77
|
lockReader,
|
|
82
78
|
type LoadedProjects,
|
|
83
79
|
} from './projects.js'
|
|
84
|
-
import { keptByAffected
|
|
80
|
+
import { keptByAffected } from './affected-tasks.js'
|
|
85
81
|
import { applyGraphStage } from './graph-stage.js'
|
|
86
82
|
import { keyExcludedDependencies } from './excluded-keys.js'
|
|
87
83
|
import { FingerprintWatch } from './fingerprint-watch.js'
|
|
88
84
|
import type { VxPlugin } from './plugin.js'
|
|
89
85
|
import { askIgnoredOutputs, gatedOutputs } from './stable-keys.js'
|
|
90
|
-
import { currentBranch, resolveCacheScope } from './run-context.js'
|
|
86
|
+
import { cacheRefs, currentBranch, resolveCacheScope } from './run-context.js'
|
|
91
87
|
import { createHashCache, type HashCache } from './task-hash.js'
|
|
92
88
|
import { type Logger, warnOf } from '../report/index.js'
|
|
93
89
|
import type { RunOptions } from './options.js'
|
|
@@ -291,33 +287,6 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
|
|
|
291
287
|
reused !== undefined
|
|
292
288
|
? Promise.resolve(reused.projects)
|
|
293
289
|
: discoverProjects(workspaceRoot, readLock, plugins, cacheDir, warnOf(log), git)
|
|
294
|
-
// What the branch changed decides which tasks read the scope (owner,
|
|
295
|
-
// 2026-10-17). Only a scoped run's first remote call asks git: a run the
|
|
296
|
-
// local cache answers makes no remote call, and diffing beside discovery
|
|
297
|
-
// cost it 20-60 ms at 1k-5k projects.
|
|
298
|
-
const scopeChanges: (() => Promise<AffectedChanges>) | undefined =
|
|
299
|
-
cacheScope === undefined
|
|
300
|
-
? undefined
|
|
301
|
-
: options.scopeChanges !== undefined
|
|
302
|
-
? async () => options.scopeChanges!
|
|
303
|
-
: () =>
|
|
304
|
-
branchChanges(
|
|
305
|
-
workspaceRoot,
|
|
306
|
-
options.scopeBase ?? branchFacts.base,
|
|
307
|
-
baseBranch,
|
|
308
|
-
discovering,
|
|
309
|
-
{
|
|
310
|
-
untracked: async () => (await git.start()).untracked,
|
|
311
|
-
// A scoped enumeration lists only its projects: null asks git.
|
|
312
|
-
links: async () => {
|
|
313
|
-
const g = await git.start()
|
|
314
|
-
if (!g.scopes.includes('.')) return null
|
|
315
|
-
const out: string[] = []
|
|
316
|
-
for (const [rel, oid] of g.trusted) if (oid.startsWith('120000:')) out.push(rel)
|
|
317
|
-
return out
|
|
318
|
-
},
|
|
319
|
-
},
|
|
320
|
-
)
|
|
321
290
|
const projectMetas = await discovering
|
|
322
291
|
mark('discover projects')
|
|
323
292
|
|
|
@@ -383,8 +352,6 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
|
|
|
383
352
|
DEFAULT_STORE.embeddings) as number,
|
|
384
353
|
decompress: (resolveSetting('cache.store.decompress', workspaceConfig).value ??
|
|
385
354
|
DEFAULT_STORE.decompress) as boolean,
|
|
386
|
-
verify: (resolveSetting('cache.store.verify', workspaceConfig).value ??
|
|
387
|
-
DEFAULT_STORE.verify) as StoreOptions['verify'],
|
|
388
355
|
},
|
|
389
356
|
)
|
|
390
357
|
const unwritable = localCache.readOnlyNotice()
|
|
@@ -519,9 +486,6 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
|
|
|
519
486
|
// cache plugin.
|
|
520
487
|
let cache: CacheLayer
|
|
521
488
|
const remotes: ResolvedRemote[] = []
|
|
522
|
-
// A scope only picks remote keys (owner, 2026-10-17): the local store
|
|
523
|
-
// keeps every branch's entries under the task keys.
|
|
524
|
-
const branchScope = cacheScope === undefined ? undefined : new BranchScope(cacheScope)
|
|
525
489
|
if (options.remoteCache) {
|
|
526
490
|
remotes.push({
|
|
527
491
|
remote: options.remoteCache,
|
|
@@ -532,16 +496,16 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
|
|
|
532
496
|
}
|
|
533
497
|
try {
|
|
534
498
|
cache = options.remoteCache
|
|
535
|
-
? new LayeredCache(localCache, remotes,
|
|
499
|
+
? new LayeredCache(localCache, remotes, options.uploadConcurrency)
|
|
536
500
|
: await resolveCache(plugins, {
|
|
537
501
|
workspaceRoot,
|
|
538
502
|
cacheDir,
|
|
539
503
|
warn: warnOf(log),
|
|
540
504
|
scope: cacheScope,
|
|
505
|
+
refs: cacheRefs(workspaceRoot, baseBranch),
|
|
541
506
|
localCache,
|
|
542
507
|
policy,
|
|
543
508
|
remotes,
|
|
544
|
-
...(branchScope !== undefined ? { branch: branchScope } : {}),
|
|
545
509
|
...(options.uploadConcurrency !== undefined
|
|
546
510
|
? { uploadConcurrency: options.uploadConcurrency }
|
|
547
511
|
: {}),
|
|
@@ -776,13 +740,6 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
|
|
|
776
740
|
if (hasHook(plugins, 'key')) {
|
|
777
741
|
await applyKeyHooks(plugins, nodes, { workspaceRoot, cacheDir, warn: warnOf(log) })
|
|
778
742
|
}
|
|
779
|
-
// The final inputs decide which tasks the branch changed: they read
|
|
780
|
-
// the scope, every other task the default key.
|
|
781
|
-
if (branchScope !== undefined && scopeChanges !== undefined) {
|
|
782
|
-
branchScope.routeTasks(async () =>
|
|
783
|
-
keyedByChange(nodes, await scopeChanges(), projects, packageGraph),
|
|
784
|
-
)
|
|
785
|
-
}
|
|
786
743
|
refuseFiltersThroughGroups(nodes)
|
|
787
744
|
const exclude = options.excludeDependencies
|
|
788
745
|
let keyOnly: ReadonlyMap<string, TaskNode> = new Map()
|