@vzn/vx 0.0.594 → 0.0.595
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/bin.ts +26 -11
- package/src/cache/archive.ts +2 -2
- package/src/cache/cache.ts +96 -90
- package/src/cache/config-evals.ts +19 -4
- package/src/cache/git-inputs.ts +19 -8
- package/src/cache/inputs.ts +110 -37
- package/src/cache/layered-cache.ts +25 -2
- package/src/cache/tar-stream.ts +1 -1
- package/src/cli/cache.ts +1 -11
- package/src/cli/completions.ts +42 -6
- package/src/cli/foreign-flags.ts +25 -4
- package/src/cli/help.ts +5 -3
- package/src/cli/init.ts +1 -1
- package/src/cli/plan-format.ts +9 -0
- package/src/cli/plugin-templates.ts +2 -2
- package/src/cli/run.ts +29 -27
- package/src/cli/select.ts +30 -3
- package/src/cli/show.ts +42 -3
- package/src/cli/watch-filter.ts +14 -1
- package/src/cli/watch-fs.ts +15 -7
- package/src/cli/watch-judge.ts +1 -1
- package/src/cli/watch.ts +18 -11
- package/src/config.ts +5 -5
- package/src/exec/env.ts +5 -0
- package/src/exec/index.ts +1 -0
- package/src/exec/proc-sample.ts +5 -1
- package/src/exec/runner.ts +98 -40
- package/src/exec/sandbox-paths.ts +11 -5
- package/src/exec/sandbox-runtime.ts +20 -4
- package/src/graph/index.ts +1 -0
- package/src/graph/scheduler.ts +1 -1
- package/src/index.ts +6 -0
- package/src/orchestrator/execute-task.ts +29 -7
- package/src/orchestrator/hit-restore.ts +6 -3
- package/src/orchestrator/local-shortcircuit.ts +4 -1
- package/src/orchestrator/lockfile-claim.ts +43 -46
- package/src/orchestrator/plan.ts +25 -1
- package/src/orchestrator/plugin-host.ts +25 -3
- package/src/orchestrator/plugin.ts +2 -1
- package/src/orchestrator/run.ts +52 -8
- package/src/orchestrator/signals.ts +5 -2
- package/src/orchestrator/summary.ts +14 -1
- package/src/util/index.ts +7 -1
- package/src/util/secret-mask.ts +36 -0
- package/src/util/settle.ts +15 -0
- package/src/workspace/affected.ts +30 -8
- package/src/workspace/config-cache.ts +25 -4
- package/src/workspace/config-imports.ts +1 -1
- package/src/workspace/config-schema.ts +48 -6
- package/src/workspace/migrate-scripts.ts +1 -1
- package/src/workspace/package-graph.ts +15 -3
- package/src/workspace/project-loader.ts +72 -0
- package/src/workspace/workspace.ts +82 -11
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vzn/vx",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.595",
|
|
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.595",
|
|
25
|
+
"@vzn/vx-linux-arm64": "0.0.595",
|
|
26
|
+
"@vzn/vx-darwin-x64": "0.0.595",
|
|
27
|
+
"@vzn/vx-darwin-arm64": "0.0.595"
|
|
28
28
|
},
|
|
29
29
|
"dependencies": {
|
|
30
30
|
"@anthropic-ai/sandbox-runtime": "^0.0.76"
|
package/src/bin.ts
CHANGED
|
@@ -24,17 +24,10 @@ async function main(): Promise<void> {
|
|
|
24
24
|
// costs). Registered before any verb runs; the façade loads on first use.
|
|
25
25
|
registerCoreAlias(() => import('./index.js') as Promise<Record<string, unknown>>)
|
|
26
26
|
const code = await run(argv)
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
// `
|
|
30
|
-
//
|
|
31
|
-
// json` on a 300-project workspace was cut mid-string (2026-09-15).
|
|
32
|
-
// `stdout.end(cb)` fixed that on 1.4.2 — and on 1.3.11 the callback
|
|
33
|
-
// still fires early: 2 MiB written, 214 KB delivered (2026-09-20).
|
|
34
|
-
// Setting the code and letting the loop drain is the one form that
|
|
35
|
-
// does not depend on when a runtime decides a pipe is flushed; it
|
|
36
|
-
// costs a hang if a verb leaves a handle open, which the suite's
|
|
37
|
-
// several hundred spawns of this binary would show at once.
|
|
27
|
+
// Not `process.exit` here: Bun drops what a pipe has not yet taken
|
|
28
|
+
// when it follows a large write (300 KB written, 64 KiB delivered;
|
|
29
|
+
// `vx history --format json` was cut mid-string, 2026-09-15). The code
|
|
30
|
+
// is set, and `exitOnceFlushed` exits after both streams have ended.
|
|
38
31
|
process.exitCode = code
|
|
39
32
|
} catch (err) {
|
|
40
33
|
const { fsRefusalHint, isFsRefusal, isOutOfFds, isUserError, OUT_OF_FDS_HINT } =
|
|
@@ -65,6 +58,28 @@ async function main(): Promise<void> {
|
|
|
65
58
|
process.exitCode = 1
|
|
66
59
|
}
|
|
67
60
|
settled = true
|
|
61
|
+
await exitOnceFlushed()
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Exit once stdout and stderr have ended, with the code the verb set. A
|
|
66
|
+
* config can leave a timer behind (a top-level `setInterval`, an await that
|
|
67
|
+
* hit its evaluation budget), and the loop it holds never drained: vx
|
|
68
|
+
* printed its verdict and hung for good. Only after `main` has settled, so
|
|
69
|
+
* `vx watch`, `vx mcp` and a run's kept-alive tasks end when their verb
|
|
70
|
+
* does. `end(cb)` delivers a large write whole on Bun 1.4.2 (4 MiB under a
|
|
71
|
+
* slow reader; `write('', cb)` delivered 450 KB), the floor `engines.bun`
|
|
72
|
+
* holds. A stream whose end never calls back leaves the loop to drain. A
|
|
73
|
+
* signal's handler exits by itself, with the signal's code.
|
|
74
|
+
*/
|
|
75
|
+
async function exitOnceFlushed(): Promise<void> {
|
|
76
|
+
if ((await import('./util/index.js')).exitClaimedBySignal()) return
|
|
77
|
+
let open = 2
|
|
78
|
+
const ended = (): void => {
|
|
79
|
+
if (--open === 0) process.exit()
|
|
80
|
+
}
|
|
81
|
+
process.stdout.end(ended)
|
|
82
|
+
process.stderr.end(ended)
|
|
68
83
|
}
|
|
69
84
|
|
|
70
85
|
// The loop drained while the verb was still pending: something it awaits
|
package/src/cache/archive.ts
CHANGED
|
@@ -168,7 +168,7 @@ export interface ExecUsage {
|
|
|
168
168
|
}
|
|
169
169
|
|
|
170
170
|
/** One regular file in an artifact, with its restore metadata resolved. */
|
|
171
|
-
|
|
171
|
+
interface ArchiveEntry {
|
|
172
172
|
/** Full archive-relative name, e.g. `outputs/dist/index.js`. */
|
|
173
173
|
name: string
|
|
174
174
|
size: number
|
|
@@ -192,7 +192,7 @@ export class ArchiveSecurityError extends Error {
|
|
|
192
192
|
}
|
|
193
193
|
|
|
194
194
|
/** What `packArtifactStream` packs: stdout plus archive name → absolute source path. */
|
|
195
|
-
|
|
195
|
+
interface PackArgs {
|
|
196
196
|
/** The cache key, recorded in the sidecar (item 943); ingest refuses an artifact without it. */
|
|
197
197
|
key?: string
|
|
198
198
|
stdout: string
|
package/src/cache/cache.ts
CHANGED
|
@@ -44,6 +44,7 @@ import {
|
|
|
44
44
|
openSync,
|
|
45
45
|
readdirSync,
|
|
46
46
|
renameSync,
|
|
47
|
+
utimesSync,
|
|
47
48
|
writeFileSync,
|
|
48
49
|
} from 'node:fs'
|
|
49
50
|
import { readdir, rm, stat, unlink, writeFile } from 'node:fs/promises'
|
|
@@ -242,6 +243,30 @@ export const SCHEMA_VERSION = 'v32'
|
|
|
242
243
|
/** The tables a store holds: dropped from a workspace index that held them itself. */
|
|
243
244
|
const STORE_TABLES = ['entry_inputs', 'output_files', 'entry_stdout', 'store_meta', 'entries']
|
|
244
245
|
|
|
246
|
+
/**
|
|
247
|
+
* An index that records a schema other than this vx's: the one an opener
|
|
248
|
+
* that writes resets. Reads the recorded version alone and touches
|
|
249
|
+
* nothing; an unreadable file is left to the open to refuse.
|
|
250
|
+
*/
|
|
251
|
+
function indexOfAnotherSchema(dbFile: string): boolean {
|
|
252
|
+
let found: string | undefined
|
|
253
|
+
try {
|
|
254
|
+
const db = new Database(dbFile, { readonly: true })
|
|
255
|
+
try {
|
|
256
|
+
found = (
|
|
257
|
+
db.query("SELECT value FROM schema_meta WHERE key = 'version'").get() as {
|
|
258
|
+
value: string
|
|
259
|
+
} | null
|
|
260
|
+
)?.value
|
|
261
|
+
} finally {
|
|
262
|
+
closeDb(db)
|
|
263
|
+
}
|
|
264
|
+
} catch {
|
|
265
|
+
return false
|
|
266
|
+
}
|
|
267
|
+
return found !== undefined && found !== SCHEMA_VERSION
|
|
268
|
+
}
|
|
269
|
+
|
|
245
270
|
/** An entry row with its stdout, which lives apart (v29); none stored reads as ''. */
|
|
246
271
|
const SELECT_ENTRY =
|
|
247
272
|
"SELECT e.*, COALESCE(s.stdout, '') AS stdout FROM entries e LEFT JOIN entry_stdout s ON s.hash = e.hash"
|
|
@@ -608,41 +633,6 @@ export class Cache implements CacheLayer {
|
|
|
608
633
|
return new Cache(cacheDir, undefined, undefined, undefined, 'inspect')
|
|
609
634
|
}
|
|
610
635
|
|
|
611
|
-
/**
|
|
612
|
-
* What a real prune reaps from an index of an EARLIER schema, which it
|
|
613
|
-
* resets first, leaving every aged artifact row-less: a dry run that
|
|
614
|
-
* refused it could not preview the biggest prune there is, the one
|
|
615
|
-
* after an upgrade (item 1083). Reads the recorded version alone and
|
|
616
|
-
* touches nothing. Null for an absent, current, newer or unreadable
|
|
617
|
-
* index; `Cache.inspect` answers those.
|
|
618
|
-
*/
|
|
619
|
-
static async orphansBeforeReset(
|
|
620
|
-
cacheDir: string,
|
|
621
|
-
): Promise<{ found: string; orphans: number; orphanBytes: number } | null> {
|
|
622
|
-
const dbFile = path.join(cacheDir, 'cache.db')
|
|
623
|
-
if (!existsSync(dbFile)) return null
|
|
624
|
-
let found: string | undefined
|
|
625
|
-
try {
|
|
626
|
-
const db = new Database(dbFile, { readonly: true })
|
|
627
|
-
try {
|
|
628
|
-
found = (
|
|
629
|
-
db.prepare("SELECT value FROM schema_meta WHERE key = 'version'").get() as {
|
|
630
|
-
value: string
|
|
631
|
-
} | null
|
|
632
|
-
)?.value
|
|
633
|
-
} finally {
|
|
634
|
-
closeDb(db)
|
|
635
|
-
}
|
|
636
|
-
} catch {
|
|
637
|
-
return null
|
|
638
|
-
}
|
|
639
|
-
if (found === undefined || found === SCHEMA_VERSION) return null
|
|
640
|
-
const aged = await scanOrphanFiles(cacheDir, () => new Set())
|
|
641
|
-
let orphanBytes = 0
|
|
642
|
-
for (const o of aged) orphanBytes += o.size
|
|
643
|
-
return { found, orphans: aged.length, orphanBytes }
|
|
644
|
-
}
|
|
645
|
-
|
|
646
636
|
constructor(
|
|
647
637
|
private readonly cacheDir: string,
|
|
648
638
|
localPolicy: { read: boolean; write: boolean } = { read: true, write: true },
|
|
@@ -655,23 +645,25 @@ export class Cache implements CacheLayer {
|
|
|
655
645
|
*/
|
|
656
646
|
private readonly artifactCeiling: number = MAX_DECOMPRESSED_ARTIFACT_BYTES,
|
|
657
647
|
/**
|
|
658
|
-
* `'inspect'`: a reading verb (`why`, `last`, `info
|
|
659
|
-
*
|
|
660
|
-
*
|
|
648
|
+
* `'inspect'`: a reading verb (`why`, `last`, `info`). It never resets
|
|
649
|
+
* the index: a schema it cannot read is refused, named, and left as it
|
|
650
|
+
* was. `'preview'`: a dry prune, which reads such an index as the reset
|
|
651
|
+
* the real prune does first leaves it, empty, with the store still
|
|
652
|
+
* reached, so it names what that prune takes (item 1083).
|
|
661
653
|
*/
|
|
662
|
-
mode: 'open' | 'inspect' = 'open',
|
|
654
|
+
mode: 'open' | 'inspect' | 'preview' = 'open',
|
|
663
655
|
/**
|
|
664
656
|
* The shared store's directory, unversioned: every key is seeded with
|
|
665
657
|
* `CACHE_VERSION`, so two vx versions never read each other's entries,
|
|
666
658
|
* and the store's own schema is `store_meta.schema` (`matchStoreSchema`). Or
|
|
667
659
|
* `null` for an index that holds its entries itself (a `cacheDir`).
|
|
668
660
|
* Undefined follows the layout the index records: a reading verb, a
|
|
669
|
-
* plugin's handle.
|
|
661
|
+
* plugin's handle. A reading open reads it only where the index
|
|
670
662
|
* records none (deleted, or never written beside a run's store).
|
|
671
663
|
*/
|
|
672
664
|
storeRoot?: string | null,
|
|
673
665
|
) {
|
|
674
|
-
this.inspecting = mode
|
|
666
|
+
this.inspecting = mode !== 'open'
|
|
675
667
|
this.read = localPolicy.read
|
|
676
668
|
// The directory exists before the DB opens — bun:sqlite won't create
|
|
677
669
|
// parent dirs for us. A directory this user cannot write into is a
|
|
@@ -687,7 +679,9 @@ export class Cache implements CacheLayer {
|
|
|
687
679
|
// said "no recorded runs yet" (item 900).
|
|
688
680
|
const dbFile = path.join(cacheDir, 'cache.db')
|
|
689
681
|
this.dbFile = dbFile
|
|
690
|
-
const absent =
|
|
682
|
+
const absent =
|
|
683
|
+
mode !== 'open' &&
|
|
684
|
+
(!existsSync(dbFile) || (mode === 'preview' && indexOfAnotherSchema(dbFile)))
|
|
691
685
|
this.writeBlocked = absent ? 'no index there yet' : openCacheDir(cacheDir, repoDir)
|
|
692
686
|
this.write = localPolicy.write && this.writeBlocked === null
|
|
693
687
|
try {
|
|
@@ -779,7 +773,7 @@ export class Cache implements CacheLayer {
|
|
|
779
773
|
// 2026-10-06; a newer one used to be refused, item 896). A reading verb
|
|
780
774
|
// leaves it as it was.
|
|
781
775
|
const refuseUnreadable = (found: string): void => {
|
|
782
|
-
if (mode
|
|
776
|
+
if (mode !== 'open') {
|
|
783
777
|
throw new UserError(
|
|
784
778
|
`the cache at ${cacheDir} holds index schema ${found} from another vx version; this vx reads ${SCHEMA_VERSION}, so nothing in it is readable here, and this verb leaves it untouched. The next \`vx run\` resets it`,
|
|
785
779
|
)
|
|
@@ -833,7 +827,7 @@ export class Cache implements CacheLayer {
|
|
|
833
827
|
)?.value,
|
|
834
828
|
)
|
|
835
829
|
let storeDir =
|
|
836
|
-
mode
|
|
830
|
+
mode !== 'open'
|
|
837
831
|
? (recorded ?? (typeof storeRoot === 'string' ? storeRoot : undefined))
|
|
838
832
|
: storeRoot === undefined
|
|
839
833
|
? recorded
|
|
@@ -877,17 +871,10 @@ export class Cache implements CacheLayer {
|
|
|
877
871
|
this.storeDir = storeDir
|
|
878
872
|
this.artifactDir = storeDir ?? cacheDir
|
|
879
873
|
if (storeDir !== undefined) {
|
|
880
|
-
this.attachStore(storeDir, mode
|
|
874
|
+
this.attachStore(storeDir, mode !== 'open')
|
|
881
875
|
this.storeReset = this.matchStoreSchema(mode === 'open' && this.writeBlocked === null)
|
|
882
|
-
}
|
|
883
|
-
|
|
884
|
-
createTables(this.db, storeDir === undefined ? 'main' : 'store')
|
|
885
|
-
if (storeDir !== undefined && mode === 'open' && this.writeBlocked === null) {
|
|
886
|
-
this.db
|
|
887
|
-
.prepare(
|
|
888
|
-
"INSERT INTO store.store_meta(key, value) VALUES ('schema', ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
|
889
|
-
)
|
|
890
|
-
.run(SCHEMA_VERSION)
|
|
876
|
+
} else {
|
|
877
|
+
createTables(this.db, 'main')
|
|
891
878
|
}
|
|
892
879
|
if (mode === 'open' && this.writeBlocked === null && storeDir !== recorded) {
|
|
893
880
|
if (storeDir === undefined)
|
|
@@ -1018,50 +1005,63 @@ export class Cache implements CacheLayer {
|
|
|
1018
1005
|
* reads it: a store another `SCHEMA_VERSION` wrote has its tables dropped
|
|
1019
1006
|
* (owner, 2026-10-06), the artifacts kept. Each is indexed again from its
|
|
1020
1007
|
* own bytes on its next hit (`adopt`). A reading verb reads such a store
|
|
1021
|
-
* as empty and changes nothing.
|
|
1008
|
+
* as empty and changes nothing. Creates the store's tables either way.
|
|
1022
1009
|
*/
|
|
1023
1010
|
private matchStoreSchema(writable: boolean): SchemaReset | null {
|
|
1024
|
-
const
|
|
1025
|
-
(
|
|
1026
|
-
|
|
1027
|
-
.
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1011
|
+
const read = (): { has: boolean; found: string | undefined } => {
|
|
1012
|
+
const tables = new Set(
|
|
1013
|
+
(
|
|
1014
|
+
this.db
|
|
1015
|
+
.prepare("SELECT name FROM store.sqlite_master WHERE type = 'table'")
|
|
1016
|
+
.all() as Array<{
|
|
1017
|
+
name: string
|
|
1018
|
+
}>
|
|
1019
|
+
).map((r) => r.name),
|
|
1020
|
+
)
|
|
1021
|
+
const found = tables.has('store_meta')
|
|
1022
|
+
? (
|
|
1023
|
+
this.db.prepare("SELECT value FROM store.store_meta WHERE key = 'schema'").get() as {
|
|
1024
|
+
value: string
|
|
1025
|
+
} | null
|
|
1026
|
+
)?.value
|
|
1027
|
+
: undefined
|
|
1028
|
+
return { has: tables.has('entries'), found }
|
|
1029
|
+
}
|
|
1030
|
+
const seen = read()
|
|
1031
|
+
if (seen.has && seen.found === SCHEMA_VERSION) {
|
|
1032
|
+
createTables(this.db, 'store')
|
|
1033
|
+
return null
|
|
1034
|
+
}
|
|
1042
1035
|
if (!writable) {
|
|
1043
|
-
|
|
1044
|
-
|
|
1036
|
+
if (seen.has) {
|
|
1037
|
+
this.db.exec('DETACH DATABASE store')
|
|
1038
|
+
this.db.prepare('ATTACH DATABASE ? AS store').run(':memory:')
|
|
1039
|
+
}
|
|
1040
|
+
createTables(this.db, 'store')
|
|
1045
1041
|
return null
|
|
1046
1042
|
}
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
const now = (
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
if (t !== 'store_meta') this.db.exec(`DROP TABLE IF EXISTS store.${t}`)
|
|
1043
|
+
// Re-read, drop, create and stamp under one write lock: as separate
|
|
1044
|
+
// transactions, another vx's open landing after the drop made its own
|
|
1045
|
+
// tables, and this one's `CREATE IF NOT EXISTS` kept them under its stamp.
|
|
1046
|
+
return this.db
|
|
1047
|
+
.transaction((): SchemaReset | null => {
|
|
1048
|
+
const now = read()
|
|
1049
|
+
let reset: SchemaReset | null = null
|
|
1050
|
+
if (now.has && now.found !== SCHEMA_VERSION) {
|
|
1051
|
+
for (const t of STORE_TABLES) {
|
|
1052
|
+
if (t !== 'store_meta') this.db.exec(`DROP TABLE IF EXISTS store.${t}`)
|
|
1053
|
+
}
|
|
1054
|
+
reset = { from: now.found ?? 'an earlier schema', to: SCHEMA_VERSION }
|
|
1060
1055
|
}
|
|
1061
|
-
|
|
1056
|
+
createTables(this.db, 'store')
|
|
1057
|
+
this.db
|
|
1058
|
+
.prepare(
|
|
1059
|
+
"INSERT INTO store.store_meta(key, value) VALUES ('schema', ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
|
1060
|
+
)
|
|
1061
|
+
.run(SCHEMA_VERSION)
|
|
1062
|
+
return reset
|
|
1062
1063
|
})
|
|
1063
1064
|
.immediate()
|
|
1064
|
-
return reset
|
|
1065
1065
|
}
|
|
1066
1066
|
|
|
1067
1067
|
// --- config evaluations: `ConfigEvalStore`, delegated to `ConfigEvalTable` ---
|
|
@@ -1222,6 +1222,12 @@ export class Cache implements CacheLayer {
|
|
|
1222
1222
|
// A second name for the same bytes: the index step renames it over
|
|
1223
1223
|
// the artifact inside its write transaction, as for a save.
|
|
1224
1224
|
linkSync(finalPath, tmpPath)
|
|
1225
|
+
// The link shares the artifact's inode and so its mtime, which an
|
|
1226
|
+
// artifact that needs adopting has had for hours: another process's
|
|
1227
|
+
// orphan sweep read the temp as a crashed save's and took it, and the
|
|
1228
|
+
// artifact with it, while this adopt was scanning them.
|
|
1229
|
+
const now = new Date()
|
|
1230
|
+
utimesSync(tmpPath, now, now)
|
|
1225
1231
|
} catch {
|
|
1226
1232
|
return false
|
|
1227
1233
|
}
|
|
@@ -6,6 +6,9 @@
|
|
|
6
6
|
import type { Database, SQLQueryBindings } from 'bun:sqlite'
|
|
7
7
|
import { lazyStatement } from './schema.js'
|
|
8
8
|
|
|
9
|
+
const CONFIG_EVALS_SWEPT_AT = 'config_evals_swept_at'
|
|
10
|
+
const DAY_MS = 24 * 60 * 60 * 1000
|
|
11
|
+
|
|
9
12
|
export class ConfigEvalTable {
|
|
10
13
|
private readonly selectConfigEval: ReturnType<Database['prepare']>
|
|
11
14
|
private readonly insertConfigEval: ReturnType<Database['prepare']>
|
|
@@ -106,10 +109,22 @@ export class ConfigEvalTable {
|
|
|
106
109
|
/**
|
|
107
110
|
* Retention: a row not WRITTEN since `cutoff`. A hit does not refresh it
|
|
108
111
|
* (that would be a write per config on every warm run), so a config that
|
|
109
|
-
* hit for thirty days is evaluated once more and stored again.
|
|
112
|
+
* hit for thirty days is evaluated once more and stored again. No index
|
|
113
|
+
* covers `created_at`, so each sweep reads every row with its JSON (1–6 ms
|
|
114
|
+
* at 8,000 rows, 2 MB); it runs once a day, on its own clock in
|
|
115
|
+
* `schema_meta`, as the file-hash sweep does.
|
|
110
116
|
*/
|
|
111
|
-
pruneOlderThan(cutoff: number): void {
|
|
112
|
-
|
|
113
|
-
|
|
117
|
+
pruneOlderThan(cutoff: number, now: number = Date.now()): void {
|
|
118
|
+
const last = this.db
|
|
119
|
+
.prepare('SELECT value FROM schema_meta WHERE key = ?')
|
|
120
|
+
.get(CONFIG_EVALS_SWEPT_AT) as { value: string } | null
|
|
121
|
+
if (last !== null && now - Number(last.value) < DAY_MS) return
|
|
122
|
+
this.db.transaction(() => {
|
|
123
|
+
this.db.prepare('DELETE FROM config_evals WHERE created_at < ?').run(cutoff)
|
|
124
|
+
this.db.prepare('DELETE FROM config_closures WHERE created_at < ?').run(cutoff)
|
|
125
|
+
this.db
|
|
126
|
+
.prepare('INSERT OR REPLACE INTO schema_meta(key, value) VALUES (?, ?)')
|
|
127
|
+
.run(CONFIG_EVALS_SWEPT_AT, String(now))
|
|
128
|
+
})()
|
|
114
129
|
}
|
|
115
130
|
}
|
package/src/cache/git-inputs.ts
CHANGED
|
@@ -1304,8 +1304,11 @@ export async function startGitEnumeration(
|
|
|
1304
1304
|
const facts = repoFacts(workspaceRoot)
|
|
1305
1305
|
// The blob-size check's verdict is a function of the index's entries, so
|
|
1306
1306
|
// the index file's bytes key it: one read, where `ls-files --debug` and a
|
|
1307
|
-
// lookup per entry cost 550 ms at 100,000 files (A-60).
|
|
1308
|
-
|
|
1307
|
+
// lookup per entry cost 550 ms at 100,000 files (A-60). The verdict's
|
|
1308
|
+
// paths are workspace-relative, so the repo→workspace prefix keys it too:
|
|
1309
|
+
// a nested workspace sharing the cache read the outer one's verdict and
|
|
1310
|
+
// trusted a resized blob.
|
|
1311
|
+
const pathspecKey = xxh3hex([facts?.prefix ?? '', ...pathspecs].join('\0'))
|
|
1309
1312
|
const indexFile =
|
|
1310
1313
|
facts === null || facts.indexFile === ''
|
|
1311
1314
|
? undefined
|
|
@@ -1521,9 +1524,10 @@ export async function applyGitEnumeration(
|
|
|
1521
1524
|
// Sort once, then each project's files are a contiguous range found
|
|
1522
1525
|
// by binary search on its `dir/` prefix — O((F+P) log F) instead of
|
|
1523
1526
|
// the O(P·F) per-project startsWith scan (54 ms at 1090 projects ×
|
|
1524
|
-
// ~9k files; ~5 ms this way).
|
|
1525
|
-
//
|
|
1526
|
-
//
|
|
1527
|
+
// ~9k files; ~5 ms this way). Every path under `dir/` sorts in
|
|
1528
|
+
// [`dir/`, `dir0`): '0' is the code unit after '/'. An upper bound of
|
|
1529
|
+
// `dir/` + U+FFFF left out a file whose name starts with U+FFFF, and
|
|
1530
|
+
// it never entered the key.
|
|
1527
1531
|
// Git lists in order; a list that already is skips the sort.
|
|
1528
1532
|
let inOrder = true
|
|
1529
1533
|
for (let i = 1; i < all.length; i++) {
|
|
@@ -1557,10 +1561,15 @@ export async function applyGitEnumeration(
|
|
|
1557
1561
|
return lo
|
|
1558
1562
|
}
|
|
1559
1563
|
for (const projectDir of projectDirs) {
|
|
1560
|
-
const
|
|
1564
|
+
const spelled =
|
|
1561
1565
|
base !== undefined && projectDir.startsWith(base) && path.normalize(projectDir) === projectDir
|
|
1562
1566
|
? projectDir.slice(base.length).replace(/\/$/, '')
|
|
1563
1567
|
: relPosix(workspaceRoot, projectDir)
|
|
1568
|
+
// macOS git reports paths NFC (core.precomposeunicode) while a dir
|
|
1569
|
+
// discovered by readdir keeps the spelling it was created with: match
|
|
1570
|
+
// NFC, and key the OIDs by the project's spelling, which is how
|
|
1571
|
+
// `resolveFiles` looks them up.
|
|
1572
|
+
const relPrefix = spelled.normalize('NFC')
|
|
1564
1573
|
if (relPrefix === '' || relPrefix === '.') {
|
|
1565
1574
|
cache.set(projectDir, all)
|
|
1566
1575
|
const rootOids = new Map<string, string>()
|
|
@@ -1570,14 +1579,16 @@ export async function applyGitEnumeration(
|
|
|
1570
1579
|
}
|
|
1571
1580
|
const prefix = `${relPrefix}/`
|
|
1572
1581
|
const start = lowerBound(prefix)
|
|
1573
|
-
const end = lowerBound(`${
|
|
1582
|
+
const end = lowerBound(`${relPrefix}0`)
|
|
1574
1583
|
const matches: string[] = []
|
|
1575
1584
|
const projOids = new Map<string, string>()
|
|
1585
|
+
const projAbs =
|
|
1586
|
+
relPrefix === spelled ? abs : (rel: string) => path.join(projectDir, rel.slice(prefix.length))
|
|
1576
1587
|
for (let i = start; i < end; i++) {
|
|
1577
1588
|
const rel = sorted[i]!
|
|
1578
1589
|
matches.push(rel.slice(prefix.length))
|
|
1579
1590
|
const oid = trusted.get(rel)
|
|
1580
|
-
if (oid !== undefined) projOids.set(
|
|
1591
|
+
if (oid !== undefined) projOids.set(projAbs(rel), oid)
|
|
1581
1592
|
}
|
|
1582
1593
|
// An empty slice is a directory git did not see, not an empty project:
|
|
1583
1594
|
// a project has at least its package.json, tracked or untracked. A
|