@vzn/vx 0.0.650 → 0.0.652
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/README.md +7 -23
- package/package.json +5 -5
- package/schemas/last.json +15 -0
- package/schemas/show.json +14 -20
- package/src/bin.ts +21 -31
- package/src/cache/archive.ts +1 -1
- package/src/cache/cache.ts +221 -30
- package/src/cache/file-hashes.ts +3 -0
- package/src/cache/git-inputs.ts +188 -39
- package/src/cache/history-stream.ts +134 -0
- package/src/cache/history-writer.ts +76 -0
- package/src/cache/index.ts +3 -0
- package/src/cache/inputs.ts +77 -14
- package/src/cache/layer.ts +17 -6
- package/src/cache/layered-cache.ts +4 -0
- package/src/cache/linked-inputs.ts +3 -0
- package/src/cache/lockfile-memo.ts +5 -3
- package/src/cache/output-index.ts +5 -2
- package/src/cache/remote-lane.ts +2 -1
- package/src/cache/run-history.ts +56 -17
- package/src/cache/schema.ts +15 -2
- package/src/cli/cache.ts +28 -18
- package/src/cli/completions.ts +4 -2
- package/src/cli/docs.ts +10 -11
- package/src/cli/help.ts +14 -25
- package/src/cli/index.ts +28 -9
- package/src/cli/info.ts +16 -8
- package/src/cli/init.ts +23 -15
- package/src/cli/last.ts +246 -204
- package/src/cli/lock.ts +55 -5
- package/src/cli/plan-format.ts +100 -118
- package/src/cli/plugin-commands.ts +8 -2
- package/src/cli/plugin-templates.ts +36 -45
- package/src/cli/run.ts +46 -27
- package/src/cli/select.ts +43 -5
- package/src/cli/show.ts +45 -52
- package/src/cli/task-verb.ts +22 -0
- package/src/cli/upgrade.ts +3 -1
- package/src/cli/watch-fs.ts +6 -1
- package/src/cli/watch-judge.ts +3 -2
- package/src/cli/watch-set.ts +4 -4
- package/src/cli/watch.ts +94 -52
- package/src/cli/why.ts +123 -89
- package/src/cli/workspace-config.ts +2 -1
- package/src/config.ts +47 -30
- package/src/exec/executor.ts +42 -2
- package/src/exec/index.ts +1 -0
- package/src/exec/kill-tree.ts +58 -5
- package/src/exec/local-executor.ts +1 -1
- package/src/exec/pipes.ts +152 -0
- package/src/exec/runner.ts +199 -182
- package/src/exec/sandbox-binds.ts +1 -1
- package/src/exec/sandbox-paths.ts +3 -1
- package/src/exec/sandbox-runtime.ts +317 -23
- package/src/exec/spawn-off.ts +154 -0
- package/src/exec/task-proxy.ts +93 -0
- package/src/exec/warm-shell.ts +146 -0
- package/src/graph/critical-path.ts +40 -28
- package/src/graph/definition.ts +16 -3
- package/src/graph/priorities.ts +98 -44
- package/src/graph/scheduler.ts +17 -11
- package/src/graph/task-graph.ts +51 -24
- package/src/handoff.ts +10 -7
- package/src/history/failure-mode.ts +24 -1
- package/src/history/history.ts +5 -0
- package/src/history/index.ts +1 -0
- package/src/history/metrics.ts +14 -6
- package/src/index.ts +7 -3
- package/src/orchestrator/admission.ts +1 -1
- package/src/orchestrator/command-windows.ts +1 -1
- package/src/orchestrator/deferred-outputs.ts +1 -1
- package/src/orchestrator/doctor.ts +1 -1
- package/src/orchestrator/download-policy.ts +5 -7
- package/src/orchestrator/excluded-keys.ts +1 -1
- package/src/orchestrator/execute-task.ts +67 -20
- package/src/orchestrator/graph-memo.ts +50 -1
- package/src/orchestrator/graph-stage.ts +6 -0
- package/src/orchestrator/hit-restore.ts +5 -6
- package/src/orchestrator/index.ts +3 -0
- package/src/orchestrator/keyed-projects.ts +28 -0
- package/src/orchestrator/live-usage.ts +16 -11
- package/src/orchestrator/local-shortcircuit.ts +2 -2
- package/src/orchestrator/locked-digests.ts +63 -0
- package/src/orchestrator/lockfile-claim.ts +108 -29
- package/src/orchestrator/miss-save.ts +58 -11
- package/src/orchestrator/persistent-task.ts +5 -1
- package/src/orchestrator/placement.ts +1 -1
- package/src/orchestrator/plan.ts +7 -3
- package/src/orchestrator/plugin-host.ts +67 -2
- package/src/orchestrator/plugin.ts +53 -2
- package/src/orchestrator/prepare.ts +16 -1
- package/src/orchestrator/projects.ts +4 -1
- package/src/orchestrator/remote-prefetch.ts +1 -1
- package/src/orchestrator/run-context.ts +29 -10
- package/src/orchestrator/run-failures.ts +62 -13
- package/src/orchestrator/run-keys.ts +1 -1
- package/src/orchestrator/run-records.ts +88 -59
- package/src/orchestrator/run-report.ts +1 -1
- package/src/orchestrator/run.ts +145 -41
- package/src/orchestrator/sandbox-request.ts +52 -9
- package/src/orchestrator/shell-verdict.ts +9 -0
- package/src/orchestrator/signals.ts +8 -5
- package/src/orchestrator/stable-keys.ts +17 -18
- package/src/orchestrator/task-artifacts.ts +1 -1
- package/src/orchestrator/task-hash.ts +21 -7
- package/src/orchestrator/telemetry.ts +5 -1
- package/src/report/events.ts +6 -14
- package/src/report/forecast.ts +10 -32
- package/src/report/framed-output.ts +283 -175
- package/src/report/index.ts +8 -3
- package/src/report/logger.ts +74 -39
- package/src/report/plain-output.ts +3 -1
- package/src/report/status-line.ts +49 -357
- package/src/report/summary.ts +105 -136
- package/src/settings.ts +8 -8
- package/src/ui/ansi.ts +48 -0
- package/src/ui/components.ts +506 -0
- package/src/ui/index.ts +79 -0
- package/src/ui/kit.ts +37 -0
- package/src/ui/live-region.ts +363 -0
- package/src/{util/refusal-notice.ts → ui/refusal.ts} +7 -5
- package/src/ui/terminal.ts +51 -0
- package/src/ui/theme.ts +224 -0
- package/src/ui/version-line.ts +28 -0
- package/src/util/config-frame.ts +191 -14
- package/src/util/edit-distance.ts +6 -1
- package/src/util/index.ts +2 -15
- package/src/util/paths.ts +21 -3
- package/src/util/secret-mask.ts +39 -3
- package/src/util/settle.ts +33 -1
- package/src/util/tail.ts +4 -1
- package/src/util/timing.ts +4 -16
- package/src/workspace/affected.ts +89 -30
- package/src/workspace/bracket-literal.ts +2 -2
- package/src/workspace/config-schema.ts +70 -63
- package/src/workspace/dependencies-lock.ts +191 -0
- package/src/workspace/filter.ts +36 -1
- package/src/workspace/index.ts +9 -0
- package/src/workspace/lockfile.ts +159 -6
- package/src/workspace/migration.ts +16 -9
- package/src/workspace/workspace.ts +3 -0
- package/src/util/colors.ts +0 -133
- package/src/util/report.ts +0 -44
- package/src/util/ui.ts +0 -258
- /package/src/{report/path-links.ts → ui/links.ts} +0 -0
- /package/src/{report → ui}/terminal-signals.ts +0 -0
package/src/cache/cache.ts
CHANGED
|
@@ -51,7 +51,13 @@ import {
|
|
|
51
51
|
} from 'node:fs'
|
|
52
52
|
import { chmod, lstat, mkdir, readdir, rm, stat, unlink, utimes, writeFile } from 'node:fs/promises'
|
|
53
53
|
import path from 'node:path'
|
|
54
|
-
import {
|
|
54
|
+
import {
|
|
55
|
+
createArtifactTables,
|
|
56
|
+
createTables,
|
|
57
|
+
inHashes,
|
|
58
|
+
lazyStatement,
|
|
59
|
+
SQLITE_FCNTL_PERSIST_WAL,
|
|
60
|
+
} from './schema.js'
|
|
55
61
|
import {
|
|
56
62
|
UserError,
|
|
57
63
|
DOT_VX_GITIGNORE,
|
|
@@ -130,6 +136,9 @@ import { behindLink } from './linked-inputs.js'
|
|
|
130
136
|
* this, so the file time trails the last use by at most this much, and
|
|
131
137
|
* the policy reads the file time plus this as the last use.
|
|
132
138
|
*/
|
|
139
|
+
// About 1.3 ms of inserts per turn: a task's exit waits at most that.
|
|
140
|
+
const HELD_SLICE = 250
|
|
141
|
+
|
|
133
142
|
const ORPHAN_GRACE_MS = 60 * 60 * 1000
|
|
134
143
|
|
|
135
144
|
/**
|
|
@@ -272,7 +281,7 @@ function cantOpenIndex(dbFile: string, err: unknown): UserError | undefined {
|
|
|
272
281
|
// the memo simply stops answering wrongly, so an affected task's
|
|
273
282
|
// key moves from a WRONG value to the right one: it misses once,
|
|
274
283
|
// re-runs, re-caches. Self-healing, never a wrong hit.
|
|
275
|
-
// v25: runs.cached — whether the task declared
|
|
284
|
+
// v25: runs.cached — whether the task declared `cache: true`. An
|
|
276
285
|
// uncached task's row looked like any executed miss, so `vx why`
|
|
277
286
|
// headlined "cache key changed (inputs differ)" for a task that
|
|
278
287
|
// runs every time by design, and `vx last` could not mark it the
|
|
@@ -355,7 +364,10 @@ function cantOpenIndex(dbFile: string, err: unknown): UserError | undefined {
|
|
|
355
364
|
// v50: steps, warnings, failures and lockfile_memos — what a run said
|
|
356
365
|
// and a lockfile claimant parsed, files under the cache dir until
|
|
357
366
|
// now (if it can be in the DB, it is). The cache KEY is unchanged.
|
|
358
|
-
|
|
367
|
+
// v51: runs.queued_ms, output_bytes, blocks, flaky_passes and
|
|
368
|
+
// flaky_failures — the rest of what a results row shows, so `vx
|
|
369
|
+
// last --sql` reads it. The cache KEY is unchanged.
|
|
370
|
+
export const SCHEMA_VERSION = 'v51'
|
|
359
371
|
|
|
360
372
|
/**
|
|
361
373
|
* An entry's key components as its one `entry_inputs` row (v34): JSON
|
|
@@ -942,10 +954,21 @@ export class Cache implements CacheLayer {
|
|
|
942
954
|
private adoptStats = 0
|
|
943
955
|
/** The artifact directory's names, listed once past `ADOPT_STATS`; this process's writes kept in it. */
|
|
944
956
|
private artifactNames: Set<string> | undefined
|
|
957
|
+
private heldBlobSizes: Array<[string, number]> | undefined
|
|
958
|
+
private heldTimer: ReturnType<typeof setTimeout> | undefined
|
|
959
|
+
/** Saves' index writes waiting for this turn's one transaction (`indexInBatch`). */
|
|
960
|
+
private indexQueue: Array<{ write: () => void; settle: (err?: unknown) => void }> = []
|
|
961
|
+
private heldRest: () => Array<[string, number]> = () => []
|
|
945
962
|
private readonly insertEntryInput: ReturnType<Database['prepare']>
|
|
946
963
|
/** The per-file (mtime, size) → blob-OID memo behind `hashFile`. */
|
|
947
964
|
private readonly files: FileHashStore
|
|
948
965
|
private readonly outputs: OutputIndex
|
|
966
|
+
/**
|
|
967
|
+
* Each entry `save()` wrote: its files' bytes, summed as the rows went in,
|
|
968
|
+
* for the miss row's `outputs` (a read back was one statement a miss;
|
|
969
|
+
* principle 9). Taken once, by `outputBytes`.
|
|
970
|
+
*/
|
|
971
|
+
private readonly savedBytes = new Map<string, number>()
|
|
949
972
|
private readonly folds: IndexFoldMemo
|
|
950
973
|
private readonly history: RunHistory
|
|
951
974
|
/** What the remotes said they hold, across runs (remote-hits.ts). */
|
|
@@ -1235,12 +1258,13 @@ export class Cache implements CacheLayer {
|
|
|
1235
1258
|
}
|
|
1236
1259
|
}
|
|
1237
1260
|
}
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1261
|
+
// An index that held its entries itself (no store yet: a repository
|
|
1262
|
+
// before its first commit) hands them to the store below.
|
|
1263
|
+
const holds =
|
|
1264
|
+
storeDir !== undefined &&
|
|
1265
|
+
mode === 'open' &&
|
|
1266
|
+
this.writeBlocked === null &&
|
|
1267
|
+
readable(
|
|
1244
1268
|
() =>
|
|
1245
1269
|
this.db
|
|
1246
1270
|
.prepare(
|
|
@@ -1248,16 +1272,6 @@ export class Cache implements CacheLayer {
|
|
|
1248
1272
|
)
|
|
1249
1273
|
.get() != null,
|
|
1250
1274
|
)
|
|
1251
|
-
if (holds) {
|
|
1252
|
-
this.db
|
|
1253
|
-
.transaction(() => {
|
|
1254
|
-
for (const t of [...STORE_TABLES, ...ARTIFACT_TABLES]) {
|
|
1255
|
-
this.db.exec(`DROP TABLE IF EXISTS main.${t}`)
|
|
1256
|
-
}
|
|
1257
|
-
})
|
|
1258
|
-
.immediate()
|
|
1259
|
-
}
|
|
1260
|
-
}
|
|
1261
1275
|
this.storeDir = storeDir
|
|
1262
1276
|
this.artifactDir = storeDir ?? cacheDir
|
|
1263
1277
|
if (storeDir !== undefined) {
|
|
@@ -1271,6 +1285,7 @@ export class Cache implements CacheLayer {
|
|
|
1271
1285
|
if (mode === 'open' && this.writeBlocked === null && (foreign || storeDir !== recorded)) {
|
|
1272
1286
|
readable(() => this.matchArtifactLayout(storeDir === undefined ? 'main' : 'store'))
|
|
1273
1287
|
}
|
|
1288
|
+
if (holds) this.carryEntries(cacheDir, storeDir!)
|
|
1274
1289
|
if (mode === 'open' && this.writeBlocked === null && storeDir !== recorded) {
|
|
1275
1290
|
if (storeDir === undefined)
|
|
1276
1291
|
this.db.prepare("DELETE FROM schema_meta WHERE key = 'store_dir'").run()
|
|
@@ -1396,6 +1411,68 @@ export class Cache implements CacheLayer {
|
|
|
1396
1411
|
}
|
|
1397
1412
|
}
|
|
1398
1413
|
|
|
1414
|
+
/**
|
|
1415
|
+
* The entries an index held itself, moved into the store it now opens:
|
|
1416
|
+
* the same keys over the same bytes, so the first run after a
|
|
1417
|
+
* repository's first commit replays what the run before it saved. Then
|
|
1418
|
+
* dropped from the index, where an unqualified name would shadow the
|
|
1419
|
+
* store's. An artifact file that cannot be renamed there (another
|
|
1420
|
+
* device) is left for `prune`, and its entry is a miss.
|
|
1421
|
+
*/
|
|
1422
|
+
private carryEntries(cacheDir: string, storeDir: string): void {
|
|
1423
|
+
const has = (schema: 'main' | 'store', table: string): boolean =>
|
|
1424
|
+
this.db
|
|
1425
|
+
.prepare(`SELECT 1 FROM ${schema}.sqlite_master WHERE type = 'table' AND name = ?`)
|
|
1426
|
+
.get(table) != null
|
|
1427
|
+
const layout = (schema: 'main' | 'store'): string | undefined =>
|
|
1428
|
+
has(schema, 'artifacts_meta')
|
|
1429
|
+
? (
|
|
1430
|
+
this.db
|
|
1431
|
+
.prepare(`SELECT value FROM ${schema}.artifacts_meta WHERE key = 'layout'`)
|
|
1432
|
+
.get() as { value: string } | null
|
|
1433
|
+
)?.value
|
|
1434
|
+
: undefined
|
|
1435
|
+
if (storeDir !== cacheDir) {
|
|
1436
|
+
let names: string[] = []
|
|
1437
|
+
try {
|
|
1438
|
+
names = readdirSync(cacheDir)
|
|
1439
|
+
} catch {
|
|
1440
|
+
// No directory: nothing to carry.
|
|
1441
|
+
}
|
|
1442
|
+
for (const name of names) {
|
|
1443
|
+
if (!name.endsWith('.tar.zst') && !name.endsWith('.tree')) continue
|
|
1444
|
+
const to = path.join(storeDir, name)
|
|
1445
|
+
if (existsSync(to)) continue
|
|
1446
|
+
try {
|
|
1447
|
+
renameSync(path.join(cacheDir, name), to)
|
|
1448
|
+
} catch {
|
|
1449
|
+
// Another device: the artifact stays an orphan here.
|
|
1450
|
+
}
|
|
1451
|
+
}
|
|
1452
|
+
}
|
|
1453
|
+
this.db
|
|
1454
|
+
.transaction(() => {
|
|
1455
|
+
// `entries` first: the other tables' rows reference it.
|
|
1456
|
+
for (const t of STORE_TABLES.toReversed()) {
|
|
1457
|
+
if (t !== 'store_meta' && has('main', t) && has('store', t)) {
|
|
1458
|
+
this.db.exec(`INSERT OR IGNORE INTO store.${t} SELECT * FROM main.${t}`)
|
|
1459
|
+
}
|
|
1460
|
+
}
|
|
1461
|
+
if (
|
|
1462
|
+
has('main', 'artifacts') &&
|
|
1463
|
+
has('store', 'artifacts') &&
|
|
1464
|
+
layout('main') === ARTIFACT_LAYOUT &&
|
|
1465
|
+
layout('store') === ARTIFACT_LAYOUT
|
|
1466
|
+
) {
|
|
1467
|
+
this.db.exec('INSERT OR IGNORE INTO store.artifacts SELECT * FROM main.artifacts')
|
|
1468
|
+
}
|
|
1469
|
+
for (const t of [...STORE_TABLES, ...ARTIFACT_TABLES]) {
|
|
1470
|
+
this.db.exec(`DROP TABLE IF EXISTS main.${t}`)
|
|
1471
|
+
}
|
|
1472
|
+
})
|
|
1473
|
+
.immediate()
|
|
1474
|
+
}
|
|
1475
|
+
|
|
1399
1476
|
/**
|
|
1400
1477
|
* Attach the shared store as `store`, with the pragmas the index takes:
|
|
1401
1478
|
* WAL for both, so a workspace's run and another's share the store as
|
|
@@ -1580,7 +1657,32 @@ export class Cache implements CacheLayer {
|
|
|
1580
1657
|
}
|
|
1581
1658
|
/** `BlobSizeMemo`: remember blob sizes, honouring the local WRITE axis. */
|
|
1582
1659
|
rememberBlobSizes(sizes: ReadonlyMap<string, number>): void {
|
|
1583
|
-
this.
|
|
1660
|
+
if (this.heldBlobSizes !== undefined) for (const e of sizes) this.heldBlobSizes.push(e)
|
|
1661
|
+
else this.guard(() => this.files.rememberBlobSizes(sizes))
|
|
1662
|
+
}
|
|
1663
|
+
/**
|
|
1664
|
+
* Hold the blob sizes remembered from now on until `storeHeldBlobSizes`
|
|
1665
|
+
* or `close`: a run learns them before its first task, where storing
|
|
1666
|
+
* 40,030 cost a cold 9,603-task run 217 ms (2026-11-01).
|
|
1667
|
+
*/
|
|
1668
|
+
holdBlobSizes(): void {
|
|
1669
|
+
this.heldBlobSizes ??= []
|
|
1670
|
+
}
|
|
1671
|
+
/**
|
|
1672
|
+
* Store the held blob sizes `HELD_SLICE` per event-loop turn from the
|
|
1673
|
+
* next turn on, while the run's tasks run; `close` stores the rest.
|
|
1674
|
+
*/
|
|
1675
|
+
storeHeldBlobSizes(): void {
|
|
1676
|
+
const held = this.heldBlobSizes
|
|
1677
|
+
this.heldBlobSizes = undefined
|
|
1678
|
+
if (held === undefined || held.length === 0) return
|
|
1679
|
+
let at = 0
|
|
1680
|
+
const step = (): void => {
|
|
1681
|
+
this.guard(() => this.files.rememberBlobSizes(new Map(held.slice(at, (at += HELD_SLICE)))))
|
|
1682
|
+
this.heldTimer = at < held.length ? setTimeout(step, 0) : undefined
|
|
1683
|
+
}
|
|
1684
|
+
this.heldRest = () => held.slice(at)
|
|
1685
|
+
this.heldTimer = setTimeout(step, 0)
|
|
1584
1686
|
}
|
|
1585
1687
|
/** `BlobSizeMemo`: the paths an index with this digest distrusts, when known. */
|
|
1586
1688
|
blobVerdict(digest: string): string[] | undefined {
|
|
@@ -1755,7 +1857,7 @@ export class Cache implements CacheLayer {
|
|
|
1755
1857
|
}
|
|
1756
1858
|
|
|
1757
1859
|
/** An adoption's verdict: an artifact that fails the checks is a miss. */
|
|
1758
|
-
private async adopted(indexing: Promise<
|
|
1860
|
+
private async adopted(indexing: Promise<unknown>): Promise<boolean> {
|
|
1759
1861
|
try {
|
|
1760
1862
|
await indexing
|
|
1761
1863
|
return true
|
|
@@ -1953,6 +2055,11 @@ export class Cache implements CacheLayer {
|
|
|
1953
2055
|
outputsPath(hash: string): string {
|
|
1954
2056
|
return this.tarPath(hash)
|
|
1955
2057
|
}
|
|
2058
|
+
outputBytes(hash: string): number | undefined {
|
|
2059
|
+
const bytes = this.savedBytes.get(hash)
|
|
2060
|
+
this.savedBytes.delete(hash)
|
|
2061
|
+
return bytes
|
|
2062
|
+
}
|
|
1956
2063
|
artifactSize(hash: string): number | undefined {
|
|
1957
2064
|
return this.guard(
|
|
1958
2065
|
() =>
|
|
@@ -2046,7 +2153,15 @@ export class Cache implements CacheLayer {
|
|
|
2046
2153
|
* uploaded under a fresh sum.
|
|
2047
2154
|
*/
|
|
2048
2155
|
private treePlan(hash: string, tree: string, rows: readonly OutputFileRow[]): ArtifactPlan {
|
|
2049
|
-
|
|
2156
|
+
let plan: ArtifactPlan
|
|
2157
|
+
try {
|
|
2158
|
+
plan = planOfTree(tree, this.stdoutText(hash))
|
|
2159
|
+
} catch (err) {
|
|
2160
|
+
// A sidecar cut short at rest, as a file is: named as the damage it is.
|
|
2161
|
+
if (err instanceof SyntaxError)
|
|
2162
|
+
throw new CorruptArtifactError(hash, 'its sidecar does not parse')
|
|
2163
|
+
throw err
|
|
2164
|
+
}
|
|
2050
2165
|
const size = new Map(rows.map((r) => [treeName(r.path), r.size]))
|
|
2051
2166
|
for (const i of plan.inputs) {
|
|
2052
2167
|
const want = size.get(i.name)
|
|
@@ -2488,7 +2603,16 @@ export class Cache implements CacheLayer {
|
|
|
2488
2603
|
if (!this.write) return
|
|
2489
2604
|
// A cache that reads saves only on a miss. One that does not (`--force`)
|
|
2490
2605
|
// re-executes a key it may hold: that entry is kept as it is.
|
|
2491
|
-
|
|
2606
|
+
// The kept entry's bytes are this run's too (same key): the row's
|
|
2607
|
+
// `outputs` counts them as a hit would.
|
|
2608
|
+
if (!this.read && this.guard(() => this.storedHere(args.hash))) {
|
|
2609
|
+
const rows = this.loadOutputFilesBatch([args.hash]).get(args.hash) ?? []
|
|
2610
|
+
this.savedBytes.set(
|
|
2611
|
+
args.hash,
|
|
2612
|
+
rows.reduce((n, r) => n + r.size, 0),
|
|
2613
|
+
)
|
|
2614
|
+
return
|
|
2615
|
+
}
|
|
2492
2616
|
// The cache directory is the constructor's (`mkdirSync`, with a
|
|
2493
2617
|
// refusal recorded for `assertWritable`); re-creating it here and
|
|
2494
2618
|
// again in `writeArtifactAndIndex` cost every save two thread-pool
|
|
@@ -2505,7 +2629,7 @@ export class Cache implements CacheLayer {
|
|
|
2505
2629
|
throw err
|
|
2506
2630
|
})
|
|
2507
2631
|
endPack()
|
|
2508
|
-
await this.guard(() =>
|
|
2632
|
+
const bytes = await this.guard(() =>
|
|
2509
2633
|
this.writeArtifactAndIndex(
|
|
2510
2634
|
args.hash,
|
|
2511
2635
|
'tar' in packed ? packed.compressed : 'plan' in packed ? packed.saved : packed,
|
|
@@ -2518,6 +2642,7 @@ export class Cache implements CacheLayer {
|
|
|
2518
2642
|
'tar' in packed ? packed.tar : undefined,
|
|
2519
2643
|
),
|
|
2520
2644
|
)
|
|
2645
|
+
this.savedBytes.set(args.hash, bytes)
|
|
2521
2646
|
}
|
|
2522
2647
|
|
|
2523
2648
|
/**
|
|
@@ -2732,7 +2857,7 @@ export class Cache implements CacheLayer {
|
|
|
2732
2857
|
if (plan.size > this.artifactCeiling) {
|
|
2733
2858
|
throw new Error(
|
|
2734
2859
|
`${args.entry.taskId} is not cached: its outputs pack to ${formatBytes(plan.size)}, past the ` +
|
|
2735
|
-
`${formatBytes(this.artifactCeiling)} artifact ceiling a restore enforces — narrow
|
|
2860
|
+
`${formatBytes(this.artifactCeiling)} artifact ceiling a restore enforces — narrow outputs.files`,
|
|
2736
2861
|
)
|
|
2737
2862
|
}
|
|
2738
2863
|
return plan
|
|
@@ -2827,7 +2952,7 @@ export class Cache implements CacheLayer {
|
|
|
2827
2952
|
* scanned as is rather than decoded back out of the bytes just encoded.
|
|
2828
2953
|
*/
|
|
2829
2954
|
tar?: Uint8Array,
|
|
2830
|
-
): Promise<
|
|
2955
|
+
): Promise<number> {
|
|
2831
2956
|
// Validate BEFORE anything touches the final path. `ingest()` feeds
|
|
2832
2957
|
// us a temp of network bytes; a truncated/garbage body that went live first
|
|
2833
2958
|
// would leave a corrupt `<hash>.tar.zst` behind (with no SQL row,
|
|
@@ -3049,6 +3174,7 @@ export class Cache implements CacheLayer {
|
|
|
3049
3174
|
const upsertStdout = this.upsertStdout
|
|
3050
3175
|
const deleteStdout = this.deleteStdout
|
|
3051
3176
|
const outputs = this.outputs
|
|
3177
|
+
let outputBytes = 0
|
|
3052
3178
|
const insertEntryInput = this.insertEntryInput
|
|
3053
3179
|
const inputComponents = meta.inputComponents
|
|
3054
3180
|
// The artifact goes live INSIDE the write transaction, taken
|
|
@@ -3140,7 +3266,7 @@ export class Cache implements CacheLayer {
|
|
|
3140
3266
|
hash,
|
|
3141
3267
|
stdoutText.startsWith('\uFEFF') ? utf8.encode(stdoutText) : stdoutText,
|
|
3142
3268
|
)
|
|
3143
|
-
outputs.replaceFileRows(hash, outputFileRows)
|
|
3269
|
+
outputBytes = outputs.replaceFileRows(hash, outputFileRows)
|
|
3144
3270
|
// INSERT OR IGNORE: identical inputs derive this same hash, so a
|
|
3145
3271
|
// re-save's row is identical — keep the first, skip the rest.
|
|
3146
3272
|
if (inputComponents !== undefined && inputComponents.length > 0) {
|
|
@@ -3152,7 +3278,7 @@ export class Cache implements CacheLayer {
|
|
|
3152
3278
|
// Built inside the try: on a database a stopping run closed while a
|
|
3153
3279
|
// download streamed in, building it throws, and the temp or tree
|
|
3154
3280
|
// the body went to stayed behind.
|
|
3155
|
-
this.
|
|
3281
|
+
await this.indexInBatch(index)
|
|
3156
3282
|
} catch (err) {
|
|
3157
3283
|
endTx()
|
|
3158
3284
|
const left = renamed ?? tree ?? tmpPath
|
|
@@ -3162,6 +3288,57 @@ export class Cache implements CacheLayer {
|
|
|
3162
3288
|
}
|
|
3163
3289
|
endTx()
|
|
3164
3290
|
for (const a of aside) await rm(a, { recursive: true, force: true })
|
|
3291
|
+
return outputBytes
|
|
3292
|
+
}
|
|
3293
|
+
|
|
3294
|
+
/**
|
|
3295
|
+
* Run `write` in a write transaction shared with every other save's that
|
|
3296
|
+
* arrives in this turn: settles once it has committed, or rejects with
|
|
3297
|
+
* its own error (its savepoint rolled back) or the commit's. A cold run's
|
|
3298
|
+
* saves land a few per turn, and a transaction each spent half its index
|
|
3299
|
+
* time on BEGIN and COMMIT on the main thread, the run's busiest
|
|
3300
|
+
* (2026-11-03: 81 of 164 ms over 500 saves).
|
|
3301
|
+
*/
|
|
3302
|
+
private indexInBatch(write: () => void): Promise<void> {
|
|
3303
|
+
return new Promise((resolve, reject) => {
|
|
3304
|
+
this.indexQueue.push({
|
|
3305
|
+
write,
|
|
3306
|
+
settle: (err) => (err === undefined ? resolve() : reject(err)),
|
|
3307
|
+
})
|
|
3308
|
+
if (this.indexQueue.length === 1) setImmediate(() => this.flushIndex())
|
|
3309
|
+
})
|
|
3310
|
+
}
|
|
3311
|
+
|
|
3312
|
+
private flushIndex(): void {
|
|
3313
|
+
const batch = this.indexQueue
|
|
3314
|
+
if (batch.length === 0) return
|
|
3315
|
+
this.indexQueue = []
|
|
3316
|
+
const failed = new Map<number, unknown>()
|
|
3317
|
+
try {
|
|
3318
|
+
this.db
|
|
3319
|
+
.transaction(() => {
|
|
3320
|
+
for (let i = 0; i < batch.length; i++) {
|
|
3321
|
+
// An error SQLite answers by rolling the whole transaction
|
|
3322
|
+
// back (a full disk, a busy lock) leaves none to go on in: the
|
|
3323
|
+
// rest are not run, and fail with it.
|
|
3324
|
+
if (failed.size > 0 && !this.db.inTransaction) {
|
|
3325
|
+
failed.set(i, failed.values().next().value)
|
|
3326
|
+
continue
|
|
3327
|
+
}
|
|
3328
|
+
try {
|
|
3329
|
+
// Nested: a savepoint, so one write's failure undoes only its own rows.
|
|
3330
|
+
this.db.transaction(batch[i]!.write)()
|
|
3331
|
+
} catch (err) {
|
|
3332
|
+
failed.set(i, err)
|
|
3333
|
+
}
|
|
3334
|
+
}
|
|
3335
|
+
})
|
|
3336
|
+
.immediate()
|
|
3337
|
+
} catch (err) {
|
|
3338
|
+
for (let i = 0; i < batch.length; i++) batch[i]!.settle(failed.get(i) ?? err)
|
|
3339
|
+
return
|
|
3340
|
+
}
|
|
3341
|
+
for (let i = 0; i < batch.length; i++) batch[i]!.settle(failed.get(i))
|
|
3165
3342
|
}
|
|
3166
3343
|
|
|
3167
3344
|
private tempTreePath(hash: string): string {
|
|
@@ -3515,6 +3692,12 @@ export class Cache implements CacheLayer {
|
|
|
3515
3692
|
|
|
3516
3693
|
close(): void {
|
|
3517
3694
|
try {
|
|
3695
|
+
this.flushIndex()
|
|
3696
|
+
clearTimeout(this.heldTimer)
|
|
3697
|
+
const held = this.heldBlobSizes ?? (this.heldTimer !== undefined ? this.heldRest() : [])
|
|
3698
|
+
this.heldBlobSizes = undefined
|
|
3699
|
+
this.heldTimer = undefined
|
|
3700
|
+
if (held.length > 0) this.files.rememberBlobSizes(new Map(held))
|
|
3518
3701
|
this.flushAccessed()
|
|
3519
3702
|
this.outputs.flushOutputDirs()
|
|
3520
3703
|
this.folds.flush()
|
|
@@ -3550,8 +3733,16 @@ export class Cache implements CacheLayer {
|
|
|
3550
3733
|
}
|
|
3551
3734
|
}
|
|
3552
3735
|
|
|
3553
|
-
/**
|
|
3554
|
-
|
|
3736
|
+
/**
|
|
3737
|
+
* The files a second connection writes a run's hits through
|
|
3738
|
+
* (history-stream.ts); undefined when the store keeps no history on disk.
|
|
3739
|
+
* A function, as `indexDb` is: no plugin writes the history.
|
|
3740
|
+
*/
|
|
3741
|
+
export function historyFiles(cache: Cache): { index: string; store?: string } | undefined {
|
|
3742
|
+
if (cache['empty'] || cache['writeBlocked'] !== null || cache['inspecting']) return undefined
|
|
3743
|
+
if (cache.storeDir === undefined) return { index: cache['dbFile'] }
|
|
3744
|
+
return { index: cache['dbFile'], store: path.join(cache.storeDir, 'store.db') }
|
|
3745
|
+
}
|
|
3555
3746
|
|
|
3556
3747
|
/**
|
|
3557
3748
|
* The cache's `bun:sqlite` handle, for core's own readers (`vx last`, `vx
|
package/src/cache/file-hashes.ts
CHANGED
|
@@ -72,6 +72,9 @@ export class FileHashStore {
|
|
|
72
72
|
/** Each of `oids` whose size a `rememberBlobSizes` stored: one `IN` query per 900. */
|
|
73
73
|
knownBlobSizes(oids: readonly string[]): Map<string, number> {
|
|
74
74
|
const out = new Map<string, number>()
|
|
75
|
+
// An empty memo (a cold cache) answers nothing: 40,030 lookups there
|
|
76
|
+
// cost a 9,603-task run 57 ms before its first task (2026-11-01).
|
|
77
|
+
if (this.db.query('SELECT 1 FROM blob_sizes LIMIT 1').get() === null) return out
|
|
75
78
|
for (let i = 0; i < oids.length; i += 900) {
|
|
76
79
|
const chunk = oids.slice(i, i + 900)
|
|
77
80
|
const rows = this.db
|