@vzn/vx 0.0.57 → 0.0.58
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 +84 -32
- package/src/orchestrator/execute-task.ts +4 -7
- package/src/util/edit-distance.ts +11 -7
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vzn/vx",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.58",
|
|
4
4
|
"description": "An open, extensible monorepo task runner.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -17,10 +17,10 @@
|
|
|
17
17
|
"node": ">=18"
|
|
18
18
|
},
|
|
19
19
|
"optionalDependencies": {
|
|
20
|
-
"@vzn/vx-linux-x64": "0.0.
|
|
21
|
-
"@vzn/vx-linux-arm64": "0.0.
|
|
22
|
-
"@vzn/vx-darwin-x64": "0.0.
|
|
23
|
-
"@vzn/vx-darwin-arm64": "0.0.
|
|
20
|
+
"@vzn/vx-linux-x64": "0.0.58",
|
|
21
|
+
"@vzn/vx-linux-arm64": "0.0.58",
|
|
22
|
+
"@vzn/vx-darwin-x64": "0.0.58",
|
|
23
|
+
"@vzn/vx-darwin-arm64": "0.0.58"
|
|
24
24
|
},
|
|
25
25
|
"dependencies": {
|
|
26
26
|
"@anthropic-ai/sandbox-runtime": "^0.0.76"
|
package/src/cache/cache.ts
CHANGED
|
@@ -116,6 +116,15 @@ function unreadableIndex(err: unknown): boolean {
|
|
|
116
116
|
return typeof code === 'string' && (code.startsWith('SQLITE_CORRUPT') || code === 'SQLITE_NOTADB')
|
|
117
117
|
}
|
|
118
118
|
|
|
119
|
+
/** The refusal for an index SQLite cannot read, at the open or at any later read or write (A-8). */
|
|
120
|
+
function unreadableIndexError(dbFile: string, err: unknown): UserError {
|
|
121
|
+
return new UserError(
|
|
122
|
+
`the cache index ${dbFile} is not a database SQLite can read (${(err as Error).message}) — ` +
|
|
123
|
+
`it holds nothing a run cannot rebuild: remove it with its -wal and -shm files and ` +
|
|
124
|
+
`re-run, and \`vx cache prune\` reclaims the artifacts it indexed`,
|
|
125
|
+
)
|
|
126
|
+
}
|
|
127
|
+
|
|
119
128
|
/** Say once, on the channel the opener has, that an upgrade emptied the index. */
|
|
120
129
|
export function noteSchemaReset(cache: Cache, warn: (message: string) => void): void {
|
|
121
130
|
if (cache.formatChange !== null) {
|
|
@@ -417,6 +426,7 @@ export class Cache implements CacheLayer {
|
|
|
417
426
|
private readonly inspecting: boolean
|
|
418
427
|
/** Why this process cannot write into `cacheDir`, or `null`; decided at open. */
|
|
419
428
|
private readonly writeBlocked: string | null
|
|
429
|
+
private readonly dbFile: string
|
|
420
430
|
|
|
421
431
|
/**
|
|
422
432
|
* Set when THIS open found an index written by another `SCHEMA_VERSION`
|
|
@@ -508,6 +518,7 @@ export class Cache implements CacheLayer {
|
|
|
508
518
|
// created the typo's directory, a `.gitignore` and a database, then
|
|
509
519
|
// said "no recorded runs yet" (item 900).
|
|
510
520
|
const dbFile = path.join(cacheDir, 'cache.db')
|
|
521
|
+
this.dbFile = dbFile
|
|
511
522
|
const absent = mode === 'inspect' && !existsSync(dbFile)
|
|
512
523
|
this.writeBlocked = absent ? 'no index there yet' : openCacheDir(cacheDir)
|
|
513
524
|
this.write = localPolicy.write && this.writeBlocked === null
|
|
@@ -530,11 +541,7 @@ export class Cache implements CacheLayer {
|
|
|
530
541
|
} catch (err) {
|
|
531
542
|
if (!unreadableIndex(err)) throw err
|
|
532
543
|
this.db.close()
|
|
533
|
-
throw
|
|
534
|
-
`the cache index ${dbFile} is not a database SQLite can read (${(err as Error).message}) — ` +
|
|
535
|
-
`it holds nothing a run cannot rebuild: remove it with its -wal and -shm files and ` +
|
|
536
|
-
`re-run, and \`vx cache prune\` reclaims the artifacts it indexed`,
|
|
537
|
-
)
|
|
544
|
+
throw unreadableIndexError(dbFile, err)
|
|
538
545
|
}
|
|
539
546
|
}
|
|
540
547
|
readable(() => {
|
|
@@ -627,10 +634,10 @@ export class Cache implements CacheLayer {
|
|
|
627
634
|
`)
|
|
628
635
|
|
|
629
636
|
this.db.exec(`
|
|
630
|
-
--
|
|
631
|
-
--
|
|
632
|
-
--
|
|
633
|
-
--
|
|
637
|
+
-- The queryable index: command, exit_code, duration, size,
|
|
638
|
+
-- timestamps, and stdout, which the <hash>.tar.zst artifact also
|
|
639
|
+
-- carries so it survives a remote round trip; a local hit
|
|
640
|
+
-- replays it from here.
|
|
634
641
|
CREATE TABLE IF NOT EXISTS entries (
|
|
635
642
|
hash TEXT PRIMARY KEY,
|
|
636
643
|
project TEXT NOT NULL,
|
|
@@ -873,37 +880,57 @@ export class Cache implements CacheLayer {
|
|
|
873
880
|
}
|
|
874
881
|
}
|
|
875
882
|
|
|
883
|
+
/**
|
|
884
|
+
* Run `op` against the index, mapping SQLite's "not a database" — a page
|
|
885
|
+
* corrupt past the ones the open reads — to the open's own refusal. The
|
|
886
|
+
* open reads the header and `schema_meta` alone (item 1005), so a file
|
|
887
|
+
* corrupt deeper answered the first lookup that reached the bad page: every
|
|
888
|
+
* task of a run failed as an "internal error", and `vx cache prune` printed
|
|
889
|
+
* a stack (A-8). Every other error passes through as it was.
|
|
890
|
+
*/
|
|
891
|
+
private guard<T>(op: () => T): T {
|
|
892
|
+
const refuse = (err: unknown): never => {
|
|
893
|
+
throw unreadableIndex(err) ? unreadableIndexError(this.dbFile, err) : err
|
|
894
|
+
}
|
|
895
|
+
try {
|
|
896
|
+
const out = op()
|
|
897
|
+
return (out instanceof Promise ? out.catch(refuse) : out) as T
|
|
898
|
+
} catch (err) {
|
|
899
|
+
return refuse(err)
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
|
|
876
903
|
// --- config evaluations: `ConfigEvalStore`, delegated to `ConfigEvalTable` ---
|
|
877
904
|
getConfigEval(key: string): string | null {
|
|
878
|
-
return this.configEvals.getConfigEval(key)
|
|
905
|
+
return this.guard(() => this.configEvals.getConfigEval(key))
|
|
879
906
|
}
|
|
880
907
|
getConfigClosures(configPaths: readonly string[]): Map<string, string[]> {
|
|
881
|
-
return this.configEvals.getConfigClosures(configPaths)
|
|
908
|
+
return this.guard(() => this.configEvals.getConfigClosures(configPaths))
|
|
882
909
|
}
|
|
883
910
|
putConfigClosure(configPath: string, files: readonly string[]): void {
|
|
884
|
-
this.configEvals.putConfigClosure(configPath, files)
|
|
911
|
+
return this.guard(() => this.configEvals.putConfigClosure(configPath, files))
|
|
885
912
|
}
|
|
886
913
|
getConfigEvals(keys: readonly string[]): Map<string, string> {
|
|
887
|
-
return this.configEvals.getConfigEvals(keys)
|
|
914
|
+
return this.guard(() => this.configEvals.getConfigEvals(keys))
|
|
888
915
|
}
|
|
889
916
|
putConfigEval(key: string, json: string): void {
|
|
890
|
-
this.configEvals.putConfigEval(key, json)
|
|
917
|
+
return this.guard(() => this.configEvals.putConfigEval(key, json))
|
|
891
918
|
}
|
|
892
919
|
putConfigEvals(entries: ReadonlyArray<readonly [string, string]>): void {
|
|
893
|
-
this.configEvals.putConfigEvals(entries)
|
|
920
|
+
return this.guard(() => this.configEvals.putConfigEvals(entries))
|
|
894
921
|
}
|
|
895
922
|
putConfigClosures(entries: ReadonlyArray<readonly [string, readonly string[]]>): void {
|
|
896
|
-
this.configEvals.putConfigClosures(entries)
|
|
923
|
+
return this.guard(() => this.configEvals.putConfigClosures(entries))
|
|
897
924
|
}
|
|
898
925
|
// --- input file hashes: delegated to `FileHashStore` (see file-hashes.ts) ---
|
|
899
926
|
hashFile(filePath: string): Promise<string> {
|
|
900
|
-
return this.files.hashFile(filePath)
|
|
927
|
+
return this.guard(() => this.files.hashFile(filePath))
|
|
901
928
|
}
|
|
902
929
|
hashBytes(bytes: Uint8Array, nearPath: string): string {
|
|
903
930
|
return this.files.hashBytes(bytes, nearPath)
|
|
904
931
|
}
|
|
905
932
|
hashFiles(paths: readonly string[]): Promise<Map<string, string>> {
|
|
906
|
-
return this.files.hashFiles(paths)
|
|
933
|
+
return this.guard(() => this.files.hashFiles(paths))
|
|
907
934
|
}
|
|
908
935
|
/**
|
|
909
936
|
* `relPosix` against the run's workspace root, memoized: the same three
|
|
@@ -939,7 +966,7 @@ export class Cache implements CacheLayer {
|
|
|
939
966
|
// Local reads disabled (e.g. `--force` / `--cache=local:w`): report a
|
|
940
967
|
// miss so the task re-executes. The artifact + index are untouched.
|
|
941
968
|
if (!this.read) return null
|
|
942
|
-
return this.readEntry(hash)
|
|
969
|
+
return this.guard(() => this.readEntry(hash))
|
|
943
970
|
}
|
|
944
971
|
|
|
945
972
|
/**
|
|
@@ -952,7 +979,7 @@ export class Cache implements CacheLayer {
|
|
|
952
979
|
* every single run.
|
|
953
980
|
*/
|
|
954
981
|
getIngested(hash: string): Promise<CacheEntry | null> {
|
|
955
|
-
return this.readEntry(hash)
|
|
982
|
+
return this.guard(() => this.readEntry(hash))
|
|
956
983
|
}
|
|
957
984
|
|
|
958
985
|
private async readEntry(hash: string): Promise<CacheEntry | null> {
|
|
@@ -989,6 +1016,10 @@ export class Cache implements CacheLayer {
|
|
|
989
1016
|
* the lazy path.
|
|
990
1017
|
*/
|
|
991
1018
|
async getMany(hashes: readonly string[]): Promise<Map<string, CacheEntry>> {
|
|
1019
|
+
return this.guard(() => this.getManyEntries(hashes))
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
private async getManyEntries(hashes: readonly string[]): Promise<Map<string, CacheEntry>> {
|
|
992
1023
|
const out = new Map<string, CacheEntry>()
|
|
993
1024
|
if (!this.read || hashes.length === 0) return out
|
|
994
1025
|
const rows: EntryRow[] = []
|
|
@@ -1019,6 +1050,10 @@ export class Cache implements CacheLayer {
|
|
|
1019
1050
|
// Existence probe: SQL row + artifact-on-disk check, no byte reads
|
|
1020
1051
|
// and no accessed_at bump (the plan path must stay read-only).
|
|
1021
1052
|
async has(hash: string): Promise<'local' | 'remote' | null> {
|
|
1053
|
+
return this.guard(() => this.hasEntry(hash))
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
private async hasEntry(hash: string): Promise<'local' | 'remote' | null> {
|
|
1022
1057
|
if (!this.read) return null
|
|
1023
1058
|
const row = this.selectEntry.get(hash) as EntryRow | undefined
|
|
1024
1059
|
if (!row) return null
|
|
@@ -1038,7 +1073,7 @@ export class Cache implements CacheLayer {
|
|
|
1038
1073
|
// a microtask hop per call, and a 1,000-hit warm run pays the proofs
|
|
1039
1074
|
// 2,000 times (measured 2026-09-10: +3 ms on `classify + probe`).
|
|
1040
1075
|
loadOutputFilesBatch(hashes: readonly string[]): Map<string, OutputFileRow[]> {
|
|
1041
|
-
return this.outputs.loadOutputFilesBatch(hashes)
|
|
1076
|
+
return this.guard(() => this.outputs.loadOutputFilesBatch(hashes))
|
|
1042
1077
|
}
|
|
1043
1078
|
isOutputsCurrent(projectDir: string, expected: readonly OutputFileRow[]): Promise<boolean> {
|
|
1044
1079
|
return this.outputs.isOutputsCurrent(projectDir, expected)
|
|
@@ -1059,7 +1094,7 @@ export class Cache implements CacheLayer {
|
|
|
1059
1094
|
this.outputs.recordOutputStamps(hash, projectDir, workspaceRoot)
|
|
1060
1095
|
}
|
|
1061
1096
|
loadOutputDirsBatch(hashes: readonly string[]): Map<string, OutputDirRow[]> {
|
|
1062
|
-
return this.outputs.loadOutputDirsBatch(hashes)
|
|
1097
|
+
return this.guard(() => this.outputs.loadOutputDirsBatch(hashes))
|
|
1063
1098
|
}
|
|
1064
1099
|
outputDirsCurrent(projectDir: string, rows: readonly OutputDirRow[]): Promise<boolean> {
|
|
1065
1100
|
return this.outputs.outputDirsCurrent(projectDir, rows)
|
|
@@ -1258,12 +1293,14 @@ export class Cache implements CacheLayer {
|
|
|
1258
1293
|
const endPack = span('save: pack')
|
|
1259
1294
|
const compressed = await this.packArtifactToTemp(this.tempPath(args.hash), args)
|
|
1260
1295
|
endPack()
|
|
1261
|
-
await this.
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1296
|
+
await this.guard(() =>
|
|
1297
|
+
this.writeArtifactAndIndex(args.hash, compressed, {
|
|
1298
|
+
taskId: args.entry.taskId,
|
|
1299
|
+
command: args.entry.command,
|
|
1300
|
+
durationMs: args.entry.durationMs,
|
|
1301
|
+
...(args.inputComponents !== undefined ? { inputComponents: args.inputComponents } : {}),
|
|
1302
|
+
}),
|
|
1303
|
+
)
|
|
1267
1304
|
}
|
|
1268
1305
|
|
|
1269
1306
|
/**
|
|
@@ -1301,7 +1338,7 @@ export class Cache implements CacheLayer {
|
|
|
1301
1338
|
await unlink(tmpPath).catch(() => undefined)
|
|
1302
1339
|
throw err
|
|
1303
1340
|
}
|
|
1304
|
-
await this.writeArtifactAndIndex(hash, { tmpPath }, meta)
|
|
1341
|
+
await this.guard(() => this.writeArtifactAndIndex(hash, { tmpPath }, meta))
|
|
1305
1342
|
}
|
|
1306
1343
|
|
|
1307
1344
|
/** Archive name → absolute source path for every declared output. */
|
|
@@ -1612,15 +1649,19 @@ export class Cache implements CacheLayer {
|
|
|
1612
1649
|
|
|
1613
1650
|
// --- run history: delegated to `RunHistory` (see run-history.ts) ---
|
|
1614
1651
|
recordRun(run: RunRecord): void {
|
|
1615
|
-
this.history.recordRun(run)
|
|
1652
|
+
return this.guard(() => this.history.recordRun(run))
|
|
1616
1653
|
}
|
|
1617
1654
|
recordRuns(runs: readonly RunRecord[]): void {
|
|
1618
|
-
this.history.recordRuns(runs)
|
|
1655
|
+
return this.guard(() => this.history.recordRuns(runs))
|
|
1619
1656
|
}
|
|
1620
1657
|
recordRunBundle(bundle: { runs: readonly RunRecord[]; invocation: InvocationRecord }): void {
|
|
1621
|
-
this.history.recordRunBundle(bundle)
|
|
1658
|
+
return this.guard(() => this.history.recordRunBundle(bundle))
|
|
1622
1659
|
}
|
|
1623
1660
|
stats(opts: CacheStatsOptions = {}): CacheStats {
|
|
1661
|
+
return this.guard(() => this.statsOf(opts))
|
|
1662
|
+
}
|
|
1663
|
+
|
|
1664
|
+
private statsOf(opts: CacheStatsOptions): CacheStats {
|
|
1624
1665
|
this.flushAccessed()
|
|
1625
1666
|
this.outputs.flushOutputDirs()
|
|
1626
1667
|
const project = opts.project
|
|
@@ -1664,6 +1705,13 @@ export class Cache implements CacheLayer {
|
|
|
1664
1705
|
async evictIfDue(
|
|
1665
1706
|
policy: { maxAgeMs?: number; maxBytes?: number },
|
|
1666
1707
|
now: number = Date.now(),
|
|
1708
|
+
): Promise<PruneResult | null> {
|
|
1709
|
+
return this.guard(() => this.evictIfDueNow(policy, now))
|
|
1710
|
+
}
|
|
1711
|
+
|
|
1712
|
+
private async evictIfDueNow(
|
|
1713
|
+
policy: { maxAgeMs?: number; maxBytes?: number },
|
|
1714
|
+
now: number,
|
|
1667
1715
|
): Promise<PruneResult | null> {
|
|
1668
1716
|
if (this.writeBlocked !== null || this.inspecting) return null
|
|
1669
1717
|
// First: an entry this run restored still carries its old `accessed_at`
|
|
@@ -1698,6 +1746,10 @@ export class Cache implements CacheLayer {
|
|
|
1698
1746
|
}
|
|
1699
1747
|
|
|
1700
1748
|
async prune(options: PruneOptions): Promise<PruneResult> {
|
|
1749
|
+
return this.guard(() => this.pruneIndex(options))
|
|
1750
|
+
}
|
|
1751
|
+
|
|
1752
|
+
private async pruneIndex(options: PruneOptions): Promise<PruneResult> {
|
|
1701
1753
|
this.flushAccessed()
|
|
1702
1754
|
// Before any entry is deleted, so a kept entry's pending snapshot is on
|
|
1703
1755
|
// disk whatever the eviction does next. A pruned hash's snapshot cannot
|
|
@@ -449,13 +449,10 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
449
449
|
// The PROBE hash is computed WITHOUT capture — a warm all-cache-hit
|
|
450
450
|
// run allocates no component array and pushes nothing (the warm path
|
|
451
451
|
// does zero extra Tier-3 work). The cache-key components for the
|
|
452
|
-
// Tier-3 input fingerprint are captured only on a MISS,
|
|
453
|
-
// `
|
|
454
|
-
// — the HashCache memos
|
|
455
|
-
//
|
|
456
|
-
// pass a fold + array pushes, no re-stat / re-hash I/O. It runs on
|
|
457
|
-
// the miss path only, where the task is about to spawn a subprocess
|
|
458
|
-
// anyway, so its cost is in the noise.
|
|
452
|
+
// Tier-3 input fingerprint are captured only on a MISS, by
|
|
453
|
+
// `describeTaskInputs` just before the command spawns (with
|
|
454
|
+
// `captureInto` set) — the HashCache memos plus the gitFilesCache OID
|
|
455
|
+
// map make that pass a fold + array pushes, no re-stat / re-hash I/O.
|
|
459
456
|
//
|
|
460
457
|
// Local short-circuit reuse: when the classify phase already derived
|
|
461
458
|
// this task's stable key + probed it, reuse the up-front hash verbatim
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Levenshtein distance, capped: anything past
|
|
3
|
-
*
|
|
4
|
-
*
|
|
2
|
+
* Levenshtein distance, capped: anything at or past `cap` edits reads as
|
|
3
|
+
* `cap` (3 by default: past two edits a hint would guess rather than
|
|
4
|
+
* help). A caller that allows three edits needs a cap of four — under a
|
|
5
|
+
* cap of 3, a candidate nine edits away read as 3 and was offered.
|
|
5
6
|
*/
|
|
6
|
-
export function editDistance(a: string, b: string): number {
|
|
7
|
-
if (Math.abs(a.length - b.length)
|
|
7
|
+
export function editDistance(a: string, b: string, cap = 3): number {
|
|
8
|
+
if (Math.abs(a.length - b.length) >= cap) return cap
|
|
8
9
|
let prev = Array.from({ length: b.length + 1 }, (_, i) => i)
|
|
9
10
|
for (let i = 1; i <= a.length; i++) {
|
|
10
11
|
const cur = [i]
|
|
@@ -17,7 +18,7 @@ export function editDistance(a: string, b: string): number {
|
|
|
17
18
|
}
|
|
18
19
|
prev = cur
|
|
19
20
|
}
|
|
20
|
-
return Math.min(prev[b.length]!,
|
|
21
|
+
return Math.min(prev[b.length]!, cap)
|
|
21
22
|
}
|
|
22
23
|
|
|
23
24
|
/**
|
|
@@ -35,7 +36,10 @@ export function nearest(
|
|
|
35
36
|
let best: string | undefined
|
|
36
37
|
let bestD = maxEdits + 1
|
|
37
38
|
for (const c of candidates) {
|
|
38
|
-
|
|
39
|
+
// Capped one past the budget, so "past it" never reads as within it:
|
|
40
|
+
// `--continue-on-error` was nine edits from `--concurrency`, capped to
|
|
41
|
+
// the three `vx run`'s same-prefix hint allows, and offered.
|
|
42
|
+
const d = editDistance(name, c, maxEdits + 1)
|
|
39
43
|
if (d < bestD) {
|
|
40
44
|
bestD = d
|
|
41
45
|
best = c
|