@vzn/vx 0.0.617 → 0.0.619
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/package.json +5 -5
- package/src/cache/archive.ts +17 -0
- package/src/cache/cache.ts +2 -1
- package/src/cache/inputs.ts +11 -2
- package/src/cache/output-index.ts +24 -1
- package/src/orchestrator/hit-restore.ts +12 -2
- package/src/orchestrator/prepare.ts +14 -7
- package/src/workspace/lockfile.ts +18 -5
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vzn/vx",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.619",
|
|
4
4
|
"description": "The fastest task runner and build cache for JavaScript monorepos.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -21,10 +21,10 @@
|
|
|
21
21
|
"node": ">=18"
|
|
22
22
|
},
|
|
23
23
|
"optionalDependencies": {
|
|
24
|
-
"@vzn/vx-linux-x64": "0.0.
|
|
25
|
-
"@vzn/vx-linux-arm64": "0.0.
|
|
26
|
-
"@vzn/vx-darwin-x64": "0.0.
|
|
27
|
-
"@vzn/vx-darwin-arm64": "0.0.
|
|
24
|
+
"@vzn/vx-linux-x64": "0.0.619",
|
|
25
|
+
"@vzn/vx-linux-arm64": "0.0.619",
|
|
26
|
+
"@vzn/vx-darwin-x64": "0.0.619",
|
|
27
|
+
"@vzn/vx-darwin-arm64": "0.0.619"
|
|
28
28
|
},
|
|
29
29
|
"dependencies": {
|
|
30
30
|
"@anthropic-ai/sandbox-runtime": "^0.0.76"
|
package/src/cache/archive.ts
CHANGED
|
@@ -689,6 +689,14 @@ class Extractor {
|
|
|
689
689
|
// resolve was a round trip spent on a comparison never made (item 627).
|
|
690
690
|
let probe = path.dirname(targetResolved)
|
|
691
691
|
while (probe.startsWith(baseResolved + path.sep)) {
|
|
692
|
+
// Nothing there resolves either: the lstat answers the common case
|
|
693
|
+
// (a clean pruned the directory) without the failed realpath's round
|
|
694
|
+
// trip and error (X-163). A dangling link is there and falls through,
|
|
695
|
+
// as does an lstat that fails otherwise: the realpath decides.
|
|
696
|
+
if (absent(probe)) {
|
|
697
|
+
probe = path.dirname(probe)
|
|
698
|
+
continue
|
|
699
|
+
}
|
|
692
700
|
const real = await realOf(probe).then(
|
|
693
701
|
(r) => r,
|
|
694
702
|
() => null,
|
|
@@ -918,6 +926,15 @@ async function linkOutError(base: string, probe: string, realBase: string): Prom
|
|
|
918
926
|
)
|
|
919
927
|
}
|
|
920
928
|
|
|
929
|
+
/** Whether nothing at all is at `p` (a dangling link is something). */
|
|
930
|
+
function absent(p: string): boolean {
|
|
931
|
+
try {
|
|
932
|
+
return lstatSync(p, { throwIfNoEntry: false }) === undefined
|
|
933
|
+
} catch {
|
|
934
|
+
return false
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
|
|
921
938
|
/**
|
|
922
939
|
* `realpath`, for a path holding a backslash too: Bun's answers ENOENT for
|
|
923
940
|
* any such path (1.4.2), so the containment walk took a link named
|
package/src/cache/cache.ts
CHANGED
|
@@ -1399,6 +1399,7 @@ export class Cache implements CacheLayer {
|
|
|
1399
1399
|
})
|
|
1400
1400
|
const liveHashes = live.map((r) => r.hash)
|
|
1401
1401
|
const fileRows = this.loadOutputFilesBatch(liveHashes)
|
|
1402
|
+
this.outputs.hold(fileRows)
|
|
1402
1403
|
const dirRows = this.loadOutputDirsBatch(liveHashes)
|
|
1403
1404
|
for (const row of live) {
|
|
1404
1405
|
this.touched.add(row.hash)
|
|
@@ -1534,7 +1535,7 @@ export class Cache implements CacheLayer {
|
|
|
1534
1535
|
// same truncated expectation against the same truncated tree and agrees
|
|
1535
1536
|
// forever. Refuse instead of silently under-restoring — checked before
|
|
1536
1537
|
// anything is renamed into place.
|
|
1537
|
-
const rows = this.
|
|
1538
|
+
const rows = this.guard(() => this.outputs.rowsOf(hash))
|
|
1538
1539
|
const expected = rows
|
|
1539
1540
|
.filter((r) => workspaceRoot !== undefined || !r.path.startsWith(WORKSPACE_OUTPUT_PREFIX))
|
|
1540
1541
|
.map((r) => (r.path.startsWith(WORKSPACE_OUTPUT_PREFIX) ? r.path : `outputs/${r.path}`))
|
package/src/cache/inputs.ts
CHANGED
|
@@ -797,8 +797,15 @@ export async function cleanOutputs(args: {
|
|
|
797
797
|
projectDir: string
|
|
798
798
|
outputs: readonly string[]
|
|
799
799
|
nestedProjectDirs: string[]
|
|
800
|
+
/**
|
|
801
|
+
* What `resolveOutputs` returned for these same arguments, when the
|
|
802
|
+
* caller walked the tree with no await since: a hit that found its tree
|
|
803
|
+
* stale walked it to say so, and a second walk here was half the
|
|
804
|
+
* restore's tree work (X-161).
|
|
805
|
+
*/
|
|
806
|
+
resolved?: readonly string[] | undefined
|
|
800
807
|
}): Promise<string[]> {
|
|
801
|
-
const files = await resolveOutputs(args)
|
|
808
|
+
const files = args.resolved ?? (await resolveOutputs(args))
|
|
802
809
|
// `force: true` makes rm tolerate ENOENT (e.g. when two output
|
|
803
810
|
// globs overlap and a sibling already deleted a path mid-iteration).
|
|
804
811
|
// A symlink is unlinked, never followed.
|
|
@@ -1131,8 +1138,10 @@ export async function resolveWorkspaceOutputs(args: {
|
|
|
1131
1138
|
export async function cleanWorkspaceOutputs(args: {
|
|
1132
1139
|
workspaceRoot: string
|
|
1133
1140
|
outputs: readonly string[]
|
|
1141
|
+
/** `cleanOutputs`'s `resolved`, from `resolveWorkspaceOutputs`. */
|
|
1142
|
+
resolved?: readonly string[] | undefined
|
|
1134
1143
|
}): Promise<string[]> {
|
|
1135
|
-
const files = await resolveWorkspaceOutputs(args)
|
|
1144
|
+
const files = args.resolved ?? (await resolveWorkspaceOutputs(args))
|
|
1136
1145
|
await pruneEmptiedDirs(
|
|
1137
1146
|
args.workspaceRoot,
|
|
1138
1147
|
await removeAll(files, args.workspaceRoot),
|
|
@@ -49,6 +49,17 @@ export class OutputIndex {
|
|
|
49
49
|
private readonly pendingDirs = new Map<string, Array<[string, number]> | null>()
|
|
50
50
|
/** Stamps taken and not yet written, flushed with the directory snapshots. */
|
|
51
51
|
private readonly pendingStamps = new Map<string, Array<[string, number, number]>>()
|
|
52
|
+
/**
|
|
53
|
+
* The rows the last batched probe loaded, by hash: a hit's restore and
|
|
54
|
+
* the stamps after it read their rows here instead of the index, twice
|
|
55
|
+
* a restore (X-162). Read for path, size, mode and mtime only; a stamp
|
|
56
|
+
* taken since is the overlay `loadOutputFilesBatch` adds. Dropped when
|
|
57
|
+
* an entry's rows are replaced, and replaced by the next probe; rows
|
|
58
|
+
* held for an entry since pruned serve a restore that finds no artifact.
|
|
59
|
+
* Another process's save under the same key is the race the hit's own
|
|
60
|
+
* skip-restore check, which reads these rows, already runs.
|
|
61
|
+
*/
|
|
62
|
+
private readonly held = new Map<string, readonly OutputFileRow[]>()
|
|
52
63
|
|
|
53
64
|
constructor(private readonly db: Database) {
|
|
54
65
|
this.insertOutputFile = lazyStatement(
|
|
@@ -108,6 +119,7 @@ export class OutputIndex {
|
|
|
108
119
|
*/
|
|
109
120
|
replaceFileRows(hash: string, rows: ReadonlyArray<[string, number, number, number]>): void {
|
|
110
121
|
// A stamp taken for the rows this replaces describes their files, not these.
|
|
122
|
+
this.held.delete(hash)
|
|
111
123
|
this.pendingStamps.delete(hash)
|
|
112
124
|
this.deleteOutputFiles.run(hash)
|
|
113
125
|
this.deleteStamps.run(hash)
|
|
@@ -116,6 +128,17 @@ export class OutputIndex {
|
|
|
116
128
|
}
|
|
117
129
|
}
|
|
118
130
|
|
|
131
|
+
/** Hold a probe's rows for its restores (`held`), in place of the last probe's. */
|
|
132
|
+
hold(rows: ReadonlyMap<string, readonly OutputFileRow[]>): void {
|
|
133
|
+
this.held.clear()
|
|
134
|
+
for (const [hash, list] of rows) this.held.set(hash, list)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** `hash`'s rows: the probe's when held, else read. Path, size, mode and mtime only. */
|
|
138
|
+
rowsOf(hash: string): readonly OutputFileRow[] {
|
|
139
|
+
return this.held.get(hash) ?? this.loadOutputFilesBatch([hash]).get(hash) ?? []
|
|
140
|
+
}
|
|
141
|
+
|
|
119
142
|
loadOutputFilesBatch(hashes: readonly string[]): Map<string, OutputFileRow[]> {
|
|
120
143
|
const out = new Map<string, OutputFileRow[]>()
|
|
121
144
|
if (hashes.length === 0) return out
|
|
@@ -298,7 +321,7 @@ export class OutputIndex {
|
|
|
298
321
|
* Written with the directory snapshots, in one transaction.
|
|
299
322
|
*/
|
|
300
323
|
recordOutputStamps(hash: string, projectDir: string, workspaceRoot: string): void {
|
|
301
|
-
const rows = this.
|
|
324
|
+
const rows = this.rowsOf(hash)
|
|
302
325
|
const stamps: Array<[string, number, number]> = []
|
|
303
326
|
for (const e of rows) {
|
|
304
327
|
const ws = e.path.startsWith(WORKSPACE_OUTPUT_PREFIX)
|
|
@@ -102,6 +102,10 @@ export async function restoreHit(restore: RestoreHitArgs): Promise<TaskOutcome>
|
|
|
102
102
|
// — 1.7 ms each on a four-task real repo whose `link` and `element`
|
|
103
103
|
// tasks declare `outputs: []` (2026-09-10, VX_TIMING).
|
|
104
104
|
let skipRestore = !anyOutputs
|
|
105
|
+
// The walk that found the tree stale, handed to the clean when nothing
|
|
106
|
+
// was awaited between them: the clean's own walk would only repeat it
|
|
107
|
+
// (X-161).
|
|
108
|
+
let walked: { proj: string[]; ws: string[] } | undefined
|
|
105
109
|
if (anyOutputs) {
|
|
106
110
|
const endRows = span('output rows')
|
|
107
111
|
const expected = hit.outputRows ?? args.cache.loadOutputFilesBatch([hash]).get(hash) ?? []
|
|
@@ -180,6 +184,7 @@ export async function restoreHit(restore: RestoreHitArgs): Promise<TaskOutcome>
|
|
|
180
184
|
treeMatches = additive
|
|
181
185
|
? rowsPresent(actualRels, projExpected) && rowsPresent(actualWsRels, wsExpected)
|
|
182
186
|
: setsMatch(actualRels, projExpected) && setsMatch(actualWsRels, wsExpected)
|
|
187
|
+
if (!treeMatches) walked = { proj: actualAbs, ws: actualWsAbs }
|
|
183
188
|
}
|
|
184
189
|
if (treeMatches) {
|
|
185
190
|
const endStat = span('output stat')
|
|
@@ -216,6 +221,7 @@ export async function restoreHit(restore: RestoreHitArgs): Promise<TaskOutcome>
|
|
|
216
221
|
})
|
|
217
222
|
endGlob()
|
|
218
223
|
skipRestore = actual.length === 0 && actualWs.length === 0
|
|
224
|
+
walked = { proj: actual, ws: actualWs }
|
|
219
225
|
}
|
|
220
226
|
}
|
|
221
227
|
if (!skipRestore) {
|
|
@@ -233,8 +239,12 @@ export async function restoreHit(restore: RestoreHitArgs): Promise<TaskOutcome>
|
|
|
233
239
|
outputs: wsOutputs,
|
|
234
240
|
})
|
|
235
241
|
} else {
|
|
236
|
-
if (outputs.length > 0)
|
|
237
|
-
|
|
242
|
+
if (outputs.length > 0) {
|
|
243
|
+
cleanedRels = await cleanOutputs({ ...cleanArgs, resolved: walked?.proj })
|
|
244
|
+
}
|
|
245
|
+
if (wsOutputs.length > 0) {
|
|
246
|
+
cleanedWsRels = await cleanWorkspaceOutputs({ ...wsCleanArgs, resolved: walked?.ws })
|
|
247
|
+
}
|
|
238
248
|
}
|
|
239
249
|
await args.cache.restoreOutputs(hash, node.projectDir, args.workspaceRoot)
|
|
240
250
|
// The tree now holds this entry's bytes: stamp them for the next hit
|
|
@@ -189,6 +189,18 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
|
|
|
189
189
|
// fingerprint. A watch cycle is a new run and reads it afresh.
|
|
190
190
|
const reads: LoadReads = new Map()
|
|
191
191
|
const workspaceRoot = await findWorkspaceRoot(options.cwd, reads)
|
|
192
|
+
// A frozen run with nothing staged reads the lock whatever discovery
|
|
193
|
+
// finds, so the read (1.1 MB at 1,000 projects, ~10 ms to read and
|
|
194
|
+
// parse) starts here and overlaps discovery's I/O instead of following
|
|
195
|
+
// the cache open (X-181). A missing or malformed lock still refuses at
|
|
196
|
+
// the config load, after any earlier refusal.
|
|
197
|
+
let lockRead: Promise<Lockfile> | undefined
|
|
198
|
+
const readLock = (): Promise<Lockfile> =>
|
|
199
|
+
(lockRead ??= readLockfile(workspaceRoot).then((read) => {
|
|
200
|
+
if (read === null) throw new UserError(FROZEN_WITHOUT_LOCK)
|
|
201
|
+
return read
|
|
202
|
+
}))
|
|
203
|
+
if (options.frozen === true && options.staged === undefined) void readLock().catch(() => {})
|
|
192
204
|
// An UNSCOPED run (no explicit scope, at least one bare task name)
|
|
193
205
|
// enumerates the whole tree whatever the configs say, so git starts
|
|
194
206
|
// HERE — the walk needs only the root — and overlaps the workspace
|
|
@@ -313,13 +325,8 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
|
|
|
313
325
|
// Read once, and only when a config is to be read from it: the CLI's
|
|
314
326
|
// selection pass may have staged every config (`options.staged`), and
|
|
315
327
|
// its load refused a frozen run without a lock. A 1,000-project lock is
|
|
316
|
-
// 1.1 MB of JSON (I-27)
|
|
317
|
-
|
|
318
|
-
const readLock = (): Promise<Lockfile> =>
|
|
319
|
-
(lockRead ??= readLockfile(workspaceRoot).then((read) => {
|
|
320
|
-
if (read === null) throw new UserError(FROZEN_WITHOUT_LOCK)
|
|
321
|
-
return read
|
|
322
|
-
}))
|
|
328
|
+
// 1.1 MB of JSON (I-27); a run with nothing staged started its read
|
|
329
|
+
// beside discovery, above.
|
|
323
330
|
const staged = options.staged
|
|
324
331
|
const allStaged =
|
|
325
332
|
staged !== undefined &&
|
|
@@ -179,13 +179,18 @@ export async function frozenProjectConfigs(
|
|
|
179
179
|
// The lock is hand-editable; the stored config crosses the same
|
|
180
180
|
// boundary a freshly evaluated one does.
|
|
181
181
|
const digest = store === undefined ? undefined : lockDigests.get(lock)
|
|
182
|
-
const
|
|
182
|
+
const prefix =
|
|
183
183
|
digest === undefined
|
|
184
184
|
? undefined
|
|
185
|
-
:
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
185
|
+
: `vx-lock-valid-v${CONFIG_EVAL_VERSION}\0${VERSION}\0${Bun.version}\0${digest}`
|
|
186
|
+
// One row for a lock whose every entry was accepted: a warm `--all` run
|
|
187
|
+
// asked for 1,090 per-project rows in one `IN` query, ~10 ms of its
|
|
188
|
+
// config load, where the eval-cache path it replaces is cheaper (X-181).
|
|
189
|
+
// A scoped run still validates, and remembers, only what it loads.
|
|
190
|
+
if (prefix !== undefined && store !== undefined && store.getConfigEval(prefix) !== null) {
|
|
191
|
+
return entries.map((e) => e.config)
|
|
192
|
+
}
|
|
193
|
+
const keys = prefix === undefined ? undefined : metas.map((m) => `${prefix}\0${m.name}`)
|
|
189
194
|
const known =
|
|
190
195
|
keys === undefined || store === undefined
|
|
191
196
|
? new Map<string, string>()
|
|
@@ -199,6 +204,14 @@ export async function frozenProjectConfigs(
|
|
|
199
204
|
validateProjectConfig(entries[i]!.config, `${LOCKFILE_NAME} (${metas[i]!.name})`)
|
|
200
205
|
if (key !== undefined) accepted.push([key, '1'])
|
|
201
206
|
}
|
|
207
|
+
// Every meta found its entry above, so as many distinct names as the
|
|
208
|
+
// lock holds is every entry of it.
|
|
209
|
+
if (
|
|
210
|
+
prefix !== undefined &&
|
|
211
|
+
new Set(metas.map((m) => m.name)).size === Object.keys(lock.projects).length
|
|
212
|
+
) {
|
|
213
|
+
accepted.push([prefix, '1'])
|
|
214
|
+
}
|
|
202
215
|
if (store !== undefined && accepted.length > 0) {
|
|
203
216
|
if (store.putConfigEvals !== undefined) store.putConfigEvals(accepted)
|
|
204
217
|
else for (const [k, v] of accepted) store.putConfigEval(k, v)
|