@vzn/vx 0.0.521 → 0.0.523
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/cache.ts +16 -41
- package/src/cache/git-inputs.ts +81 -15
- package/src/cache/index.ts +0 -1
- package/src/cache/output-index.ts +15 -1
- package/src/cli/cache.ts +1 -2
- package/src/cli/last.ts +2 -3
- package/src/cli/why.ts +2 -3
- package/src/cli/workspace-config.ts +1 -2
- package/src/orchestrator/doctor.ts +1 -2
- package/src/orchestrator/prepare.ts +0 -2
- package/src/orchestrator/run-context.ts +114 -66
- package/src/orchestrator/run.ts +7 -3
- package/src/workspace/config-cache.ts +6 -2
- package/src/workspace/migration.ts +11 -17
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vzn/vx",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.523",
|
|
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.523",
|
|
25
|
+
"@vzn/vx-linux-arm64": "0.0.523",
|
|
26
|
+
"@vzn/vx-darwin-x64": "0.0.523",
|
|
27
|
+
"@vzn/vx-darwin-arm64": "0.0.523"
|
|
28
28
|
},
|
|
29
29
|
"dependencies": {
|
|
30
30
|
"@anthropic-ai/sandbox-runtime": "^0.0.76"
|
package/src/cache/cache.ts
CHANGED
|
@@ -34,6 +34,7 @@
|
|
|
34
34
|
import { Database } from 'bun:sqlite'
|
|
35
35
|
import {
|
|
36
36
|
accessSync,
|
|
37
|
+
chmodSync,
|
|
37
38
|
closeSync,
|
|
38
39
|
constants,
|
|
39
40
|
existsSync,
|
|
@@ -169,38 +170,6 @@ function outOfFdsAtOpen(dbFile: string, err: unknown): UserError | undefined {
|
|
|
169
170
|
}
|
|
170
171
|
}
|
|
171
172
|
|
|
172
|
-
/** Say once, on the channel the opener has, that an upgrade emptied the index. */
|
|
173
|
-
export function noteSchemaReset(cache: Cache, warn: (message: string) => void): void {
|
|
174
|
-
if (cache.storeFallback !== null) {
|
|
175
|
-
warn(
|
|
176
|
-
`[vx] shared cache store ${cache.storeFallback} is not usable; entries stay in ${cache.storeDir ?? 'this workspace'}, where no other workspace hits them`,
|
|
177
|
-
)
|
|
178
|
-
}
|
|
179
|
-
if (cache.storeMoved !== null) {
|
|
180
|
-
const { from, to } = cache.storeMoved
|
|
181
|
-
warn(
|
|
182
|
-
`[vx] cache entries now live in the shared store ${to}; the ones in ${from} miss once, and \`vx cache prune\` reclaims their artifacts`,
|
|
183
|
-
)
|
|
184
|
-
}
|
|
185
|
-
if (cache.storeReset !== null) {
|
|
186
|
-
const { from, to } = cache.storeReset
|
|
187
|
-
warn(
|
|
188
|
-
`[vx] shared cache store ${cache.storeDir} re-indexed: schema ${from} → ${to} (vx version changed); its artifacts stay and are indexed again as tasks hit them`,
|
|
189
|
-
)
|
|
190
|
-
}
|
|
191
|
-
if (cache.formatChange !== null) {
|
|
192
|
-
const { from, to } = cache.formatChange
|
|
193
|
-
warn(
|
|
194
|
-
`[vx] cache format changed: ${from} → ${to} (vx version changed); every cached task misses once and re-saves, and the old entries, never read again, age out under \`vx cache prune --older-than\` or \`cacheRetention\``,
|
|
195
|
-
)
|
|
196
|
-
return
|
|
197
|
-
}
|
|
198
|
-
if (cache.schemaReset === null) return
|
|
199
|
-
const { from, to } = cache.schemaReset
|
|
200
|
-
warn(
|
|
201
|
-
`[vx] cache index reset: schema ${from} → ${to} (vx version changed); run history starts over, and each artifact is indexed again when its task next hits`,
|
|
202
|
-
)
|
|
203
|
-
}
|
|
204
173
|
// SCHEMA history (drop+recreate on mismatch; pre-alpha, no migrations):
|
|
205
174
|
// v20: file_hashes.content_hash (git blob OIDs).
|
|
206
175
|
// v21: dropped the unused outputs_hash column (pure-input hashing).
|
|
@@ -459,7 +428,16 @@ function ensurePrivateDir(dir: string): string | null {
|
|
|
459
428
|
if (!st.isDirectory()) return `${dir} is not a directory`
|
|
460
429
|
const uid = process.getuid?.()
|
|
461
430
|
if (uid !== undefined && st.uid !== uid) return `${dir} belongs to another user`
|
|
462
|
-
|
|
431
|
+
// The user's own directory open to others (a `~/.vx` made at the
|
|
432
|
+
// default umask, 0755) is vx's to close: refusing it left the store
|
|
433
|
+
// unused on a stock macOS home (owner, 2026-10-06: "you should own it").
|
|
434
|
+
if ((st.mode & 0o077) !== 0) {
|
|
435
|
+
try {
|
|
436
|
+
chmodSync(dir, 0o700)
|
|
437
|
+
} catch (err) {
|
|
438
|
+
return `${dir} is open to other users and chmod 700 failed (${errorText(err)})`
|
|
439
|
+
}
|
|
440
|
+
}
|
|
463
441
|
return null
|
|
464
442
|
}
|
|
465
443
|
|
|
@@ -550,8 +528,7 @@ export class Cache implements CacheLayer {
|
|
|
550
528
|
|
|
551
529
|
/**
|
|
552
530
|
* The store this open was asked for and could not use, with why: the
|
|
553
|
-
* entries went to a store inside `cacheDir` instead.
|
|
554
|
-
* `noteSchemaReset`.
|
|
531
|
+
* entries went to a store inside `cacheDir` instead.
|
|
555
532
|
*/
|
|
556
533
|
readonly storeFallback: string | null = null
|
|
557
534
|
|
|
@@ -561,24 +538,22 @@ export class Cache implements CacheLayer {
|
|
|
561
538
|
/**
|
|
562
539
|
* Set when THIS open found an index written by another `SCHEMA_VERSION`
|
|
563
540
|
* and dropped every table. The next open sees the current version and
|
|
564
|
-
* reports null
|
|
565
|
-
*
|
|
566
|
-
* channel. Without it an upgrade empties the cache and the history in
|
|
567
|
-
* silence, and the all-miss run that follows looks like a bug.
|
|
541
|
+
* reports null. vx prints nothing for it: the cache is vx's to keep
|
|
542
|
+
* (owner, 2026-10-06).
|
|
568
543
|
*/
|
|
569
544
|
readonly schemaReset: SchemaReset | null = null
|
|
570
545
|
|
|
571
546
|
/**
|
|
572
547
|
* Set when THIS open found entries written under another
|
|
573
548
|
* `CACHE_VERSION`: the index survives, but no old key is derived again,
|
|
574
|
-
* so every cached task misses once.
|
|
549
|
+
* so every cached task misses once. Not printed, like `schemaReset`.
|
|
575
550
|
*/
|
|
576
551
|
readonly formatChange: SchemaReset | null = null
|
|
577
552
|
|
|
578
553
|
/**
|
|
579
554
|
* Set when THIS open found the shared store written under another
|
|
580
555
|
* `SCHEMA_VERSION` and dropped its inventory; the artifacts stay and are
|
|
581
|
-
* indexed again as they hit.
|
|
556
|
+
* indexed again as they hit. Never printed: the cache is vx's to keep.
|
|
582
557
|
*/
|
|
583
558
|
readonly storeReset: SchemaReset | null = null
|
|
584
559
|
|
package/src/cache/git-inputs.ts
CHANGED
|
@@ -214,6 +214,22 @@ export class GitFilesCache extends Map<string, readonly string[]> {
|
|
|
214
214
|
this.dirty = dirty
|
|
215
215
|
}
|
|
216
216
|
|
|
217
|
+
/** The populate's `git var -l`, for `configValue`. */
|
|
218
|
+
private vars: Map<string, string> | null = null
|
|
219
|
+
|
|
220
|
+
setGitVars(listing: string | null): void {
|
|
221
|
+
this.vars = listing === null ? null : gitVarList(listing)
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* A config key's value as `git config --get` answers it (the last one),
|
|
226
|
+
* from the populate's `git var -l`: null when the config has none,
|
|
227
|
+
* undefined when no listing was read.
|
|
228
|
+
*/
|
|
229
|
+
configValue(key: string): string | null | undefined {
|
|
230
|
+
return this.vars === null ? undefined : (this.vars.get(key) ?? null)
|
|
231
|
+
}
|
|
232
|
+
|
|
217
233
|
markOutputsChanged(projectDir: string, relPaths: readonly string[]): void {
|
|
218
234
|
this.recordChanged(projectDir, relPaths)
|
|
219
235
|
// The workspace-wide partition sees the same files under
|
|
@@ -774,6 +790,12 @@ async function dropFilteredOids(
|
|
|
774
790
|
pathspecs: readonly string[]
|
|
775
791
|
gitVars: string
|
|
776
792
|
spawnGit: (a: string[], stdin?: string) => Promise<GitRun | null>
|
|
793
|
+
/**
|
|
794
|
+
* The same `check-attr`, already started over every listed index entry
|
|
795
|
+
* (a superset of `trusted`) because `attributesInIndex` fired before
|
|
796
|
+
* `status` was back.
|
|
797
|
+
*/
|
|
798
|
+
early?: Promise<GitRun | null> | undefined
|
|
777
799
|
},
|
|
778
800
|
): Promise<void> {
|
|
779
801
|
if (trusted.size === 0) return
|
|
@@ -823,14 +845,48 @@ async function dropFilteredOids(
|
|
|
823
845
|
// blob's OID keyed an edit git calls clean, and the run replayed the old
|
|
824
846
|
// output (item 978). Git LFS is a `filter` as well: its files are hashed
|
|
825
847
|
// from disk now, the one honest identity of what a task reads.
|
|
826
|
-
const res = await args.spawnGit(
|
|
827
|
-
['check-attr', '--stdin', '-z', 'text', 'eol', 'ident', 'filter', 'working-tree-encoding'],
|
|
828
|
-
[...trusted.keys()].join('\0'),
|
|
829
|
-
)
|
|
848
|
+
const res = await (args.early ?? args.spawnGit(CHECK_ATTR, [...trusted.keys()].join('\0')))
|
|
830
849
|
if (res === null || res.exitCode !== 0) return
|
|
831
850
|
for (const rel of parseCheckAttrOutput(res.stdout)) trusted.delete(rel)
|
|
832
851
|
}
|
|
833
852
|
|
|
853
|
+
const CHECK_ATTR = [
|
|
854
|
+
'check-attr',
|
|
855
|
+
'--stdin',
|
|
856
|
+
'-z',
|
|
857
|
+
'text',
|
|
858
|
+
'eol',
|
|
859
|
+
'ident',
|
|
860
|
+
'filter',
|
|
861
|
+
'working-tree-encoding',
|
|
862
|
+
]
|
|
863
|
+
|
|
864
|
+
/**
|
|
865
|
+
* The filter gate's half that needs only the index listing: an attributes
|
|
866
|
+
* file tracked in it, in `$GIT_DIR/info/attributes`, or above the scanned
|
|
867
|
+
* dirs. When it fires, `dropFilteredOids` is sure to ask `check-attr`, so the
|
|
868
|
+
* enumeration starts it beside `status` (~8 ms on this repo, U-3)
|
|
869
|
+
* instead of after it. When it does not, the whole gate decides later.
|
|
870
|
+
*/
|
|
871
|
+
function attributesInIndex(args: {
|
|
872
|
+
tracked: readonly string[]
|
|
873
|
+
workspaceRoot: string
|
|
874
|
+
gitDir: string
|
|
875
|
+
gitPrefix: string
|
|
876
|
+
pathspecs: readonly string[]
|
|
877
|
+
}): boolean {
|
|
878
|
+
if (
|
|
879
|
+
args.gitDir !== '' &&
|
|
880
|
+
existsSync(path.resolve(args.workspaceRoot, args.gitDir, 'info', 'attributes'))
|
|
881
|
+
) {
|
|
882
|
+
return true
|
|
883
|
+
}
|
|
884
|
+
for (const rel of args.tracked) {
|
|
885
|
+
if (rel === '.gitattributes' || rel.endsWith('/.gitattributes')) return true
|
|
886
|
+
}
|
|
887
|
+
return attributesAbove(args)
|
|
888
|
+
}
|
|
889
|
+
|
|
834
890
|
/**
|
|
835
891
|
* The repository root, derived from the workspace root and git's own
|
|
836
892
|
* `--show-prefix` (the repo→workspace path). Exact and free: the prefix
|
|
@@ -1095,6 +1151,8 @@ export interface GitEnumeration {
|
|
|
1095
1151
|
trusted: Map<string, string>
|
|
1096
1152
|
/** Whether the worktree had uncommitted changes; null when `git status` failed. */
|
|
1097
1153
|
dirty: boolean | null
|
|
1154
|
+
/** `git var -l`'s listing (merged config, then git's variables); null when it failed. */
|
|
1155
|
+
vars: string | null
|
|
1098
1156
|
/**
|
|
1099
1157
|
* What `git status` listed, workspace-relative: modified, staged or deleted
|
|
1100
1158
|
* paths (both sides of a rename) and untracked files. Null when it failed.
|
|
@@ -1259,7 +1317,20 @@ export async function startGitEnumeration(
|
|
|
1259
1317
|
}
|
|
1260
1318
|
}
|
|
1261
1319
|
const indexKeyRead = readIndexKey()
|
|
1262
|
-
const
|
|
1320
|
+
const gitPrefix = facts?.prefix ?? ''
|
|
1321
|
+
const gitDir = facts?.commonDir ?? ''
|
|
1322
|
+
const ls = await listing
|
|
1323
|
+
const parsedLs =
|
|
1324
|
+
ls !== null && ls.exitCode === 0
|
|
1325
|
+
? parseLsFilesOutput(ls.stdout.length === 0 ? [] : ls.stdout.split('\0'), ls.undecodable)
|
|
1326
|
+
: undefined
|
|
1327
|
+
const early =
|
|
1328
|
+
parsedLs !== undefined &&
|
|
1329
|
+
parsedLs.oids.size > 0 &&
|
|
1330
|
+
attributesInIndex({ tracked: parsedLs.files, workspaceRoot, gitDir, gitPrefix, pathspecs })
|
|
1331
|
+
? spawnGit(CHECK_ATTR, [...parsedLs.oids.keys()].join('\0'))
|
|
1332
|
+
: undefined
|
|
1333
|
+
const [, status, vars] = await running
|
|
1263
1334
|
// The key stands for the index `ls-files` listed only when that index
|
|
1264
1335
|
// was written before the enumeration began (git writes it by rename, so
|
|
1265
1336
|
// the stamp moves with any write) and is still in place after it: a
|
|
@@ -1281,20 +1352,14 @@ export async function startGitEnumeration(
|
|
|
1281
1352
|
if (ls === null) {
|
|
1282
1353
|
throw gitSpawnRefusal(workspaceRoot)
|
|
1283
1354
|
}
|
|
1284
|
-
if (
|
|
1355
|
+
if (parsedLs === undefined) {
|
|
1285
1356
|
const stderr = ls.stderr.trim()
|
|
1286
1357
|
throw new UserError(
|
|
1287
1358
|
`vx requires git: ${workspaceRoot} is not inside a git work tree. ` +
|
|
1288
1359
|
`Run 'git init' in your workspace root.${stderr ? ` (git: ${stderr})` : ''}`,
|
|
1289
1360
|
)
|
|
1290
1361
|
}
|
|
1291
|
-
const {
|
|
1292
|
-
files: tracked,
|
|
1293
|
-
oids,
|
|
1294
|
-
flagged,
|
|
1295
|
-
gitlinks,
|
|
1296
|
-
undecodable,
|
|
1297
|
-
} = parseLsFilesOutput(ls.stdout.length === 0 ? [] : ls.stdout.split('\0'), ls.undecodable)
|
|
1362
|
+
const { files: tracked, oids, flagged, gitlinks, undecodable } = parsedLs
|
|
1298
1363
|
// Normalize `status`'s repo-root-relative paths to workspace-relative (strip
|
|
1299
1364
|
// the `--show-prefix`) so the dirty set is keyed identically to the trusted
|
|
1300
1365
|
// OID map. Without this, when the workspace root is a git subdir, a modified
|
|
@@ -1302,8 +1367,6 @@ export async function startGitEnumeration(
|
|
|
1302
1367
|
// a STALE cache hit serving old outputs. Empty prefix (workspace == git root,
|
|
1303
1368
|
// the common case) is a zero-cost no-op. Paths above the workspace can't be
|
|
1304
1369
|
// inputs, so they drop out of the set.
|
|
1305
|
-
const gitPrefix = facts?.prefix ?? ''
|
|
1306
|
-
const gitDir = facts?.commonDir ?? ''
|
|
1307
1370
|
const parsedStatus =
|
|
1308
1371
|
status !== null && status.exitCode === 0
|
|
1309
1372
|
? parseStatusOutput(status.stdout, status.undecodable)
|
|
@@ -1357,6 +1420,7 @@ export async function startGitEnumeration(
|
|
|
1357
1420
|
pathspecs,
|
|
1358
1421
|
gitVars: vars !== null && vars.exitCode === 0 ? vars.stdout : '',
|
|
1359
1422
|
spawnGit,
|
|
1423
|
+
early,
|
|
1360
1424
|
})
|
|
1361
1425
|
if (vars !== null && vars.exitCode === 0 && fileModeIgnored(vars.stdout)) {
|
|
1362
1426
|
restampModes(trusted, workspaceRoot)
|
|
@@ -1368,6 +1432,7 @@ export async function startGitEnumeration(
|
|
|
1368
1432
|
all,
|
|
1369
1433
|
trusted,
|
|
1370
1434
|
dirty: worktreeDirty,
|
|
1435
|
+
vars: vars !== null && vars.exitCode === 0 ? vars.stdout : null,
|
|
1371
1436
|
changed: dirty === null ? null : [...dirty, ...untracked],
|
|
1372
1437
|
untracked: dirty === null ? null : untracked,
|
|
1373
1438
|
undecodable,
|
|
@@ -1436,6 +1501,7 @@ export async function applyGitEnumeration(
|
|
|
1436
1501
|
await dropResizedOids(enumeration, memo)
|
|
1437
1502
|
const { all, trusted } = enumeration
|
|
1438
1503
|
cache.setWorktreeDirty(enumeration.dirty)
|
|
1504
|
+
cache.setGitVars(enumeration.vars)
|
|
1439
1505
|
cache.enumeratedAtMs = enumeration.startedAtMs
|
|
1440
1506
|
cache.markUndecodable(enumeration.undecodable.map((rel) => path.join(workspaceRoot, rel)))
|
|
1441
1507
|
// Sort once, then each project's files are a contiguous range found
|
package/src/cache/index.ts
CHANGED
|
@@ -8,6 +8,12 @@
|
|
|
8
8
|
|
|
9
9
|
/** `mtime_ms` of a recorded output prefix that did not exist when the snapshot was taken. */
|
|
10
10
|
const ABSENT_DIR_MTIME = -1
|
|
11
|
+
/**
|
|
12
|
+
* `mtime_ms` of a recorded output prefix that was a regular file: a literal
|
|
13
|
+
* output naming one (`public/app.js`). The set under it is the file alone
|
|
14
|
+
* while it stays a regular file; its bytes are the per-file check's.
|
|
15
|
+
*/
|
|
16
|
+
const FILE_PREFIX_MTIME = -2
|
|
11
17
|
|
|
12
18
|
import type { Database } from 'bun:sqlite'
|
|
13
19
|
import { inHashes, lazyStatement } from './schema.js'
|
|
@@ -244,7 +250,14 @@ export class OutputIndex {
|
|
|
244
250
|
}
|
|
245
251
|
return false
|
|
246
252
|
}
|
|
247
|
-
if (!st.isDirectory())
|
|
253
|
+
if (!st.isDirectory()) {
|
|
254
|
+
// A literal output naming a file walked its glob on every hit: two
|
|
255
|
+
// of this repo's site tasks, ~6 ms of each warm run (U-5).
|
|
256
|
+
if (!isPrefix || !st.isFile()) return false
|
|
257
|
+
rows.push([rel, FILE_PREFIX_MTIME])
|
|
258
|
+
files.push(rel)
|
|
259
|
+
return true
|
|
260
|
+
}
|
|
248
261
|
rows.push([rel, st.mtimeMs])
|
|
249
262
|
if (rows.length > OUTPUT_DIRS_CAP) return false
|
|
250
263
|
let entries
|
|
@@ -379,6 +392,7 @@ export class OutputIndex {
|
|
|
379
392
|
return r.mtimeMs === ABSENT_DIR_MTIME
|
|
380
393
|
}
|
|
381
394
|
if (r.mtimeMs === ABSENT_DIR_MTIME) return false
|
|
395
|
+
if (r.mtimeMs === FILE_PREFIX_MTIME) return st.isFile()
|
|
382
396
|
return st.isDirectory() && Math.abs(st.mtimeMs - r.mtimeMs) < 1
|
|
383
397
|
})
|
|
384
398
|
return results.every(Boolean)
|
package/src/cli/cache.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs'
|
|
2
|
-
import { Cache
|
|
2
|
+
import { Cache } from '../cache/index.js'
|
|
3
3
|
import { flagHint, refusedWord, seeHelp } from './help.js'
|
|
4
4
|
import { nearest, parseDuration, parseSize } from '../util/index.js'
|
|
5
5
|
import { acquireRunLock } from '../orchestrator/index.js'
|
|
@@ -189,7 +189,6 @@ async function pruneCmd(args: readonly string[]): Promise<number> {
|
|
|
189
189
|
// stack (an unprivileged user on a root-owned `.vx`, 2026-09-16). A dry
|
|
190
190
|
// run only reads, and reads a read-only cache fine.
|
|
191
191
|
if (!parsed.dryRun) cache.assertWritable()
|
|
192
|
-
noteSchemaReset(cache, warnToStderr)
|
|
193
192
|
// A prune beside a run on this workspace evicts what the run has just
|
|
194
193
|
// probed as hits, whose `accessed_at` bumps it has not flushed yet; the
|
|
195
194
|
// run then re-runs those tasks (upstream survey, nx#36688). So a prune
|
package/src/cli/last.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// Read-only — no config evaluation, no re-hash, no cache probe.
|
|
6
6
|
|
|
7
7
|
import type { Database } from 'bun:sqlite'
|
|
8
|
-
import { Cache
|
|
8
|
+
import { Cache } from '../cache/index.js'
|
|
9
9
|
import { formatBytes } from './format.js'
|
|
10
10
|
import { flagHint, seeHelp } from './help.js'
|
|
11
11
|
import {
|
|
@@ -21,7 +21,7 @@ import {
|
|
|
21
21
|
} from '../orchestrator/index.js'
|
|
22
22
|
import { formatElapsed, UserError } from '../util/index.js'
|
|
23
23
|
import { findWorkspaceRoot } from '../workspace/index.js'
|
|
24
|
-
import { cliCacheDir, parseCacheDirFlag
|
|
24
|
+
import { cliCacheDir, parseCacheDirFlag } from './workspace-config.js'
|
|
25
25
|
|
|
26
26
|
interface LastArgs {
|
|
27
27
|
runId?: string
|
|
@@ -232,7 +232,6 @@ export async function lastCmd(args: readonly string[]): Promise<number> {
|
|
|
232
232
|
|
|
233
233
|
const root = await findWorkspaceRoot(process.cwd())
|
|
234
234
|
const cache = Cache.inspect(await cliCacheDir(root, parsed.cacheDir))
|
|
235
|
-
noteSchemaReset(cache, warnToStderr)
|
|
236
235
|
try {
|
|
237
236
|
const db = cache.dbHandle()
|
|
238
237
|
// Nothing recorded at all is its own answer: `--failed` said "no
|
package/src/cli/why.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// no config evaluation, no re-hash.
|
|
7
7
|
|
|
8
8
|
import path from 'node:path'
|
|
9
|
-
import { Cache
|
|
9
|
+
import { Cache } from '../cache/index.js'
|
|
10
10
|
import { flagHint, seeHelp } from './help.js'
|
|
11
11
|
import { splitTaskId } from '../graph/index.js'
|
|
12
12
|
import {
|
|
@@ -18,7 +18,7 @@ import {
|
|
|
18
18
|
} from '../orchestrator/index.js'
|
|
19
19
|
import { MASKED, nearMatches, printable, UserError } from '../util/index.js'
|
|
20
20
|
import { findWorkspaceRoot } from '../workspace/index.js'
|
|
21
|
-
import { cliCacheDir, parseCacheDirFlag
|
|
21
|
+
import { cliCacheDir, parseCacheDirFlag } from './workspace-config.js'
|
|
22
22
|
|
|
23
23
|
interface WhyArgs {
|
|
24
24
|
target?: string
|
|
@@ -185,7 +185,6 @@ export async function whyCmd(args: readonly string[]): Promise<number> {
|
|
|
185
185
|
|
|
186
186
|
const root = await findWorkspaceRoot(process.cwd())
|
|
187
187
|
const cache = Cache.inspect(await cliCacheDir(root, parsed.cacheDir))
|
|
188
|
-
noteSchemaReset(cache, warnToStderr)
|
|
189
188
|
try {
|
|
190
189
|
const db = cache.dbHandle()
|
|
191
190
|
const taskId = resolveTarget(cache, await rootSpelled(root, parsed.target))
|
|
@@ -8,7 +8,7 @@ import { existsSync } from 'node:fs'
|
|
|
8
8
|
import path from 'node:path'
|
|
9
9
|
import type { WorkspaceConfig } from '../config.js'
|
|
10
10
|
import { UserError } from '../util/index.js'
|
|
11
|
-
import { Cache
|
|
11
|
+
import { Cache } from '../cache/index.js'
|
|
12
12
|
import { discoverProjects, loadProjects, loadWorkspacePlugins } from '../orchestrator/index.js'
|
|
13
13
|
import type { VxPlugin } from '../orchestrator/index.js'
|
|
14
14
|
import {
|
|
@@ -147,7 +147,6 @@ export async function loadCliProjects(
|
|
|
147
147
|
'open',
|
|
148
148
|
opts.cacheDir === undefined ? ws.storeRoot : null,
|
|
149
149
|
)
|
|
150
|
-
if (cache !== null) noteSchemaReset(cache, warnToStderr)
|
|
151
150
|
try {
|
|
152
151
|
const loaded = await loadProjects({
|
|
153
152
|
workspaceRoot,
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
import os from 'node:os'
|
|
9
9
|
import path from 'node:path'
|
|
10
|
-
import { Cache, CACHE_VERSION,
|
|
10
|
+
import { Cache, CACHE_VERSION, SCHEMA_VERSION } from '../cache/index.js'
|
|
11
11
|
import { PLUGIN_HOOKS } from '../config.js'
|
|
12
12
|
import {
|
|
13
13
|
cgroupCpuQuota,
|
|
@@ -154,7 +154,6 @@ async function collectWorkspaceInfo(
|
|
|
154
154
|
const metas = await discoverProjects(await loadWorkspace(root, reads), plugins, cacheDir, warn)
|
|
155
155
|
const storeRoot = opts.cacheDir === undefined ? resolveStoreRoot(root, workspaceConfig) : null
|
|
156
156
|
const cache = Cache.inspect(cacheDir)
|
|
157
|
-
noteSchemaReset(cache, warn)
|
|
158
157
|
let stats
|
|
159
158
|
let orphans
|
|
160
159
|
let flaky: FlakyTask[]
|
|
@@ -12,7 +12,6 @@ import type { WorkspaceConfig } from '../config.js'
|
|
|
12
12
|
import { beginRun, mark, nearest, UserError } from '../util/index.js'
|
|
13
13
|
import {
|
|
14
14
|
Cache,
|
|
15
|
-
noteSchemaReset,
|
|
16
15
|
type CacheLayer,
|
|
17
16
|
type CachePolicy,
|
|
18
17
|
FULL_CACHE_POLICY,
|
|
@@ -287,7 +286,6 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
|
|
|
287
286
|
storeRoot,
|
|
288
287
|
)
|
|
289
288
|
localCache.assertWritable()
|
|
290
|
-
noteSchemaReset(localCache, (m) => log.status(m))
|
|
291
289
|
// Two digests from one read: the config-evaluation cache keys on every
|
|
292
290
|
// file (a config may import a dependency), the task keys on the files no
|
|
293
291
|
// plugin claims (`VxPlugin.fingerprint`).
|
|
@@ -4,9 +4,11 @@
|
|
|
4
4
|
// unset env degrades each field to null and NEVER throws. Telemetry must
|
|
5
5
|
// not be able to fail a build.
|
|
6
6
|
//
|
|
7
|
-
// Cost:
|
|
8
|
-
//
|
|
9
|
-
// declares the `telemetry` capability — nobody else
|
|
7
|
+
// Cost: HEAD read from `.git` on every run (`captureGitContext`), plus
|
|
8
|
+
// `refs/remotes/origin/HEAD` and the enumeration's `remote.origin.url`
|
|
9
|
+
// only when a plugin declares the `telemetry` capability — nobody else
|
|
10
|
+
// needs those fields. Each falls back to a `git` spawn where the files or
|
|
11
|
+
// the enumeration cannot answer.
|
|
10
12
|
|
|
11
13
|
import fs from 'node:fs'
|
|
12
14
|
import os from 'node:os'
|
|
@@ -115,28 +117,9 @@ function readHeadDirect(
|
|
|
115
117
|
workspaceRoot: string,
|
|
116
118
|
): { commitSha: string; branch: string | null } | null {
|
|
117
119
|
try {
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
const m = /^gitdir:\s*(.+)$/m.exec(fs.readFileSync(gitDir, 'utf8'))
|
|
122
|
-
if (m === null) return null
|
|
123
|
-
gitDir = path.resolve(workspaceRoot, m[1]!.trim())
|
|
124
|
-
}
|
|
125
|
-
let commonDir = gitDir
|
|
126
|
-
const commonFile = path.join(gitDir, 'commondir')
|
|
127
|
-
if (fs.existsSync(commonFile)) {
|
|
128
|
-
commonDir = path.resolve(gitDir, fs.readFileSync(commonFile, 'utf8').trim())
|
|
129
|
-
}
|
|
130
|
-
// Reftable storage (`git init --ref-format=reftable`, git 2.45): HEAD
|
|
131
|
-
// names `refs/heads/.invalid` and the refs live in `reftable/`, so the
|
|
132
|
-
// files say nothing true about HEAD. A leftover `packed-refs` there was
|
|
133
|
-
// read as the answer; git is asked instead.
|
|
134
|
-
if (
|
|
135
|
-
fs.existsSync(path.join(gitDir, 'reftable')) ||
|
|
136
|
-
fs.existsSync(path.join(commonDir, 'reftable'))
|
|
137
|
-
) {
|
|
138
|
-
return null
|
|
139
|
-
}
|
|
120
|
+
const dirs = refDirsOf(workspaceRoot)
|
|
121
|
+
if (dirs === null) return null
|
|
122
|
+
const { gitDir, commonDir } = dirs
|
|
140
123
|
const head = fs.readFileSync(path.join(gitDir, 'HEAD'), 'utf8').trim()
|
|
141
124
|
const SHA = /^[0-9a-f]{40,64}$/
|
|
142
125
|
if (SHA.test(head)) return { commitSha: head, branch: null }
|
|
@@ -166,6 +149,57 @@ function readHeadDirect(
|
|
|
166
149
|
}
|
|
167
150
|
}
|
|
168
151
|
|
|
152
|
+
/**
|
|
153
|
+
* The git dir and common dir behind `workspaceRoot` when its refs are files
|
|
154
|
+
* this code reads: a `.git` directory or a `.git` FILE (`gitdir: …`, a linked
|
|
155
|
+
* worktree, whose shared refs live under `commondir`). Null for a reftable
|
|
156
|
+
* repository (`git init --ref-format=reftable`, git 2.45): HEAD names
|
|
157
|
+
* `refs/heads/.invalid` and the refs live in `reftable/`, so the files say
|
|
158
|
+
* nothing true. A leftover `packed-refs` there was read as the answer.
|
|
159
|
+
* Throws when there is no `.git` at the root (a workspace in a subdirectory).
|
|
160
|
+
*/
|
|
161
|
+
function refDirsOf(workspaceRoot: string): { gitDir: string; commonDir: string } | null {
|
|
162
|
+
let gitDir = path.join(workspaceRoot, '.git')
|
|
163
|
+
const st = fs.statSync(gitDir)
|
|
164
|
+
if (st.isFile()) {
|
|
165
|
+
const m = /^gitdir:\s*(.+)$/m.exec(fs.readFileSync(gitDir, 'utf8'))
|
|
166
|
+
if (m === null) return null
|
|
167
|
+
gitDir = path.resolve(workspaceRoot, m[1]!.trim())
|
|
168
|
+
}
|
|
169
|
+
let commonDir = gitDir
|
|
170
|
+
const commonFile = path.join(gitDir, 'commondir')
|
|
171
|
+
if (fs.existsSync(commonFile)) {
|
|
172
|
+
commonDir = path.resolve(gitDir, fs.readFileSync(commonFile, 'utf8').trim())
|
|
173
|
+
}
|
|
174
|
+
if (
|
|
175
|
+
fs.existsSync(path.join(gitDir, 'reftable')) ||
|
|
176
|
+
fs.existsSync(path.join(commonDir, 'reftable'))
|
|
177
|
+
) {
|
|
178
|
+
return null
|
|
179
|
+
}
|
|
180
|
+
return { gitDir, commonDir }
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* `refs/remotes/origin/HEAD`'s target from the files, as `git symbolic-ref
|
|
185
|
+
* --short` names it (`origin/main`): `undefined` when the files cannot say
|
|
186
|
+
* (no `.git` at the root, reftable), null when there is no such symbolic
|
|
187
|
+
* ref. A symbolic ref is never packed, so a missing file is git's answer
|
|
188
|
+
* too.
|
|
189
|
+
*/
|
|
190
|
+
function readOriginHeadDirect(workspaceRoot: string): string | null | undefined {
|
|
191
|
+
try {
|
|
192
|
+
const dirs = refDirsOf(workspaceRoot)
|
|
193
|
+
if (dirs === null) return undefined
|
|
194
|
+
const file = path.join(dirs.commonDir, 'refs', 'remotes', 'origin', 'HEAD')
|
|
195
|
+
if (!fs.existsSync(file)) return null
|
|
196
|
+
const ref = /^ref:\s*refs\/remotes\/(.+)$/.exec(fs.readFileSync(file, 'utf8').trim())
|
|
197
|
+
return ref === null ? undefined : ref[1]!
|
|
198
|
+
} catch {
|
|
199
|
+
return undefined
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
169
203
|
/**
|
|
170
204
|
* The branch a CI provider says this checkout came from, or null.
|
|
171
205
|
*
|
|
@@ -221,30 +255,34 @@ export function captureDefaultBranch(
|
|
|
221
255
|
}
|
|
222
256
|
}
|
|
223
257
|
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
if (
|
|
258
|
+
// The file first, as `captureGitContext` reads HEAD: a spawn here was
|
|
259
|
+
// ~8 ms of every run a telemetry plugin is declared in.
|
|
260
|
+
let ref = readOriginHeadDirect(workspaceRoot)
|
|
261
|
+
if (ref === undefined) {
|
|
262
|
+
ref = null
|
|
263
|
+
try {
|
|
264
|
+
const proc = Bun.spawnSync({
|
|
265
|
+
cmd: [
|
|
266
|
+
executablePath('git'),
|
|
267
|
+
'-C',
|
|
268
|
+
workspaceRoot,
|
|
269
|
+
'symbolic-ref',
|
|
270
|
+
'--short',
|
|
271
|
+
'refs/remotes/origin/HEAD',
|
|
272
|
+
],
|
|
273
|
+
stdout: 'pipe',
|
|
274
|
+
stderr: 'ignore',
|
|
275
|
+
})
|
|
276
|
+
if (proc.exitCode === 0) ref = new TextDecoder().decode(proc.stdout).trim()
|
|
277
|
+
} catch {
|
|
278
|
+
// git unavailable / no remote HEAD ref: null.
|
|
243
279
|
}
|
|
244
|
-
} catch {
|
|
245
|
-
// git unavailable / no remote HEAD ref: null.
|
|
246
280
|
}
|
|
247
|
-
return null
|
|
281
|
+
if (ref === null) return null
|
|
282
|
+
// `refs/remotes/origin/HEAD` → `origin/main`; strip the remote prefix.
|
|
283
|
+
const slash = ref.indexOf('/')
|
|
284
|
+
const name = slash >= 0 ? ref.slice(slash + 1) : ref
|
|
285
|
+
return name.length > 0 ? name : null
|
|
248
286
|
}
|
|
249
287
|
|
|
250
288
|
// Recognized CI env vars in match-priority order. The first whose value
|
|
@@ -324,28 +362,38 @@ export function normalizeRemoteUrl(raw: string): string {
|
|
|
324
362
|
* checkout keeps a stable identity across runs; `.vx/` is already
|
|
325
363
|
* gitignored infrastructure);
|
|
326
364
|
* 3. unwritable `.vx/` → the root path itself (stable per machine).
|
|
327
|
-
*
|
|
328
|
-
*
|
|
365
|
+
* The URL comes from the run's own `git var -l` when it has one, else one
|
|
366
|
+
* `git` spawn behind try/catch; call sites gate on telemetry being active
|
|
367
|
+
* so a plain run never pays it. Never throws.
|
|
329
368
|
*/
|
|
330
|
-
export function captureWorkspaceIdentity(
|
|
369
|
+
export function captureWorkspaceIdentity(
|
|
370
|
+
workspaceRoot: string,
|
|
371
|
+
/**
|
|
372
|
+
* `remote.origin.url` as the run's enumeration read it (`git var -l`, the
|
|
373
|
+
* same merged config): null when it has none. Undefined asks git.
|
|
374
|
+
*/
|
|
375
|
+
originUrl?: string | null,
|
|
376
|
+
): WorkspaceIdentity {
|
|
331
377
|
const base = workspaceRoot.replace(/\/+$/, '').split('/').pop() || 'workspace'
|
|
332
378
|
try {
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
if (
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
379
|
+
let url = originUrl ?? ''
|
|
380
|
+
if (originUrl === undefined) {
|
|
381
|
+
const proc = Bun.spawnSync({
|
|
382
|
+
// `config --get`, NOT `remote get-url`: get-url applies insteadOf
|
|
383
|
+
// rewrites, so two developers mirroring the same repo through
|
|
384
|
+
// different proxies would derive different workspace ids. The raw
|
|
385
|
+
// configured URL is the identity.
|
|
386
|
+
cmd: [executablePath('git'), '-C', workspaceRoot, 'config', '--get', 'remote.origin.url'],
|
|
387
|
+
stdout: 'pipe',
|
|
388
|
+
stderr: 'ignore',
|
|
389
|
+
})
|
|
390
|
+
if (proc.exitCode === 0) url = new TextDecoder().decode(proc.stdout)
|
|
391
|
+
}
|
|
392
|
+
url = url.trim()
|
|
393
|
+
if (url.length > 0) {
|
|
394
|
+
const normalized = normalizeRemoteUrl(url)
|
|
395
|
+
const name = normalized.split('/').pop() || base
|
|
396
|
+
return { id: xxh3hex(normalized), name }
|
|
349
397
|
}
|
|
350
398
|
} catch {
|
|
351
399
|
// git unavailable — fall through to the salt.
|
package/src/orchestrator/run.ts
CHANGED
|
@@ -720,9 +720,13 @@ async function runOnBus(
|
|
|
720
720
|
// but declines still pays — its answer is only knowable by asking.
|
|
721
721
|
const hasTelemetryPlugin = prepared.plugins.some((p) => p.telemetry !== undefined)
|
|
722
722
|
if (hasTelemetryPlugin || options.telemetrySinks !== undefined) {
|
|
723
|
-
// Workspace identity (telemetry v2)
|
|
724
|
-
//
|
|
725
|
-
|
|
723
|
+
// Workspace identity (telemetry v2), paid only when a telemetry
|
|
724
|
+
// consumer can exist — a plain run never reaches here. The remote URL
|
|
725
|
+
// is the enumeration's `git var -l`: a spawn here cost ~4 ms.
|
|
726
|
+
const wsIdentity = captureWorkspaceIdentity(
|
|
727
|
+
workspaceRoot,
|
|
728
|
+
gitFilesCache.configValue('remote.origin.url'),
|
|
729
|
+
)
|
|
726
730
|
runContextRecord = {
|
|
727
731
|
runId,
|
|
728
732
|
vxVersion: VERSION,
|
|
@@ -165,6 +165,10 @@ const IMPURE_LITERAL_RE = /constructor|__proto__|prototype/
|
|
|
165
165
|
const IMPORT_RE =
|
|
166
166
|
/\b(?:import|export)\b(?:(?!\b(?:import|export)\b)[^;])*?\bfrom\s*\0(\d+)\0|\bimport\s*\0(\d+)\0/g
|
|
167
167
|
|
|
168
|
+
// `import` as a keyword. Followed by `:` it is a property key (a task named
|
|
169
|
+
// `import`): no import form, static or dynamic, puts a colon after the word.
|
|
170
|
+
const IMPORT_WORD_RE = /\bimport\b(?!\s*:)/g
|
|
171
|
+
|
|
168
172
|
/**
|
|
169
173
|
* Every static import in `source` as its specifier and statement, or `null`
|
|
170
174
|
* when the source cannot be lexed or holds an `import` no form above
|
|
@@ -181,9 +185,9 @@ function staticImports(
|
|
|
181
185
|
let seen = 0
|
|
182
186
|
for (const m of code.matchAll(IMPORT_RE)) {
|
|
183
187
|
imports.push({ spec: strings[Number(m[1] ?? m[2])]!, statement: m[0] })
|
|
184
|
-
seen += m[0].match(
|
|
188
|
+
seen += m[0].match(IMPORT_WORD_RE)?.length ?? 0
|
|
185
189
|
}
|
|
186
|
-
if (seen !== (code.match(
|
|
190
|
+
if (seen !== (code.match(IMPORT_WORD_RE)?.length ?? 0)) return null
|
|
187
191
|
return { code, strings, imports }
|
|
188
192
|
}
|
|
189
193
|
|
|
@@ -289,7 +289,7 @@ export async function applyMigration(args: ApplyMigrationArgs): Promise<number>
|
|
|
289
289
|
files.push({
|
|
290
290
|
relPath: relPosix(root, abs),
|
|
291
291
|
abs,
|
|
292
|
-
contents: renderConfigFile(
|
|
292
|
+
contents: renderConfigFile(p, format),
|
|
293
293
|
})
|
|
294
294
|
}
|
|
295
295
|
for (const f of plan.extraFiles) {
|
|
@@ -302,7 +302,8 @@ export async function applyMigration(args: ApplyMigrationArgs): Promise<number>
|
|
|
302
302
|
const hasWorkspaceFile = (
|
|
303
303
|
await Promise.all(WORKSPACE_CONFIG_FILENAMES.map((n) => Bun.file(path.join(root, n)).exists()))
|
|
304
304
|
).some(Boolean)
|
|
305
|
-
|
|
305
|
+
// A plan may bring its own (vx-migrate declares the plugins it detects).
|
|
306
|
+
if (!hasWorkspaceFile && !plan.extraFiles.some((f) => f.relPath === workspaceName)) {
|
|
306
307
|
const abs = path.join(root, workspaceName)
|
|
307
308
|
files.push({ relPath: relPosix(root, abs), abs, contents: workspaceFile(format) })
|
|
308
309
|
}
|
|
@@ -550,11 +551,8 @@ function exampleConfig(format: MigrationFormat): string {
|
|
|
550
551
|
// `import { defineWorkspace } from '@vzn/vx'` loads a SECOND copy of core
|
|
551
552
|
// into every run — measured 2026-09-09 at ~17 ms on a two-package
|
|
552
553
|
// workspace, a fifth of the whole run — for an identity function.
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
// complete workspace. Add a remote cache, a remote executor or telemetry
|
|
556
|
-
// as one entry each — those imports are the runtime ones.
|
|
557
|
-
export default { plugins: [] }`
|
|
554
|
+
// No explanatory comment: the file is the user's (owner, 2026-10-06).
|
|
555
|
+
const WORKSPACE_BODY = `export default { plugins: [] }`
|
|
558
556
|
|
|
559
557
|
function workspaceFile(format: MigrationFormat): string {
|
|
560
558
|
return format === 'ts'
|
|
@@ -586,22 +584,18 @@ function renderValue(v: unknown, indent: string): string {
|
|
|
586
584
|
return `{\n${body.join('\n')}\n${indent}}`
|
|
587
585
|
}
|
|
588
586
|
|
|
589
|
-
function renderConfigFile(
|
|
590
|
-
source: string,
|
|
591
|
-
p: GeneratedProject,
|
|
592
|
-
verb: string,
|
|
593
|
-
format: MigrationFormat,
|
|
594
|
-
): string {
|
|
587
|
+
function renderConfigFile(p: GeneratedProject, format: MigrationFormat): string {
|
|
595
588
|
// A type-only import: the editor type-checks against the installed
|
|
596
589
|
// package, and Bun erases it, so the config loads in a workspace that
|
|
597
590
|
// runs the vx binary without the package installed. `mjs` is the same
|
|
598
591
|
// object with nothing to erase.
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
]
|
|
592
|
+
// No "generated by" header: the file is the user's from here (owner,
|
|
593
|
+
// 2026-10-06), and each TODO(vx-migrate) sits on its own task.
|
|
594
|
+
const lines: string[] = []
|
|
602
595
|
if (format === 'ts') lines.push("import type { ProjectConfig } from '@vzn/vx/config'")
|
|
603
596
|
if (p.importLines.length > 0) lines.push(...p.importLines)
|
|
604
|
-
lines.push(''
|
|
597
|
+
if (lines.length > 0) lines.push('')
|
|
598
|
+
lines.push('export default {')
|
|
605
599
|
if (p.tags !== undefined && p.tags.length > 0) lines.push(` tags: ${renderValue(p.tags, ' ')},`)
|
|
606
600
|
lines.push(' tasks: {')
|
|
607
601
|
for (const t of p.tasks) {
|