@vzn/vx 0.0.511 → 0.0.513
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 +204 -11
- package/src/cache/output-index.ts +37 -7
- package/src/cache/schema.ts +53 -28
- package/src/cli/info.ts +1 -0
- package/src/cli/workspace-config.ts +17 -2
- package/src/config.ts +5 -1
- package/src/exec/env.ts +53 -0
- package/src/exec/index.ts +7 -1
- package/src/orchestrator/doctor.ts +6 -0
- package/src/orchestrator/execute-task.ts +7 -0
- package/src/orchestrator/prepare.ts +6 -0
- package/src/workspace/index.ts +1 -0
- package/src/workspace/repo-id.ts +200 -0
- package/src/workspace/workspace.ts +26 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vzn/vx",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.513",
|
|
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.513",
|
|
25
|
+
"@vzn/vx-linux-arm64": "0.0.513",
|
|
26
|
+
"@vzn/vx-darwin-x64": "0.0.513",
|
|
27
|
+
"@vzn/vx-darwin-arm64": "0.0.513"
|
|
28
28
|
},
|
|
29
29
|
"dependencies": {
|
|
30
30
|
"@anthropic-ai/sandbox-runtime": "^0.0.76"
|
package/src/cache/cache.ts
CHANGED
|
@@ -20,6 +20,12 @@
|
|
|
20
20
|
// for the user to see — but on a cache hit there's nothing to replay
|
|
21
21
|
// (the original run was successful and stderr typically empty).
|
|
22
22
|
//
|
|
23
|
+
// With a shared store (v32, the default: `docs/design/shared-store-2026-10.md`)
|
|
24
|
+
// the entries and their artifacts live in `<store>/store.db` and
|
|
25
|
+
// `<store>/<hash>.tar.zst`, attached to the workspace's own `cache.db`,
|
|
26
|
+
// which keeps the history and the memos: every workspace of one user hits
|
|
27
|
+
// what another saved, and none reads another's runs.
|
|
28
|
+
//
|
|
23
29
|
// This is core's FLOOR, not a module to replace: remote storage is a
|
|
24
30
|
// `RemoteCacheLayer` (has / get / put) that `LayeredCache` wraps around
|
|
25
31
|
// this handle, declared by a plugin's `cache` hook. The contract every
|
|
@@ -31,6 +37,7 @@ import {
|
|
|
31
37
|
closeSync,
|
|
32
38
|
constants,
|
|
33
39
|
existsSync,
|
|
40
|
+
lstatSync,
|
|
34
41
|
mkdirSync,
|
|
35
42
|
openSync,
|
|
36
43
|
renameSync,
|
|
@@ -163,6 +170,17 @@ function outOfFdsAtOpen(dbFile: string, err: unknown): UserError | undefined {
|
|
|
163
170
|
|
|
164
171
|
/** Say once, on the channel the opener has, that an upgrade emptied the index. */
|
|
165
172
|
export function noteSchemaReset(cache: Cache, warn: (message: string) => void): void {
|
|
173
|
+
if (cache.storeFallback !== null) {
|
|
174
|
+
warn(
|
|
175
|
+
`[vx] shared cache store ${cache.storeFallback} is not usable; entries stay in ${cache.storeDir ?? 'this workspace'}, where no other workspace hits them`,
|
|
176
|
+
)
|
|
177
|
+
}
|
|
178
|
+
if (cache.storeMoved !== null) {
|
|
179
|
+
const { from, to } = cache.storeMoved
|
|
180
|
+
warn(
|
|
181
|
+
`[vx] cache entries now live in the shared store ${to}; the ones in ${from} miss once, and \`vx cache prune\` reclaims their artifacts`,
|
|
182
|
+
)
|
|
183
|
+
}
|
|
166
184
|
if (cache.formatChange !== null) {
|
|
167
185
|
const { from, to } = cache.formatChange
|
|
168
186
|
warn(
|
|
@@ -237,7 +255,15 @@ export function noteSchemaReset(cache: Cache, warn: (message: string) => void):
|
|
|
237
255
|
// restored_remote counts — a hit that restored outputs apart from
|
|
238
256
|
// one that found them in place, for every reader after the run.
|
|
239
257
|
// The cache KEY is unchanged.
|
|
240
|
-
|
|
258
|
+
// v32: the entry tables (entries, entry_stdout, output_files,
|
|
259
|
+
// entry_inputs) may live in a shared store attached as `store`;
|
|
260
|
+
// output_files' ino + ctime_ms moved to the workspace's own
|
|
261
|
+
// output_stamps, output_dirs lost its foreign key, and the value
|
|
262
|
+
// salt moved to store_meta. The cache KEY is unchanged.
|
|
263
|
+
export const SCHEMA_VERSION = 'v32'
|
|
264
|
+
|
|
265
|
+
/** The tables a store holds: dropped from a workspace index that held them itself. */
|
|
266
|
+
const STORE_TABLES = ['entry_inputs', 'output_files', 'entry_stdout', 'store_meta', 'entries']
|
|
241
267
|
|
|
242
268
|
/** An entry row with its stdout, which lives apart (v29); none stored reads as ''. */
|
|
243
269
|
const SELECT_ENTRY =
|
|
@@ -403,6 +429,39 @@ function openCacheDir(cacheDir: string): string | null {
|
|
|
403
429
|
return null
|
|
404
430
|
}
|
|
405
431
|
|
|
432
|
+
/** `openCacheDir` for the shared store: why it cannot be used, or null. Never throws. */
|
|
433
|
+
function storeBlocked(storeDir: string, storeRoot: string | null): string | null {
|
|
434
|
+
try {
|
|
435
|
+
if (storeRoot !== null) {
|
|
436
|
+
// `~/.vx`, `~/.vx/<id>`, `~/.vx/<id>/cache`: owner-only, each level
|
|
437
|
+
// made alone and re-checked, as Nx makes `~/.nx/<id>`. Artifacts are
|
|
438
|
+
// replayed into the workspace, so nobody else may write them.
|
|
439
|
+
const vxHome = path.dirname(path.dirname(storeRoot))
|
|
440
|
+
for (const dir of [vxHome, path.dirname(storeRoot), storeRoot]) {
|
|
441
|
+
const refused = ensurePrivateDir(dir)
|
|
442
|
+
if (refused !== null) return refused
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
return openCacheDir(storeDir)
|
|
446
|
+
} catch (err) {
|
|
447
|
+
return errorText(err)
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
function ensurePrivateDir(dir: string): string | null {
|
|
452
|
+
try {
|
|
453
|
+
mkdirSync(dir, { mode: 0o700 })
|
|
454
|
+
} catch (err) {
|
|
455
|
+
if ((err as NodeJS.ErrnoException).code !== 'EEXIST') return errorText(err)
|
|
456
|
+
}
|
|
457
|
+
const st = lstatSync(dir)
|
|
458
|
+
if (!st.isDirectory()) return `${dir} is not a directory`
|
|
459
|
+
const uid = process.getuid?.()
|
|
460
|
+
if (uid !== undefined && st.uid !== uid) return `${dir} belongs to another user`
|
|
461
|
+
if ((st.mode & 0o077) !== 0) return `${dir} is open to other users (chmod 700 it)`
|
|
462
|
+
return null
|
|
463
|
+
}
|
|
464
|
+
|
|
406
465
|
// Make the cache dir invisible to git, every time it is created: a `*`
|
|
407
466
|
// .gitignore inside it (the Cargo / Nx convention). Two reasons, both
|
|
408
467
|
// measured. A cache nobody ignored gets COMMITTED by the next `git add -A`;
|
|
@@ -482,6 +541,21 @@ export class Cache implements CacheLayer {
|
|
|
482
541
|
/** Why this process cannot write into `cacheDir`, or `null`; decided at open. */
|
|
483
542
|
private readonly writeBlocked: string | null
|
|
484
543
|
private readonly dbFile: string
|
|
544
|
+
/** Where the artifacts live: the shared store, or `cacheDir` itself. */
|
|
545
|
+
private readonly artifactDir: string
|
|
546
|
+
|
|
547
|
+
/** The shared store this index attached, or undefined when it holds its entries itself. */
|
|
548
|
+
readonly storeDir: string | undefined
|
|
549
|
+
|
|
550
|
+
/**
|
|
551
|
+
* The store this open was asked for and could not use, with why: the
|
|
552
|
+
* entries went to a store inside `cacheDir` instead. Said once by
|
|
553
|
+
* `noteSchemaReset`.
|
|
554
|
+
*/
|
|
555
|
+
readonly storeFallback: string | null = null
|
|
556
|
+
|
|
557
|
+
/** Set when this open moved a workspace index's own entries out for a shared store. */
|
|
558
|
+
readonly storeMoved: { from: string; to: string } | null = null
|
|
485
559
|
|
|
486
560
|
/**
|
|
487
561
|
* Set when THIS open found an index written by another `SCHEMA_VERSION`
|
|
@@ -557,6 +631,15 @@ export class Cache implements CacheLayer {
|
|
|
557
631
|
* and left as it was.
|
|
558
632
|
*/
|
|
559
633
|
mode: 'open' | 'inspect' = 'open',
|
|
634
|
+
/**
|
|
635
|
+
* The shared store's directory, unversioned: every key is seeded with
|
|
636
|
+
* `CACHE_VERSION`, so two vx versions never read each other's entries,
|
|
637
|
+
* and a store table only ever gains columns (owner, 2026-10-06). Or
|
|
638
|
+
* `null` for an index that holds its entries itself (a `cacheDir`).
|
|
639
|
+
* Undefined follows the layout the index records: a reading verb, a
|
|
640
|
+
* plugin's handle.
|
|
641
|
+
*/
|
|
642
|
+
storeRoot?: string | null,
|
|
560
643
|
) {
|
|
561
644
|
this.inspecting = mode === 'inspect'
|
|
562
645
|
this.read = localPolicy.read
|
|
@@ -693,8 +776,11 @@ export class Cache implements CacheLayer {
|
|
|
693
776
|
return null
|
|
694
777
|
}
|
|
695
778
|
if (found === SCHEMA_VERSION) return null
|
|
779
|
+
// `main.` on every name: unqualified, a table the index lacks
|
|
780
|
+
// resolves to an attached store's (none is attached yet; this
|
|
781
|
+
// keeps it so).
|
|
696
782
|
this.db.exec(
|
|
697
|
-
'DROP TABLE IF EXISTS entries; DROP TABLE IF EXISTS runs; DROP TABLE IF EXISTS file_hashes; DROP TABLE IF EXISTS blob_sizes; DROP TABLE IF EXISTS blob_verdicts; DROP TABLE IF EXISTS output_files; DROP TABLE IF EXISTS invocations; DROP TABLE IF EXISTS run_task_inputs; DROP TABLE IF EXISTS entry_inputs; DROP TABLE IF EXISTS config_evals; DROP TABLE IF EXISTS config_closures; DROP TABLE IF EXISTS output_dirs; DROP TABLE IF EXISTS entry_stdout;',
|
|
783
|
+
'DROP TABLE IF EXISTS main.entries; DROP TABLE IF EXISTS main.runs; DROP TABLE IF EXISTS main.file_hashes; DROP TABLE IF EXISTS main.blob_sizes; DROP TABLE IF EXISTS main.blob_verdicts; DROP TABLE IF EXISTS main.output_files; DROP TABLE IF EXISTS main.output_stamps; DROP TABLE IF EXISTS main.invocations; DROP TABLE IF EXISTS main.run_task_inputs; DROP TABLE IF EXISTS main.entry_inputs; DROP TABLE IF EXISTS main.config_evals; DROP TABLE IF EXISTS main.config_closures; DROP TABLE IF EXISTS main.output_dirs; DROP TABLE IF EXISTS main.entry_stdout; DROP TABLE IF EXISTS main.store_meta;',
|
|
698
784
|
)
|
|
699
785
|
this.db
|
|
700
786
|
.prepare("UPDATE schema_meta SET value = ? WHERE key = 'version'")
|
|
@@ -712,7 +798,71 @@ export class Cache implements CacheLayer {
|
|
|
712
798
|
}
|
|
713
799
|
}
|
|
714
800
|
|
|
715
|
-
|
|
801
|
+
// Where the entry tables live (v32): `store_dir` names the shared store
|
|
802
|
+
// this index was last opened with; absent, the index holds them itself.
|
|
803
|
+
const recorded = readable(
|
|
804
|
+
() =>
|
|
805
|
+
(
|
|
806
|
+
this.db.prepare("SELECT value FROM schema_meta WHERE key = 'store_dir'").get() as {
|
|
807
|
+
value: string
|
|
808
|
+
} | null
|
|
809
|
+
)?.value,
|
|
810
|
+
)
|
|
811
|
+
let storeDir =
|
|
812
|
+
mode === 'inspect' || storeRoot === undefined
|
|
813
|
+
? recorded
|
|
814
|
+
: storeRoot === null
|
|
815
|
+
? undefined
|
|
816
|
+
: storeRoot
|
|
817
|
+
if (storeDir !== undefined && mode === 'open' && this.writeBlocked === null) {
|
|
818
|
+
const blocked = storeBlocked(storeDir, typeof storeRoot === 'string' ? storeRoot : null)
|
|
819
|
+
if (blocked !== null) {
|
|
820
|
+
// A home this user cannot write (a container, a read-only mount):
|
|
821
|
+
// the entries stay in this workspace rather than fail the run.
|
|
822
|
+
const fallback = cacheDir
|
|
823
|
+
if (fallback !== storeDir) {
|
|
824
|
+
// Said by the open that falls back; the ones after it find it recorded.
|
|
825
|
+
if (recorded !== fallback) this.storeFallback = `${storeDir} (${blocked})`
|
|
826
|
+
storeDir = fallback
|
|
827
|
+
openCacheDir(fallback)
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
if (storeDir !== undefined && mode === 'open' && this.writeBlocked === null) {
|
|
832
|
+
// An index that held its entries itself would shadow the store's:
|
|
833
|
+
// SQLite resolves an unqualified name in `main` first. Its history
|
|
834
|
+
// and memos stay; its entries' artifacts are orphans `prune` reaps.
|
|
835
|
+
const holds = readable(
|
|
836
|
+
() =>
|
|
837
|
+
this.db
|
|
838
|
+
.prepare("SELECT 1 FROM main.sqlite_master WHERE type = 'table' AND name = 'entries'")
|
|
839
|
+
.get() != null,
|
|
840
|
+
)
|
|
841
|
+
if (holds) {
|
|
842
|
+
const had = this.db.prepare('SELECT 1 FROM main.entries LIMIT 1').get() != null
|
|
843
|
+
this.db
|
|
844
|
+
.transaction(() => {
|
|
845
|
+
for (const t of STORE_TABLES) this.db.exec(`DROP TABLE IF EXISTS main.${t}`)
|
|
846
|
+
})
|
|
847
|
+
.immediate()
|
|
848
|
+
if (had) this.storeMoved = { from: cacheDir, to: storeDir }
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
this.storeDir = storeDir
|
|
852
|
+
this.artifactDir = storeDir ?? cacheDir
|
|
853
|
+
if (storeDir !== undefined) this.attachStore(storeDir, mode === 'inspect')
|
|
854
|
+
|
|
855
|
+
createTables(this.db, storeDir === undefined ? 'main' : 'store')
|
|
856
|
+
if (mode === 'open' && this.writeBlocked === null && storeDir !== recorded) {
|
|
857
|
+
if (storeDir === undefined)
|
|
858
|
+
this.db.prepare("DELETE FROM schema_meta WHERE key = 'store_dir'").run()
|
|
859
|
+
else
|
|
860
|
+
this.db
|
|
861
|
+
.prepare(
|
|
862
|
+
"INSERT INTO schema_meta(key, value) VALUES ('store_dir', ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
|
863
|
+
)
|
|
864
|
+
.run(storeDir)
|
|
865
|
+
}
|
|
716
866
|
|
|
717
867
|
this.deleteEntryRow = lazyStatement(this.db, 'DELETE FROM entries WHERE hash = ?')
|
|
718
868
|
this.insertEntry = lazyStatement(
|
|
@@ -800,6 +950,33 @@ export class Cache implements CacheLayer {
|
|
|
800
950
|
}
|
|
801
951
|
}
|
|
802
952
|
|
|
953
|
+
/**
|
|
954
|
+
* Attach the shared store as `store`, with the pragmas the index takes:
|
|
955
|
+
* WAL for both, so a workspace's run and another's share the store as
|
|
956
|
+
* two runs on one `--cache-dir` share an index. A reading verb over a
|
|
957
|
+
* store with no file yet reads an empty one in memory.
|
|
958
|
+
*/
|
|
959
|
+
private attachStore(storeDir: string, inspecting: boolean): void {
|
|
960
|
+
const storeFile = path.join(storeDir, 'store.db')
|
|
961
|
+
const absent = inspecting && !existsSync(storeFile)
|
|
962
|
+
try {
|
|
963
|
+
this.db.prepare('ATTACH DATABASE ? AS store').run(absent ? ':memory:' : storeFile)
|
|
964
|
+
this.db.exec('PRAGMA store.journal_mode = WAL')
|
|
965
|
+
if (!absent) this.db.fileControl('store', SQLITE_FCNTL_PERSIST_WAL, 1)
|
|
966
|
+
this.db.exec('PRAGMA store.journal_size_limit = 67108864')
|
|
967
|
+
this.db.exec('PRAGMA store.synchronous = NORMAL')
|
|
968
|
+
} catch (err) {
|
|
969
|
+
try {
|
|
970
|
+
closeDb(this.db)
|
|
971
|
+
} catch {
|
|
972
|
+
// The refusal below is the error worth reporting.
|
|
973
|
+
}
|
|
974
|
+
throw unreadableIndex(err)
|
|
975
|
+
? unreadableIndexError(storeFile, err)
|
|
976
|
+
: (outOfFdsAtOpen(storeFile, err) ?? err)
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
|
|
803
980
|
// --- config evaluations: `ConfigEvalStore`, delegated to `ConfigEvalTable` ---
|
|
804
981
|
getConfigEval(key: string): string | null {
|
|
805
982
|
return this.guard(() => this.configEvals.getConfigEval(key))
|
|
@@ -908,7 +1085,7 @@ export class Cache implements CacheLayer {
|
|
|
908
1085
|
private loadValueSalt(): string {
|
|
909
1086
|
const read = (): string | undefined =>
|
|
910
1087
|
(
|
|
911
|
-
this.db.prepare("SELECT value FROM
|
|
1088
|
+
this.db.prepare("SELECT value FROM store_meta WHERE key = 'value_salt'").get() as {
|
|
912
1089
|
value: string
|
|
913
1090
|
} | null
|
|
914
1091
|
)?.value
|
|
@@ -921,7 +1098,7 @@ export class Cache implements CacheLayer {
|
|
|
921
1098
|
// Two processes opening one new store: the first salt stands.
|
|
922
1099
|
this.db
|
|
923
1100
|
.prepare(
|
|
924
|
-
"INSERT INTO
|
|
1101
|
+
"INSERT INTO store_meta(key, value) VALUES ('value_salt', ?) ON CONFLICT(key) DO NOTHING",
|
|
925
1102
|
)
|
|
926
1103
|
.run(salt)
|
|
927
1104
|
return read() ?? salt
|
|
@@ -1079,11 +1256,15 @@ export class Cache implements CacheLayer {
|
|
|
1079
1256
|
/** Remove one entry: its artifact first, then its rows (as `prune` orders them). */
|
|
1080
1257
|
private async dropEntry(hash: string): Promise<void> {
|
|
1081
1258
|
await rm(this.tarPath(hash), { force: true })
|
|
1082
|
-
|
|
1259
|
+
const drop = this.db.transaction(() => {
|
|
1083
1260
|
this.deleteEntryRow.run(hash)
|
|
1261
|
+
this.outputs.forget([hash])
|
|
1262
|
+
})
|
|
1263
|
+
try {
|
|
1264
|
+
drop()
|
|
1084
1265
|
} catch (err) {
|
|
1085
1266
|
if (!isIndexFull(err)) throw err
|
|
1086
|
-
|
|
1267
|
+
drop()
|
|
1087
1268
|
}
|
|
1088
1269
|
}
|
|
1089
1270
|
|
|
@@ -1921,6 +2102,7 @@ export class Cache implements CacheLayer {
|
|
|
1921
2102
|
const { test, params } = inHashes(rows)
|
|
1922
2103
|
const deleteRows = this.db.transaction(() => {
|
|
1923
2104
|
this.db.prepare(`DELETE FROM entries WHERE hash ${test}`).run(...params)
|
|
2105
|
+
this.outputs.forget(rows)
|
|
1924
2106
|
})
|
|
1925
2107
|
try {
|
|
1926
2108
|
deleteRows()
|
|
@@ -1930,6 +2112,13 @@ export class Cache implements CacheLayer {
|
|
|
1930
2112
|
}
|
|
1931
2113
|
}
|
|
1932
2114
|
|
|
2115
|
+
// This workspace's rows for entries another workspace pruned from a
|
|
2116
|
+
// shared store: never read (a lookup starts at the entry), only kept.
|
|
2117
|
+
try {
|
|
2118
|
+
this.outputs.forgetGone()
|
|
2119
|
+
} catch (err) {
|
|
2120
|
+
if (!isIndexFull(err)) throw err
|
|
2121
|
+
}
|
|
1933
2122
|
const orphans = await this.reapOrphans()
|
|
1934
2123
|
return { evicted: victims.size, bytesFreed, ...orphans }
|
|
1935
2124
|
}
|
|
@@ -1994,7 +2183,7 @@ export class Cache implements CacheLayer {
|
|
|
1994
2183
|
}>
|
|
1995
2184
|
let names: string[]
|
|
1996
2185
|
try {
|
|
1997
|
-
names = await readdir(this.
|
|
2186
|
+
names = await readdir(this.artifactDir)
|
|
1998
2187
|
} catch {
|
|
1999
2188
|
return { phantoms: new Map(), stale: [] }
|
|
2000
2189
|
}
|
|
@@ -2019,8 +2208,8 @@ export class Cache implements CacheLayer {
|
|
|
2019
2208
|
|
|
2020
2209
|
/** Row-less artifacts and temps past the in-flight grace window: one readdir, one stat per candidate. */
|
|
2021
2210
|
private async scanOrphans(): Promise<Array<{ file: string; size: number }>> {
|
|
2022
|
-
|
|
2023
|
-
this.
|
|
2211
|
+
const found = await scanOrphanFiles(
|
|
2212
|
+
this.artifactDir,
|
|
2024
2213
|
() =>
|
|
2025
2214
|
new Set(
|
|
2026
2215
|
(this.db.prepare('SELECT hash FROM entries').all() as Array<{ hash: string }>).map(
|
|
@@ -2028,6 +2217,10 @@ export class Cache implements CacheLayer {
|
|
|
2028
2217
|
),
|
|
2029
2218
|
),
|
|
2030
2219
|
)
|
|
2220
|
+
// With a shared store, no artifact in `cacheDir` is indexed: they are
|
|
2221
|
+
// what this workspace saved before its entries moved out.
|
|
2222
|
+
if (this.artifactDir === this.cacheDir) return found
|
|
2223
|
+
return [...found, ...(await scanOrphanFiles(this.cacheDir, () => new Set()))]
|
|
2031
2224
|
}
|
|
2032
2225
|
|
|
2033
2226
|
close(): void {
|
|
@@ -2070,7 +2263,7 @@ export class Cache implements CacheLayer {
|
|
|
2070
2263
|
}
|
|
2071
2264
|
|
|
2072
2265
|
private tarPath(hash: string): string {
|
|
2073
|
-
return path.join(this.
|
|
2266
|
+
return path.join(this.artifactDir, `${hash}.tar.zst`)
|
|
2074
2267
|
}
|
|
2075
2268
|
}
|
|
2076
2269
|
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// The output fingerprint index: per-entry `output_files` rows (size, mode,
|
|
2
|
-
// millisecond mtime)
|
|
2
|
+
// millisecond mtime), this workspace's `output_stamps` (inode and ctime) and
|
|
3
|
+
// `output_dirs` rows (directory mtimes), and the two
|
|
3
4
|
// proofs a cache hit runs against the tree before deciding not to restore.
|
|
4
5
|
// Owns its statements over the store's handle; `Cache` delegates, and
|
|
5
6
|
// writes the file rows through `replaceFileRows` inside its own save
|
|
@@ -30,6 +31,7 @@ export class OutputIndex {
|
|
|
30
31
|
private readonly deleteOutputDirs: ReturnType<Database['prepare']>
|
|
31
32
|
private readonly entryExists: ReturnType<Database['prepare']>
|
|
32
33
|
private readonly stampOutputFile: ReturnType<Database['prepare']>
|
|
34
|
+
private readonly deleteStamps: ReturnType<Database['prepare']>
|
|
33
35
|
/**
|
|
34
36
|
* Snapshots taken and not yet written, the last per hash. A snapshot is
|
|
35
37
|
* read by the NEXT run's hit check, never by the task that took it, so
|
|
@@ -63,8 +65,34 @@ export class OutputIndex {
|
|
|
63
65
|
this.entryExists = lazyStatement(this.db, 'SELECT 1 FROM entries WHERE hash = ?')
|
|
64
66
|
this.stampOutputFile = lazyStatement(
|
|
65
67
|
this.db,
|
|
66
|
-
'
|
|
68
|
+
'INSERT INTO output_stamps(entry_hash, path, ino, ctime_ms) VALUES (?, ?, ?, ?) ON CONFLICT(entry_hash, path) DO UPDATE SET ino = excluded.ino, ctime_ms = excluded.ctime_ms',
|
|
67
69
|
)
|
|
70
|
+
this.deleteStamps = lazyStatement(this.db, 'DELETE FROM output_stamps WHERE entry_hash = ?')
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Drop this workspace's stamps and snapshots of `hashes`, whose entries
|
|
75
|
+
* are gone: they live apart from the entry (it may be another database's),
|
|
76
|
+
* so no cascade takes them.
|
|
77
|
+
*/
|
|
78
|
+
forget(hashes: readonly string[]): void {
|
|
79
|
+
if (hashes.length === 0) return
|
|
80
|
+
for (const h of hashes) {
|
|
81
|
+
this.pendingStamps.delete(h)
|
|
82
|
+
this.pendingDirs.delete(h)
|
|
83
|
+
}
|
|
84
|
+
const { test, params } = inHashes(hashes)
|
|
85
|
+
this.db.prepare(`DELETE FROM output_stamps WHERE entry_hash ${test}`).run(...params)
|
|
86
|
+
this.db.prepare(`DELETE FROM output_dirs WHERE entry_hash ${test}`).run(...params)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** `forget` every entry no longer stored: another workspace's prune of a shared store. */
|
|
90
|
+
forgetGone(): void {
|
|
91
|
+
this.db.transaction(() => {
|
|
92
|
+
this.db.exec(
|
|
93
|
+
'DELETE FROM output_stamps WHERE entry_hash NOT IN (SELECT hash FROM entries); DELETE FROM output_dirs WHERE entry_hash NOT IN (SELECT hash FROM entries)',
|
|
94
|
+
)
|
|
95
|
+
})()
|
|
68
96
|
}
|
|
69
97
|
|
|
70
98
|
/**
|
|
@@ -76,6 +104,7 @@ export class OutputIndex {
|
|
|
76
104
|
// A stamp taken for the rows this replaces describes their files, not these.
|
|
77
105
|
this.pendingStamps.delete(hash)
|
|
78
106
|
this.deleteOutputFiles.run(hash)
|
|
107
|
+
this.deleteStamps.run(hash)
|
|
79
108
|
for (const [rel, size, mode, mtime] of rows) {
|
|
80
109
|
this.insertOutputFile.run(hash, rel, size, mode, mtime)
|
|
81
110
|
}
|
|
@@ -93,7 +122,7 @@ export class OutputIndex {
|
|
|
93
122
|
// reads its rows twice.
|
|
94
123
|
const { test, params } = inHashes(hashes)
|
|
95
124
|
const stmt = this.db.query(
|
|
96
|
-
`SELECT entry_hash, path, size_bytes, mode, mtime_ms, ino, ctime_ms FROM output_files WHERE entry_hash ${test}`,
|
|
125
|
+
`SELECT f.entry_hash, f.path, f.size_bytes, f.mode, f.mtime_ms, s.ino, s.ctime_ms FROM output_files f LEFT JOIN output_stamps s ON s.entry_hash = f.entry_hash AND s.path = f.path WHERE f.entry_hash ${test}`,
|
|
97
126
|
)
|
|
98
127
|
const rows = stmt.all(...params) as Array<{
|
|
99
128
|
entry_hash: string
|
|
@@ -296,7 +325,9 @@ export class OutputIndex {
|
|
|
296
325
|
this.pendingStamps.clear()
|
|
297
326
|
this.db.transaction(() => {
|
|
298
327
|
for (const [hash, rows] of stamps) {
|
|
299
|
-
|
|
328
|
+
// A stamp outlives nothing: an entry pruned since has no rows to vouch for.
|
|
329
|
+
if (this.entryExists.get(hash) === null) continue
|
|
330
|
+
for (const [rel, ino, ctime] of rows) this.stampOutputFile.run(hash, rel, ino, ctime)
|
|
300
331
|
}
|
|
301
332
|
})()
|
|
302
333
|
}
|
|
@@ -306,9 +337,8 @@ export class OutputIndex {
|
|
|
306
337
|
this.db.transaction(() => {
|
|
307
338
|
for (const [hash, rows] of pending) {
|
|
308
339
|
this.deleteOutputDirs.run(hash)
|
|
309
|
-
// The rows
|
|
310
|
-
// between the snapshot and this flush has nothing to describe
|
|
311
|
-
// and its insert would fail the whole transaction on the FK.
|
|
340
|
+
// The rows describe the entry: one pruned by another process
|
|
341
|
+
// between the snapshot and this flush has nothing to describe.
|
|
312
342
|
if (rows === null || this.entryExists.get(hash) === null) continue
|
|
313
343
|
for (const [rel, mtime] of rows) this.insertOutputDir.run(hash, rel, mtime)
|
|
314
344
|
}
|
package/src/cache/schema.ts
CHANGED
|
@@ -19,12 +19,21 @@ export function lazyStatement(db: Database, sql: string): ReturnType<Database['p
|
|
|
19
19
|
} as unknown as ReturnType<Database['prepare']>
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
-
|
|
22
|
+
/**
|
|
23
|
+
* Create every table. `store` names the schema holding the ENTRY tables
|
|
24
|
+
* (`entries`, `entry_stdout`, `output_files`, `entry_inputs`, `store_meta`):
|
|
25
|
+
* `main` when one `cache.db` holds everything (a `cacheDir`), `store` when
|
|
26
|
+
* they live in the shared store attached beside it (v32). The rest is the
|
|
27
|
+
* workspace's own: its history, its memos, and what its disk looked like
|
|
28
|
+
* after a save or restore. A store table is never named with its schema in
|
|
29
|
+
* a statement; SQLite finds it in `main` or in `store`, whichever has it.
|
|
30
|
+
*/
|
|
31
|
+
export function createTables(db: Database, store: 'main' | 'store' = 'main'): void {
|
|
23
32
|
// Cached config evaluations (workspace/config-cache.ts): the validated
|
|
24
33
|
// config as JSON, keyed by everything the evaluation could observe. A
|
|
25
34
|
// separate exec so the artifact schema stays byte-identical.
|
|
26
35
|
db.exec(`
|
|
27
|
-
CREATE TABLE IF NOT EXISTS config_evals (
|
|
36
|
+
CREATE TABLE IF NOT EXISTS main.config_evals (
|
|
28
37
|
key TEXT PRIMARY KEY,
|
|
29
38
|
json TEXT NOT NULL,
|
|
30
39
|
created_at INTEGER NOT NULL
|
|
@@ -34,7 +43,7 @@ export function createTables(db: Database): void {
|
|
|
34
43
|
db.exec(`
|
|
35
44
|
-- The queryable index: command, exit_code, duration, size and
|
|
36
45
|
-- timestamps; stdout in entry_stdout below.
|
|
37
|
-
CREATE TABLE IF NOT EXISTS entries (
|
|
46
|
+
CREATE TABLE IF NOT EXISTS ${store}.entries (
|
|
38
47
|
hash TEXT PRIMARY KEY,
|
|
39
48
|
project TEXT NOT NULL,
|
|
40
49
|
task TEXT NOT NULL,
|
|
@@ -52,12 +61,12 @@ export function createTables(db: Database): void {
|
|
|
52
61
|
-- carries so it survives a remote round trip; a local hit replays it
|
|
53
62
|
-- from here. Its own table: an UPDATE rewrites a whole record, and the
|
|
54
63
|
-- accessed_at bump rewrote up to 16 MB a hit. No row: empty stdout.
|
|
55
|
-
CREATE TABLE IF NOT EXISTS entry_stdout (
|
|
64
|
+
CREATE TABLE IF NOT EXISTS ${store}.entry_stdout (
|
|
56
65
|
hash TEXT PRIMARY KEY,
|
|
57
66
|
stdout TEXT NOT NULL,
|
|
58
67
|
FOREIGN KEY (hash) REFERENCES entries(hash) ON DELETE CASCADE
|
|
59
68
|
);
|
|
60
|
-
CREATE TABLE IF NOT EXISTS runs (
|
|
69
|
+
CREATE TABLE IF NOT EXISTS main.runs (
|
|
61
70
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
62
71
|
-- '' when the outcome derived no cache key (skipped / persistent).
|
|
63
72
|
-- Every reader that must not mistake it for a key guards hash != ''.
|
|
@@ -113,23 +122,23 @@ export function createTables(db: Database): void {
|
|
|
113
122
|
-- runs_ended (2026-09-09) served only the retention DELETE, which
|
|
114
123
|
-- prunes on started_at now (a row ends after it starts, so the
|
|
115
124
|
-- 30-day window moves by at most one task's duration).
|
|
116
|
-
DROP INDEX IF EXISTS runs_hash;
|
|
117
|
-
DROP INDEX IF EXISTS runs_project;
|
|
118
|
-
DROP INDEX IF EXISTS runs_ended;
|
|
119
|
-
CREATE INDEX IF NOT EXISTS runs_started_at ON runs(started_at);
|
|
120
|
-
CREATE INDEX IF NOT EXISTS runs_run_id ON runs(run_id);
|
|
125
|
+
DROP INDEX IF EXISTS main.runs_hash;
|
|
126
|
+
DROP INDEX IF EXISTS main.runs_project;
|
|
127
|
+
DROP INDEX IF EXISTS main.runs_ended;
|
|
128
|
+
CREATE INDEX IF NOT EXISTS main.runs_started_at ON runs(started_at);
|
|
129
|
+
CREATE INDEX IF NOT EXISTS main.runs_run_id ON runs(run_id);
|
|
121
130
|
-- The one keyed index, PARTIAL over failed rows: a green run's
|
|
122
131
|
-- inserts only evaluate its predicate, so the append-only cost above
|
|
123
132
|
-- holds, and the flakiness probe after a miss (failure-mode.ts,
|
|
124
133
|
-- "did this key ever fail?") reads a handful of leaves instead of
|
|
125
134
|
-- scanning the table (2026-09-10: 10–95 ms at 170k rows without it).
|
|
126
|
-
CREATE INDEX IF NOT EXISTS runs_failed ON runs(hash) WHERE status = 'failed';
|
|
135
|
+
CREATE INDEX IF NOT EXISTS main.runs_failed ON runs(hash) WHERE status = 'failed';
|
|
127
136
|
-- Per-file (mtime, size, content_hash) cache. Lets Cache.key()
|
|
128
137
|
-- skip the content-hash on inputs whose stat hasn't changed
|
|
129
138
|
-- since the last run. Pure performance optimization; the stored
|
|
130
139
|
-- hash is the exact same one content-hashing would compute now,
|
|
131
140
|
-- so the cache key derivation is unchanged.
|
|
132
|
-
CREATE TABLE IF NOT EXISTS file_hashes (
|
|
141
|
+
CREATE TABLE IF NOT EXISTS main.file_hashes (
|
|
133
142
|
path TEXT PRIMARY KEY,
|
|
134
143
|
mtime_ms INTEGER NOT NULL,
|
|
135
144
|
size_bytes INTEGER NOT NULL,
|
|
@@ -142,14 +151,14 @@ export function createTables(db: Database): void {
|
|
|
142
151
|
-- worktree size git recorded (A-60). Fixed for its OID, so a warm
|
|
143
152
|
-- run asks git for none; swept with file_hashes by seen_at, the
|
|
144
153
|
-- time the row was written.
|
|
145
|
-
CREATE TABLE IF NOT EXISTS blob_sizes (
|
|
154
|
+
CREATE TABLE IF NOT EXISTS main.blob_sizes (
|
|
146
155
|
oid TEXT PRIMARY KEY,
|
|
147
156
|
size INTEGER NOT NULL,
|
|
148
157
|
seen_at INTEGER NOT NULL
|
|
149
158
|
);
|
|
150
159
|
-- v30: the paths an index distrusts, by a hash of the index file and the
|
|
151
160
|
-- pathspecs (A-60): a warm run reads one row, not one per blob.
|
|
152
|
-
CREATE TABLE IF NOT EXISTS blob_verdicts (
|
|
161
|
+
CREATE TABLE IF NOT EXISTS main.blob_verdicts (
|
|
153
162
|
digest TEXT PRIMARY KEY,
|
|
154
163
|
paths TEXT NOT NULL,
|
|
155
164
|
seen_at INTEGER NOT NULL
|
|
@@ -163,24 +172,34 @@ export function createTables(db: Database): void {
|
|
|
163
172
|
-- ON DELETE CASCADE keeps these rows in sync with entries:
|
|
164
173
|
-- a cache prune that drops an entry sweeps its output rows
|
|
165
174
|
-- automatically.
|
|
166
|
-
CREATE TABLE IF NOT EXISTS output_files (
|
|
175
|
+
CREATE TABLE IF NOT EXISTS ${store}.output_files (
|
|
167
176
|
entry_hash TEXT NOT NULL,
|
|
168
177
|
path TEXT NOT NULL,
|
|
169
178
|
size_bytes INTEGER NOT NULL,
|
|
170
179
|
mode INTEGER NOT NULL,
|
|
171
180
|
mtime_ms INTEGER NOT NULL,
|
|
172
|
-
-- v28: the inode and ctime this machine saw after the save or
|
|
173
|
-
-- restore that left the file equal to the entry; NULL until then.
|
|
174
|
-
ino INTEGER,
|
|
175
|
-
ctime_ms INTEGER,
|
|
176
181
|
PRIMARY KEY (entry_hash, path),
|
|
177
182
|
FOREIGN KEY (entry_hash) REFERENCES entries(hash) ON DELETE CASCADE
|
|
178
183
|
);
|
|
184
|
+
-- v28, its own table since v32: the inode and ctime THIS workspace saw
|
|
185
|
+
-- after the save or restore that left the file equal to the entry. No
|
|
186
|
+
-- row: never current. Apart from output_files because the entry is
|
|
187
|
+
-- shared and the disk is not: two worktrees on one store overwrote each
|
|
188
|
+
-- other's stamps, and every switch between them restored. No foreign
|
|
189
|
+
-- key (the entry may live in another database); a prune and a re-save
|
|
190
|
+
-- delete what they orphan.
|
|
191
|
+
CREATE TABLE IF NOT EXISTS main.output_stamps (
|
|
192
|
+
entry_hash TEXT NOT NULL,
|
|
193
|
+
path TEXT NOT NULL,
|
|
194
|
+
ino INTEGER NOT NULL,
|
|
195
|
+
ctime_ms INTEGER NOT NULL,
|
|
196
|
+
PRIMARY KEY (entry_hash, path)
|
|
197
|
+
);
|
|
179
198
|
-- Each config's ORDERED import closure (the config first), so a warm
|
|
180
199
|
-- load keys it by stat-hashing the list (the file_hashes memo) instead
|
|
181
200
|
-- of reading and scanning every file. Machine-local; pruned with
|
|
182
201
|
-- config_evals (2026-09-03).
|
|
183
|
-
CREATE TABLE IF NOT EXISTS config_closures (
|
|
202
|
+
CREATE TABLE IF NOT EXISTS main.config_closures (
|
|
184
203
|
config_path TEXT PRIMARY KEY,
|
|
185
204
|
files_json TEXT NOT NULL,
|
|
186
205
|
created_at INTEGER NOT NULL
|
|
@@ -190,19 +209,18 @@ export function createTables(db: Database): void {
|
|
|
190
209
|
-- hit, unchanged mtimes prove the output SET is unchanged, replacing
|
|
191
210
|
-- the glob walk that cost 0.36 ms per hit. Machine-local: a remote
|
|
192
211
|
-- ingest writes none, and the first hit after it walks and records.
|
|
193
|
-
CREATE TABLE IF NOT EXISTS output_dirs (
|
|
212
|
+
CREATE TABLE IF NOT EXISTS main.output_dirs (
|
|
194
213
|
entry_hash TEXT NOT NULL,
|
|
195
214
|
path TEXT NOT NULL,
|
|
196
215
|
mtime_ms INTEGER NOT NULL,
|
|
197
|
-
PRIMARY KEY (entry_hash, path)
|
|
198
|
-
FOREIGN KEY (entry_hash) REFERENCES entries(hash) ON DELETE CASCADE
|
|
216
|
+
PRIMARY KEY (entry_hash, path)
|
|
199
217
|
);
|
|
200
218
|
-- v22 (Tier 3): one header row per vx-run invocation. The runs
|
|
201
219
|
-- table is per-task; this is the per-invocation record that
|
|
202
220
|
-- carries git/CI/host context, the command, tags, and run-level
|
|
203
221
|
-- counts so the dashboard never reconstructs a header with a
|
|
204
222
|
-- lossy GROUP BY over runs.
|
|
205
|
-
CREATE TABLE IF NOT EXISTS invocations (
|
|
223
|
+
CREATE TABLE IF NOT EXISTS main.invocations (
|
|
206
224
|
run_id TEXT PRIMARY KEY,
|
|
207
225
|
command TEXT NOT NULL,
|
|
208
226
|
requested_tasks TEXT NOT NULL,
|
|
@@ -233,9 +251,9 @@ export function createTables(db: Database): void {
|
|
|
233
251
|
vx_version TEXT NOT NULL,
|
|
234
252
|
tags TEXT NOT NULL DEFAULT '{}'
|
|
235
253
|
);
|
|
236
|
-
CREATE INDEX IF NOT EXISTS invocations_started ON invocations(started_at);
|
|
237
|
-
CREATE INDEX IF NOT EXISTS invocations_branch ON invocations(branch);
|
|
238
|
-
CREATE INDEX IF NOT EXISTS invocations_ci ON invocations(ci);
|
|
254
|
+
CREATE INDEX IF NOT EXISTS main.invocations_started ON invocations(started_at);
|
|
255
|
+
CREATE INDEX IF NOT EXISTS main.invocations_branch ON invocations(branch);
|
|
256
|
+
CREATE INDEX IF NOT EXISTS main.invocations_ci ON invocations(ci);
|
|
239
257
|
-- v22 (Tier 3): the input-fingerprint moat. One row per cache-key
|
|
240
258
|
-- component, keyed by the cache-ENTRY hash it belongs to (NOT a
|
|
241
259
|
-- run id). Written inside the entry-save transaction — only on a
|
|
@@ -246,7 +264,7 @@ export function createTables(db: Database): void {
|
|
|
246
264
|
-- SQL over (kind,name,hash); a JSON blob would force an app-side
|
|
247
265
|
-- parse + compare on every probe. ON DELETE CASCADE keeps these
|
|
248
266
|
-- rows in sync with entries (a prune sweeps them automatically).
|
|
249
|
-
CREATE TABLE IF NOT EXISTS entry_inputs (
|
|
267
|
+
CREATE TABLE IF NOT EXISTS ${store}.entry_inputs (
|
|
250
268
|
entry_hash TEXT NOT NULL,
|
|
251
269
|
kind TEXT NOT NULL,
|
|
252
270
|
name TEXT NOT NULL,
|
|
@@ -254,6 +272,13 @@ export function createTables(db: Database): void {
|
|
|
254
272
|
PRIMARY KEY (entry_hash, kind, name),
|
|
255
273
|
FOREIGN KEY (entry_hash) REFERENCES entries(hash) ON DELETE CASCADE
|
|
256
274
|
);
|
|
275
|
+
-- v32: what belongs to the entries rather than to one workspace: the
|
|
276
|
+
-- salt their value digests are taken under, so a digest an entry_inputs
|
|
277
|
+
-- row holds compares with the one another workspace's run takes.
|
|
278
|
+
CREATE TABLE IF NOT EXISTS ${store}.store_meta (
|
|
279
|
+
key TEXT PRIMARY KEY,
|
|
280
|
+
value TEXT NOT NULL
|
|
281
|
+
);
|
|
257
282
|
`)
|
|
258
283
|
}
|
|
259
284
|
|
package/src/cli/info.ts
CHANGED
|
@@ -91,6 +91,7 @@ export function renderInfo(f: InfoFacts): string {
|
|
|
91
91
|
['workers', describeWorkers(f.workers)],
|
|
92
92
|
['memory', describeMemory(f.memory)],
|
|
93
93
|
['cache dir', f.cacheDir],
|
|
94
|
+
['cache store', f.cacheStore ?? 'none: the cache dir holds the entries'],
|
|
94
95
|
['cache versions', `keys ${f.cacheVersion} · index schema ${f.schemaVersion}`],
|
|
95
96
|
['cache entries', `${f.cacheEntries} (${formatBytes(f.cacheBytes)})`],
|
|
96
97
|
// Only when there is something to say.
|
|
@@ -19,12 +19,15 @@ import {
|
|
|
19
19
|
type ProjectMeta,
|
|
20
20
|
readLockfile,
|
|
21
21
|
resolveCacheDir,
|
|
22
|
+
resolveStoreRoot,
|
|
22
23
|
} from '../workspace/index.js'
|
|
23
24
|
|
|
24
25
|
export interface CliWorkspace {
|
|
25
26
|
workspaceConfig: WorkspaceConfig | null
|
|
26
27
|
plugins: readonly VxPlugin[]
|
|
27
28
|
cacheDir: string
|
|
29
|
+
/** The shared store a run opens `cacheDir` with (`resolveStoreRoot`). */
|
|
30
|
+
storeRoot: string | null
|
|
28
31
|
}
|
|
29
32
|
|
|
30
33
|
export const warnToStderr = (message: string): void => {
|
|
@@ -33,7 +36,12 @@ export const warnToStderr = (message: string): void => {
|
|
|
33
36
|
|
|
34
37
|
export async function loadCliWorkspace(workspaceRoot: string): Promise<CliWorkspace> {
|
|
35
38
|
const { workspaceConfig, plugins } = await loadWorkspacePlugins(workspaceRoot, warnToStderr)
|
|
36
|
-
return {
|
|
39
|
+
return {
|
|
40
|
+
workspaceConfig,
|
|
41
|
+
plugins,
|
|
42
|
+
cacheDir: resolveCacheDir(workspaceRoot, workspaceConfig),
|
|
43
|
+
storeRoot: resolveStoreRoot(workspaceRoot, workspaceConfig),
|
|
44
|
+
}
|
|
37
45
|
}
|
|
38
46
|
|
|
39
47
|
/**
|
|
@@ -131,7 +139,14 @@ export async function loadCliProjects(
|
|
|
131
139
|
const cache =
|
|
132
140
|
opts.noCreate === true && !existsSync(cacheDir)
|
|
133
141
|
? null
|
|
134
|
-
: new Cache(
|
|
142
|
+
: new Cache(
|
|
143
|
+
cacheDir,
|
|
144
|
+
{ read: true, write: true },
|
|
145
|
+
workspaceRoot,
|
|
146
|
+
undefined,
|
|
147
|
+
'open',
|
|
148
|
+
opts.cacheDir === undefined ? ws.storeRoot : null,
|
|
149
|
+
)
|
|
135
150
|
if (cache !== null) noteSchemaReset(cache, warnToStderr)
|
|
136
151
|
try {
|
|
137
152
|
const loaded = await loadProjects({
|
package/src/config.ts
CHANGED
|
@@ -3,7 +3,11 @@
|
|
|
3
3
|
export interface WorkspaceConfig {
|
|
4
4
|
/** Maximum concurrent tasks. Defaults to the number of CPUs. */
|
|
5
5
|
concurrency?: number
|
|
6
|
-
/**
|
|
6
|
+
/**
|
|
7
|
+
* Cache directory, relative to the workspace root. Named, it holds the whole
|
|
8
|
+
* cache and shares nothing; unset, the workspace keeps its index in
|
|
9
|
+
* `.vx/cache` and its entries in the user's shared store.
|
|
10
|
+
*/
|
|
7
11
|
cacheDir?: string
|
|
8
12
|
/**
|
|
9
13
|
* Default per-task timeout (ms) — the lowest-precedence fallback for a
|
package/src/exec/env.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
1
2
|
import path from 'node:path'
|
|
2
3
|
|
|
3
4
|
// Build the environment exposed to a task.
|
|
@@ -91,3 +92,55 @@ export function buildIsolatedEnv(opts: BuildEnvOptions): NodeJS.ProcessEnv {
|
|
|
91
92
|
|
|
92
93
|
return out
|
|
93
94
|
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* `npm_execpath`, as the workspace's package manager sets it for a script.
|
|
98
|
+
* A tool that runs other scripts reads it to call the same manager back
|
|
99
|
+
* (npm-run-all's `run-s`, `run-p`) and falls back to `npm` without it:
|
|
100
|
+
* solidjs/solid's `npm-run-all -nl build:*` needed a global npm under vx,
|
|
101
|
+
* where `pnpm run build` passed (owner, 2026-10-06). It names a binary on
|
|
102
|
+
* this machine, so it is not in the key, as PATH is not.
|
|
103
|
+
*/
|
|
104
|
+
export const PM_EXEC_ENV = 'npm_execpath'
|
|
105
|
+
|
|
106
|
+
const MANAGERS = ['pnpm', 'yarn', 'bun', 'npm'] as const
|
|
107
|
+
const LOCKFILES: ReadonlyArray<readonly [string, (typeof MANAGERS)[number]]> = [
|
|
108
|
+
['pnpm-lock.yaml', 'pnpm'],
|
|
109
|
+
['yarn.lock', 'yarn'],
|
|
110
|
+
['bun.lock', 'bun'],
|
|
111
|
+
['bun.lockb', 'bun'],
|
|
112
|
+
['package-lock.json', 'npm'],
|
|
113
|
+
]
|
|
114
|
+
|
|
115
|
+
const managerPaths = new Map<string, string | null>()
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* The executable of the workspace's package manager: the root
|
|
119
|
+
* package.json's `packageManager`, else its lockfile, found on the root's
|
|
120
|
+
* `node_modules/.bin` and PATH. Learned once per root; null when the root
|
|
121
|
+
* names none or this machine has none.
|
|
122
|
+
*/
|
|
123
|
+
export function packageManagerPath(root: string): string | null {
|
|
124
|
+
const known = managerPaths.get(root)
|
|
125
|
+
if (known !== undefined) return known
|
|
126
|
+
let name: string | undefined
|
|
127
|
+
try {
|
|
128
|
+
const field = (
|
|
129
|
+
JSON.parse(readFileSync(path.join(root, 'package.json'), 'utf8')) as {
|
|
130
|
+
packageManager?: unknown
|
|
131
|
+
}
|
|
132
|
+
).packageManager
|
|
133
|
+
if (typeof field === 'string') name = MANAGERS.find((m) => field.startsWith(`${m}@`))
|
|
134
|
+
} catch {}
|
|
135
|
+
name ??= LOCKFILES.find(([f]) => existsSync(path.join(root, f)))?.[1]
|
|
136
|
+
const found =
|
|
137
|
+
name === undefined
|
|
138
|
+
? null
|
|
139
|
+
: Bun.which(name, {
|
|
140
|
+
PATH: [path.join(root, 'node_modules', '.bin'), process.env['PATH'] ?? ''].join(
|
|
141
|
+
path.delimiter,
|
|
142
|
+
),
|
|
143
|
+
})
|
|
144
|
+
managerPaths.set(root, found)
|
|
145
|
+
return found
|
|
146
|
+
}
|
package/src/exec/index.ts
CHANGED
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
// Module contract. Cross-module imports must come through here; see
|
|
2
2
|
// docs/design/module-isolation-2026-06.md and tests/module-boundaries.test.ts.
|
|
3
3
|
|
|
4
|
-
export {
|
|
4
|
+
export {
|
|
5
|
+
buildIsolatedEnv,
|
|
6
|
+
packageManagerPath,
|
|
7
|
+
PM_EXEC_ENV,
|
|
8
|
+
VX_RUN_TASK_ENV,
|
|
9
|
+
VX_RUN_WORKSPACE_ENV,
|
|
10
|
+
} from './env.js'
|
|
5
11
|
export {
|
|
6
12
|
runCommand,
|
|
7
13
|
runPersistent,
|
|
@@ -28,6 +28,7 @@ import {
|
|
|
28
28
|
lockfilePath,
|
|
29
29
|
type ProjectMeta,
|
|
30
30
|
resolveCacheDir,
|
|
31
|
+
resolveStoreRoot,
|
|
31
32
|
} from '../workspace/index.js'
|
|
32
33
|
import { flakyTasks, type FlakyTask } from './failure-mode.js'
|
|
33
34
|
import type { VxPlugin } from './plugin.js'
|
|
@@ -81,6 +82,8 @@ export interface InfoFacts {
|
|
|
81
82
|
*/
|
|
82
83
|
memory: { usableBytes: number; totalBytes: number; cgroupLimitBytes: number | null }
|
|
83
84
|
cacheDir: string
|
|
85
|
+
/** The shared store holding the entries, or null when `cacheDir` holds them. */
|
|
86
|
+
cacheStore: string | null
|
|
84
87
|
cacheVersion: string
|
|
85
88
|
schemaVersion: string
|
|
86
89
|
cacheEntries: number
|
|
@@ -149,6 +152,7 @@ async function collectWorkspaceInfo(
|
|
|
149
152
|
? resolveCacheDir(root, workspaceConfig)
|
|
150
153
|
: path.resolve(cwd, opts.cacheDir)
|
|
151
154
|
const metas = await discoverProjects(await loadWorkspace(root, reads), plugins, cacheDir, warn)
|
|
155
|
+
const storeRoot = opts.cacheDir === undefined ? resolveStoreRoot(root, workspaceConfig) : null
|
|
152
156
|
const cache = Cache.inspect(cacheDir)
|
|
153
157
|
noteSchemaReset(cache, warn)
|
|
154
158
|
let stats
|
|
@@ -232,6 +236,8 @@ async function collectWorkspaceInfo(
|
|
|
232
236
|
workers: workersFact(workspaceConfig?.concurrency),
|
|
233
237
|
memory: memoryFact(),
|
|
234
238
|
cacheDir,
|
|
239
|
+
// The store the index records, else the one a first run would open.
|
|
240
|
+
cacheStore: cache.storeDir ?? storeRoot,
|
|
235
241
|
// The two versions a bug report needs and the reset notice names: the
|
|
236
242
|
// key prefix (a bump orphans every entry) and the index schema (a
|
|
237
243
|
// mismatch drops every table).
|
|
@@ -17,6 +17,8 @@ import {
|
|
|
17
17
|
} from '../cache/index.js'
|
|
18
18
|
import {
|
|
19
19
|
buildIsolatedEnv,
|
|
20
|
+
packageManagerPath,
|
|
21
|
+
PM_EXEC_ENV,
|
|
20
22
|
VX_RUN_TASK_ENV,
|
|
21
23
|
VX_RUN_WORKSPACE_ENV,
|
|
22
24
|
runPersistent,
|
|
@@ -1411,6 +1413,11 @@ function taskEnv(node: TaskNode, step: ExecConfig, workspaceRoot: string): NodeJ
|
|
|
1411
1413
|
})
|
|
1412
1414
|
env[VX_RUN_WORKSPACE_ENV] = workspaceRoot
|
|
1413
1415
|
env[VX_RUN_TASK_ENV] = node.id
|
|
1416
|
+
// A task's own passThrough or define wins.
|
|
1417
|
+
if (env[PM_EXEC_ENV] === undefined) {
|
|
1418
|
+
const manager = packageManagerPath(workspaceRoot)
|
|
1419
|
+
if (manager !== null) env[PM_EXEC_ENV] = manager
|
|
1420
|
+
}
|
|
1414
1421
|
return env
|
|
1415
1422
|
}
|
|
1416
1423
|
|
|
@@ -38,6 +38,7 @@ import {
|
|
|
38
38
|
type Lockfile,
|
|
39
39
|
readLockfile,
|
|
40
40
|
resolveCacheDir,
|
|
41
|
+
resolveStoreRoot,
|
|
41
42
|
type ProjectEntry,
|
|
42
43
|
} from '../workspace/index.js'
|
|
43
44
|
import {
|
|
@@ -215,9 +216,12 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
|
|
|
215
216
|
mark('workspace config')
|
|
216
217
|
// `--cache-dir <path>` (RunOptions.cacheDir) overrides the workspace
|
|
217
218
|
// `cacheDir` field + the `.vx/cache` default; resolved relative to cwd.
|
|
219
|
+
// A cache dir named anywhere holds everything; the default shares its
|
|
220
|
+
// entries through the user's store.
|
|
218
221
|
const cacheDir = options.cacheDir
|
|
219
222
|
? path.resolve(options.cwd, options.cacheDir)
|
|
220
223
|
: resolveCacheDir(workspaceRoot, workspaceConfig)
|
|
224
|
+
const storeRoot = options.cacheDir ? null : resolveStoreRoot(workspaceRoot, workspaceConfig)
|
|
221
225
|
const projectMetas =
|
|
222
226
|
reused !== undefined
|
|
223
227
|
? reused.projects
|
|
@@ -278,6 +282,8 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
|
|
|
278
282
|
{ read: policy.localRead, write: policy.localWrite },
|
|
279
283
|
workspaceRoot,
|
|
280
284
|
options.artifactCeiling,
|
|
285
|
+
'open',
|
|
286
|
+
storeRoot,
|
|
281
287
|
)
|
|
282
288
|
localCache.assertWritable()
|
|
283
289
|
noteSchemaReset(localCache, (m) => log.status(m))
|
package/src/workspace/index.ts
CHANGED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
// Which repository a workspace is, so its checkouts share one store under
|
|
2
|
+
// `~/.vx/<id>` and other repositories keep theirs: Nx 23.2's
|
|
3
|
+
// `~/.nx/<id>` (`utils/workspace-id.js`, `utils/git-utils.js`), ported
|
|
4
|
+
// rule for rule (owner, 2026-10-06: "do exactly like nx"), minus the Nx
|
|
5
|
+
// Cloud id. Read from the `.git` files; git is asked only when they cannot
|
|
6
|
+
// settle it (no parseable remote, an `include`, an `insteadOf`).
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
closeSync,
|
|
10
|
+
constants,
|
|
11
|
+
existsSync,
|
|
12
|
+
fstatSync,
|
|
13
|
+
lstatSync,
|
|
14
|
+
openSync,
|
|
15
|
+
readFileSync,
|
|
16
|
+
statSync,
|
|
17
|
+
} from 'node:fs'
|
|
18
|
+
import path from 'node:path'
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* 16 hex of sha256 of the repo key, sha256(`<identity>#<workspace path in
|
|
22
|
+
* the repo>`). The identity is the remote's `host/owner/repo` in lower
|
|
23
|
+
* case, so ssh, https and token URLs agree; with no remote, the sorted
|
|
24
|
+
* first root commit; null for a shallow clone with no remote and outside
|
|
25
|
+
* git: those share nothing.
|
|
26
|
+
*/
|
|
27
|
+
export function repoIdOf(root: string): string | null {
|
|
28
|
+
const located = locateGitDir(root)
|
|
29
|
+
if (located === null) return null
|
|
30
|
+
const identity = remoteIdentity(root, located.commonDir) ?? firstCommit(root)
|
|
31
|
+
if (identity === null) return null
|
|
32
|
+
const rel = path.relative(located.gitRoot, path.resolve(root)).split(path.sep).join('/')
|
|
33
|
+
const key = sha256(`${identity}#${rel}`)
|
|
34
|
+
return sha256(key).slice(0, 16)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const sha256 = (s: string): string => new Bun.CryptoHasher('sha256').update(s).digest('hex')
|
|
38
|
+
|
|
39
|
+
interface GitDir {
|
|
40
|
+
gitRoot: string
|
|
41
|
+
commonDir: string
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The repository `dir` is in: walk up to a `.git`; null past one not ours. */
|
|
45
|
+
function locateGitDir(dir: string): GitDir | null {
|
|
46
|
+
let current = path.resolve(dir)
|
|
47
|
+
for (;;) {
|
|
48
|
+
const at = gitDirAt(current)
|
|
49
|
+
if (at !== undefined) return at
|
|
50
|
+
const up = path.dirname(current)
|
|
51
|
+
if (up === current) return null
|
|
52
|
+
current = up
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Undefined with no `.git` here (walk on); null for one vx will not read. */
|
|
57
|
+
function gitDirAt(dir: string): GitDir | null | undefined {
|
|
58
|
+
const dotGit = path.join(dir, '.git')
|
|
59
|
+
let entry
|
|
60
|
+
try {
|
|
61
|
+
entry = statSync(dotGit)
|
|
62
|
+
} catch {
|
|
63
|
+
return undefined
|
|
64
|
+
}
|
|
65
|
+
if (entry.isDirectory()) {
|
|
66
|
+
// A `.git` planted in a world-writable ancestor (`/tmp`) must not name
|
|
67
|
+
// the repository of everything under it: git's own shape and owner checks.
|
|
68
|
+
if (!existsSync(path.join(dotGit, 'HEAD')) || !existsSync(path.join(dotGit, 'objects'))) {
|
|
69
|
+
return null
|
|
70
|
+
}
|
|
71
|
+
return ownedRealDir(dotGit) ? { gitRoot: dir, commonDir: dotGit } : null
|
|
72
|
+
}
|
|
73
|
+
if (entry.isFile()) {
|
|
74
|
+
const pointer = /^gitdir:\s*(.+)$/m.exec(readOwnedFile(dotGit) ?? '')
|
|
75
|
+
if (pointer === null) return null
|
|
76
|
+
const gitDir = path.resolve(dir, pointer[1]!.trim())
|
|
77
|
+
const shared = readOwnedFile(path.join(gitDir, 'commondir'))?.trim()
|
|
78
|
+
const commonDir = shared ? path.resolve(gitDir, shared) : gitDir
|
|
79
|
+
return ownedRealDir(commonDir) ? { gitRoot: dir, commonDir } : null
|
|
80
|
+
}
|
|
81
|
+
return undefined
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const ownUid = (): number | undefined => process.getuid?.()
|
|
85
|
+
|
|
86
|
+
function ownedRealDir(p: string): boolean {
|
|
87
|
+
try {
|
|
88
|
+
const st = lstatSync(p)
|
|
89
|
+
return st.isDirectory() && (ownUid() === undefined || st.uid === ownUid())
|
|
90
|
+
} catch {
|
|
91
|
+
return false
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** A regular file of ours, read without following a link or blocking on a FIFO. */
|
|
96
|
+
function readOwnedFile(p: string): string | null {
|
|
97
|
+
let fd: number | undefined
|
|
98
|
+
try {
|
|
99
|
+
fd = openSync(p, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK)
|
|
100
|
+
const st = fstatSync(fd)
|
|
101
|
+
if (!st.isFile() || (ownUid() !== undefined && st.uid !== ownUid())) return null
|
|
102
|
+
return readFileSync(fd, 'utf8')
|
|
103
|
+
} catch {
|
|
104
|
+
return null
|
|
105
|
+
} finally {
|
|
106
|
+
if (fd !== undefined) closeSync(fd)
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** The remote's identity from the config file, else from `git remote -v`. */
|
|
111
|
+
function remoteIdentity(root: string, commonDir: string): string | null {
|
|
112
|
+
const config = readOwnedFile(path.join(commonDir, 'config'))
|
|
113
|
+
const remotes = config === null ? null : parseGitConfigRemotes(config)
|
|
114
|
+
const urls = remotes ?? remotesFromGit(root)
|
|
115
|
+
const found = new Map<string, string>()
|
|
116
|
+
let first: string | null = null
|
|
117
|
+
for (const [name, url] of urls) {
|
|
118
|
+
const info = parseRemoteUrl(url)
|
|
119
|
+
if (info !== null && !found.has(name)) {
|
|
120
|
+
found.set(name, info)
|
|
121
|
+
first ??= info
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
const picked = found.get('origin') ?? found.get('upstream') ?? found.get('base') ?? first
|
|
125
|
+
return picked === null ? null : picked.toLowerCase().replace(/\/+$/, '')
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Remote name → url pairs in file order, or null when the file cannot be
|
|
130
|
+
* answered for whole: an `include`, a `url "…"` rewrite, or a value git
|
|
131
|
+
* would unescape or cut at a comment.
|
|
132
|
+
*/
|
|
133
|
+
export function parseGitConfigRemotes(contents: string): [string, string][] | null {
|
|
134
|
+
const remotes: [string, string][] = []
|
|
135
|
+
const seen = new Set<string>()
|
|
136
|
+
let remote: string | null = null
|
|
137
|
+
for (const raw of contents.split('\n')) {
|
|
138
|
+
const line = raw.trim()
|
|
139
|
+
if (line === '' || line.startsWith('#') || line.startsWith(';')) continue
|
|
140
|
+
if (line.startsWith('[')) {
|
|
141
|
+
const close = line.indexOf(']')
|
|
142
|
+
const section = (close === -1 ? line.slice(1) : line.slice(1, close)).trim()
|
|
143
|
+
if (/^include(If)?\b/i.test(section) || /^url\s+"/i.test(section)) return null
|
|
144
|
+
remote = /^remote\s+"(.*)"$/i.exec(section)?.[1] ?? null
|
|
145
|
+
continue
|
|
146
|
+
}
|
|
147
|
+
if (remote === null) continue
|
|
148
|
+
const eq = line.indexOf('=')
|
|
149
|
+
if (eq === -1 || line.slice(0, eq).trim().toLowerCase() !== 'url') continue
|
|
150
|
+
const value = line.slice(eq + 1).trim()
|
|
151
|
+
if (/[#;\\]/.test(value)) return null
|
|
152
|
+
const url = value.replace(/^"(.*)"$/, '$1')
|
|
153
|
+
if (url.includes('"')) return null
|
|
154
|
+
// `remote.<name>.url` is multi-valued; git fetches from the first.
|
|
155
|
+
if (!seen.has(remote)) {
|
|
156
|
+
seen.add(remote)
|
|
157
|
+
remotes.push([remote, url])
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return remotes
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function remotesFromGit(root: string): [string, string][] {
|
|
164
|
+
const out = git(root, ['remote', '-v'])
|
|
165
|
+
if (out === null) return []
|
|
166
|
+
const pairs: [string, string][] = []
|
|
167
|
+
for (const line of out.split('\n')) {
|
|
168
|
+
const m = /^(\w+)\s+(\S+)\s+\((fetch|push)\)$/.exec(line.trim())
|
|
169
|
+
if (m !== null) pairs.push([m[1]!, m[2]!])
|
|
170
|
+
}
|
|
171
|
+
return pairs
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** `host/owner/repo` for the four url shapes Nx reads; null for the rest. */
|
|
175
|
+
export function parseRemoteUrl(url: string): string | null {
|
|
176
|
+
const u = url.trim()
|
|
177
|
+
const m =
|
|
178
|
+
/^git@([^:]+):([^/]+)\/(.+?)(\.git)?$/.exec(u) ??
|
|
179
|
+
/^https?:\/\/[^@]+@([^/]+)\/([^/]+)\/(.+?)(\.git)?$/.exec(u) ??
|
|
180
|
+
/^https?:\/\/([^@/]+)\/([^/]+)\/(.+?)(\.git)?$/.exec(u)
|
|
181
|
+
if (m !== null) return `${m[1]}/${m[2]}/${m[3]}`
|
|
182
|
+
const ssh = /^ssh:\/\/[^@]+@([^:/]+)(:[0-9]+)?\/([^/]+)\/(.+?)(\.git)?$/.exec(u)
|
|
183
|
+
return ssh === null ? null : `${ssh[1]}/${ssh[3]}/${ssh[4]}`
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** The sorted-first root commit, so every clone agrees; null when shallow. */
|
|
187
|
+
function firstCommit(root: string): string | null {
|
|
188
|
+
if (git(root, ['rev-parse', '--is-shallow-repository'])?.trim() === 'true') return null
|
|
189
|
+
const roots = git(root, ['rev-list', '--max-parents=0', 'HEAD'])
|
|
190
|
+
return roots?.split(/\r?\n/).filter(Boolean).sort()[0] ?? null
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function git(cwd: string, args: string[]): string | null {
|
|
194
|
+
try {
|
|
195
|
+
const p = Bun.spawnSync(['git', ...args], { cwd, stdout: 'pipe', stderr: 'ignore' })
|
|
196
|
+
return p.exitCode === 0 ? p.stdout.toString() : null
|
|
197
|
+
} catch {
|
|
198
|
+
return null
|
|
199
|
+
}
|
|
200
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { constants, type Dirent } from 'node:fs'
|
|
2
2
|
import { access, readdir, realpath, stat } from 'node:fs/promises'
|
|
3
|
+
import { homedir } from 'node:os'
|
|
3
4
|
import path from 'node:path'
|
|
4
5
|
import type { ProjectConfig, WorkspaceConfig } from '../config.js'
|
|
5
6
|
import {
|
|
@@ -13,6 +14,7 @@ import {
|
|
|
13
14
|
normalizeBunGlob,
|
|
14
15
|
} from '../util/index.js'
|
|
15
16
|
import { type LoadReads, readOnce, unreadable } from './load-reads.js'
|
|
17
|
+
import { repoIdOf } from './repo-id.js'
|
|
16
18
|
|
|
17
19
|
export interface PackageJson {
|
|
18
20
|
name: string
|
|
@@ -349,15 +351,35 @@ function extglobRefusal(pattern: string, file: string, field: string): UserError
|
|
|
349
351
|
}
|
|
350
352
|
|
|
351
353
|
/**
|
|
352
|
-
*
|
|
353
|
-
* `
|
|
354
|
-
*
|
|
354
|
+
* The workspace's own cache directory (index, history, memos): the
|
|
355
|
+
* workspace's `cacheDir`, else `VX_CACHE_DIR`, else `.vx/cache`, relative
|
|
356
|
+
* to the workspace root.
|
|
355
357
|
*/
|
|
356
358
|
export function resolveCacheDir(root: string, config: WorkspaceConfig | null): string {
|
|
357
|
-
const rel = config?.cacheDir ?? path.join('.vx', 'cache')
|
|
359
|
+
const rel = config?.cacheDir ?? (process.env['VX_CACHE_DIR'] || path.join('.vx', 'cache'))
|
|
358
360
|
return path.resolve(root, rel)
|
|
359
361
|
}
|
|
360
362
|
|
|
363
|
+
/**
|
|
364
|
+
* Where this repository's shared store lives: `~/.vx/<repo id>/cache`, as
|
|
365
|
+
* Nx 23 keeps `~/.nx/<id>/cache` (owner, 2026-10-06), so every checkout of
|
|
366
|
+
* the repository hits what another saved and no other repository shares
|
|
367
|
+
* it (`repoIdOf`). Null when the workspace names its cache (`cacheDir`,
|
|
368
|
+
* `VX_CACHE_DIR`): that directory then holds everything, as one
|
|
369
|
+
* workspace's alone. Null with no repository identity or no home.
|
|
370
|
+
*/
|
|
371
|
+
export function resolveStoreRoot(root: string, config: WorkspaceConfig | null): string | null {
|
|
372
|
+
if (config?.cacheDir !== undefined || process.env['VX_CACHE_DIR']) return null
|
|
373
|
+
// HOME first: Bun's homedir() keeps the HOME the process started with (1.4.2).
|
|
374
|
+
const home = process.env['HOME'] || homedir()
|
|
375
|
+
if (!path.isAbsolute(home)) return null
|
|
376
|
+
let id = repoIds.get(root)
|
|
377
|
+
if (id === undefined) repoIds.set(root, (id = repoIdOf(root)))
|
|
378
|
+
return id === null ? null : path.join(home, '.vx', id, 'cache')
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const repoIds = new Map<string, string | null>()
|
|
382
|
+
|
|
361
383
|
/**
|
|
362
384
|
* Read the workspace's package-glob list, supporting all common
|
|
363
385
|
* package managers:
|