@vzn/vx 0.0.646 → 0.0.649
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 +29 -22
- package/launcher.cjs +9 -2
- package/launcher.sh +2 -2
- package/package.json +5 -5
- package/schemas/cache.json +5 -5
- package/schemas/error.json +1 -1
- package/schemas/info.json +16 -2
- package/schemas/last.json +112 -5
- package/schemas/plan.json +28 -2
- package/schemas/show.json +10 -1
- package/skills/vx/SKILL.md +18 -2
- package/src/bin.ts +53 -12
- package/src/cache/archive.ts +324 -23
- package/src/cache/cache.ts +268 -53
- package/src/cache/git-inputs.ts +219 -87
- package/src/cache/index.ts +3 -2
- package/src/cache/inputs.ts +460 -78
- package/src/cache/key-fold.ts +15 -6
- package/src/cache/layer.ts +49 -9
- package/src/cache/layered-cache.ts +123 -58
- package/src/cache/linked-inputs.ts +1 -1
- package/src/cache/output-index.ts +19 -3
- package/src/cache/plugin-cache.ts +49 -23
- package/src/cache/remote-hits.ts +72 -0
- package/src/cache/remote-lane.ts +90 -14
- package/src/cache/run-history.ts +70 -23
- package/src/cache/run-store.ts +11 -10
- package/src/cache/schema.ts +25 -6
- package/src/cache/tar-stream.ts +5 -0
- package/src/cache/umask.ts +1 -2
- package/src/cli/cache.ts +58 -27
- package/src/cli/completions.ts +12 -5
- package/src/cli/docs.ts +42 -16
- package/src/cli/help.ts +58 -19
- package/src/cli/index.ts +79 -33
- package/src/cli/info.ts +12 -6
- package/src/cli/init.ts +186 -31
- package/src/cli/last.ts +439 -45
- package/src/cli/lock.ts +197 -52
- package/src/cli/plan-format.ts +91 -49
- package/src/cli/plugin-commands.ts +70 -14
- package/src/cli/plugin-templates.ts +13 -13
- package/src/cli/registry.ts +209 -0
- package/src/cli/run.ts +109 -34
- package/src/cli/select.ts +143 -46
- package/src/cli/show.ts +59 -16
- package/src/cli/task-verb.ts +16 -6
- package/src/cli/upgrade.ts +151 -49
- package/src/cli/watch-cycle.ts +10 -5
- package/src/cli/watch-filter.ts +71 -3
- package/src/cli/watch-fs.ts +22 -6
- package/src/cli/watch-judge.ts +48 -6
- package/src/cli/watch.ts +163 -42
- package/src/cli/why.ts +211 -73
- package/src/cli/workspace-config.ts +54 -15
- package/src/config.ts +13 -1
- package/src/exec/env.ts +7 -2
- package/src/exec/executor.ts +154 -10
- package/src/exec/index.ts +6 -2
- package/src/exec/kill-tree.ts +15 -0
- package/src/exec/local-executor.ts +1 -1
- package/src/exec/proc-sample.ts +3 -1
- package/src/exec/runner.ts +253 -54
- package/src/exec/sandbox-binds.ts +7 -3
- package/src/exec/sandbox-paths.ts +1 -1
- package/src/exec/sandbox-runtime.ts +211 -24
- package/src/exec/sandbox-violations.ts +52 -20
- package/src/graph/critical-path.ts +79 -0
- package/src/graph/default-build.ts +19 -0
- package/src/graph/definition.ts +38 -0
- package/src/graph/folds.ts +38 -5
- package/src/graph/index.ts +4 -1
- package/src/graph/priorities.ts +74 -8
- package/src/graph/scheduler.ts +270 -60
- package/src/graph/task-graph.ts +228 -35
- package/src/handoff.ts +97 -9
- package/src/history/failure-mode.ts +6 -3
- package/src/history/history.ts +167 -61
- package/src/history/index.ts +2 -0
- package/src/history/metrics.ts +90 -30
- package/src/index.ts +30 -13
- package/src/orchestrator/admission.ts +10 -6
- package/src/orchestrator/affected-tasks.ts +11 -6
- package/src/orchestrator/deferred-outputs.ts +2 -0
- package/src/orchestrator/doctor.ts +134 -42
- package/src/orchestrator/execute-args.ts +4 -1
- package/src/orchestrator/execute-task.ts +130 -38
- package/src/orchestrator/fingerprint-watch.ts +1 -1
- package/src/orchestrator/graph-memo.ts +140 -0
- package/src/orchestrator/graph-stage.ts +18 -1
- package/src/orchestrator/held-servers.ts +1 -1
- package/src/orchestrator/hit-restore.ts +217 -119
- package/src/orchestrator/index.ts +4 -3
- package/src/orchestrator/local-floor.ts +119 -25
- package/src/orchestrator/local-shortcircuit.ts +28 -4
- package/src/orchestrator/lockfile-claim.ts +96 -16
- package/src/orchestrator/miss-save.ts +42 -8
- package/src/orchestrator/options.ts +8 -6
- package/src/orchestrator/output-host.ts +43 -14
- package/src/orchestrator/output-log.ts +90 -10
- package/src/orchestrator/persistent-task.ts +84 -29
- package/src/orchestrator/persistent.ts +16 -4
- package/src/orchestrator/placement.ts +185 -17
- package/src/orchestrator/plan.ts +64 -7
- package/src/orchestrator/plugin-host.ts +214 -42
- package/src/orchestrator/plugin.ts +113 -37
- package/src/orchestrator/prepare.ts +168 -30
- package/src/orchestrator/projects.ts +65 -40
- package/src/orchestrator/remote-prefetch.ts +119 -25
- package/src/orchestrator/run-artifacts.ts +95 -32
- package/src/orchestrator/run-context.ts +39 -41
- package/src/orchestrator/run-failures.ts +65 -23
- package/src/orchestrator/run-id.ts +6 -3
- package/src/orchestrator/run-keys.ts +149 -13
- package/src/orchestrator/run-live.ts +294 -0
- package/src/orchestrator/run-lock.ts +13 -7
- package/src/orchestrator/run-records.ts +35 -29
- package/src/orchestrator/run-report.ts +3 -3
- package/src/orchestrator/run-steps.ts +58 -22
- package/src/orchestrator/run-stop.ts +22 -5
- package/src/orchestrator/run.ts +331 -88
- package/src/orchestrator/sandbox-fix.ts +7 -5
- package/src/orchestrator/sandbox-request.ts +70 -20
- package/src/orchestrator/shell-verdict.ts +5 -5
- package/src/orchestrator/signals.ts +26 -1
- package/src/orchestrator/stable-keys.ts +92 -8
- package/src/orchestrator/steps.ts +2 -2
- package/src/orchestrator/task-artifacts.ts +90 -26
- package/src/orchestrator/task-hash.ts +78 -13
- package/src/orchestrator/task-log-buffer.ts +72 -36
- package/src/orchestrator/task-process.ts +7 -1
- package/src/orchestrator/telemetry-host.ts +22 -2
- package/src/orchestrator/telemetry.ts +53 -21
- package/src/orchestrator/upstream.ts +15 -0
- package/src/report/events.ts +32 -13
- package/src/report/forecast.ts +31 -9
- package/src/report/framed-output.ts +141 -50
- package/src/report/index.ts +1 -0
- package/src/report/logger.ts +170 -179
- package/src/report/path-links.ts +12 -4
- package/src/report/plain-output.ts +13 -5
- package/src/report/status-line.ts +81 -53
- package/src/report/summary.ts +114 -32
- package/src/report/terminal-signals.ts +13 -2
- package/src/settings.ts +7 -6
- package/src/util/bun-version.ts +4 -1
- package/src/util/colors.ts +4 -2
- package/src/util/config-frame.ts +41 -4
- package/src/{graph → util}/dependency-spec.ts +22 -13
- package/src/util/edit-distance.ts +13 -1
- package/src/util/env-flag.ts +9 -0
- package/src/util/errors.ts +59 -3
- package/src/util/essential-env.ts +57 -0
- package/src/util/git-ignored.ts +1 -1
- package/src/util/hash.ts +35 -0
- package/src/util/hide-env.ts +45 -5
- package/src/util/index.ts +21 -5
- package/src/util/num.ts +8 -0
- package/src/util/paths.ts +91 -21
- package/src/util/report.ts +6 -1
- package/src/util/secret-mask.ts +62 -1
- package/src/util/secrets.ts +7 -2
- package/src/util/size.ts +11 -3
- package/src/util/tail.ts +6 -1
- package/src/util/timing.ts +13 -1
- package/src/util/ui.ts +61 -3
- package/src/util/which.ts +1 -1
- package/src/util/write-file.ts +67 -0
- package/src/workspace/affected.ts +710 -107
- package/src/workspace/bracket-literal.ts +84 -0
- package/src/workspace/canonical-dir.ts +37 -0
- package/src/workspace/config-bytecode.ts +17 -11
- package/src/workspace/config-eval.ts +29 -8
- package/src/workspace/config-imports.ts +199 -58
- package/src/workspace/config-schema.ts +242 -39
- package/src/workspace/filter.ts +37 -8
- package/src/workspace/index.ts +11 -1
- package/src/workspace/json-data.ts +3 -4
- package/src/workspace/lockfile.ts +57 -8
- package/src/workspace/migration.ts +69 -18
- package/src/workspace/package-graph.ts +5 -4
- package/src/workspace/project-loader.ts +226 -37
- package/src/workspace/repo-id.ts +1 -1
- package/src/workspace/workspace.ts +176 -33
package/src/cache/cache.ts
CHANGED
|
@@ -55,6 +55,7 @@ import { createArtifactTables, createTables, inHashes, lazyStatement } from './s
|
|
|
55
55
|
import {
|
|
56
56
|
UserError,
|
|
57
57
|
DOT_VX_GITIGNORE,
|
|
58
|
+
IGNORE_ALL,
|
|
58
59
|
formatBytes,
|
|
59
60
|
isDiskFull,
|
|
60
61
|
isFsRefusal,
|
|
@@ -113,10 +114,11 @@ import {
|
|
|
113
114
|
zstdEncoder,
|
|
114
115
|
} from './zstd.js'
|
|
115
116
|
import { FileHashStore } from './file-hashes.js'
|
|
116
|
-
import { LINK_MODE, OutputIndex } from './output-index.js'
|
|
117
|
+
import { DIR_MODE, LINK_MODE, OutputIndex } from './output-index.js'
|
|
117
118
|
import { RunHistory } from './run-history.js'
|
|
118
119
|
import { CACHE_VERSION, foldKey } from './key-fold.js'
|
|
119
120
|
import { FoldMemo } from './fold-memo.js'
|
|
121
|
+
import { RemoteHits } from './remote-hits.js'
|
|
120
122
|
import { behindLink } from './linked-inputs.js'
|
|
121
123
|
|
|
122
124
|
/**
|
|
@@ -143,7 +145,7 @@ const ADOPT_STATS = 64
|
|
|
143
145
|
* close, ~30 ms of it. So `accessed_at` trails the last use by at most
|
|
144
146
|
* this much, and the prune policy reads it plus this as the last use:
|
|
145
147
|
* an entry used within the limit is never pruned, and one may stay up to
|
|
146
|
-
* this long past it (
|
|
148
|
+
* this long past it (2026-10-08).
|
|
147
149
|
*/
|
|
148
150
|
const ACCESS_REFRESH_MS = 60 * 60 * 1000
|
|
149
151
|
|
|
@@ -162,11 +164,13 @@ const VX_CACHE_NAMES = new Set([
|
|
|
162
164
|
'cache.db-wal',
|
|
163
165
|
'cache.db-shm',
|
|
164
166
|
'cache.db-journal',
|
|
165
|
-
|
|
167
|
+
// run-failures.ts, run-steps.ts, orchestrator/lockfile-claim.ts. Run
|
|
168
|
+
// history lives in cache.db and the config bundle always in
|
|
169
|
+
// `.vx/cache/compiled`, so a `runs` or `compiled` here is the user's.
|
|
166
170
|
'failures',
|
|
171
|
+
'steps',
|
|
172
|
+
'warnings',
|
|
167
173
|
'lockfile-claims',
|
|
168
|
-
// The workspace config's bytecode bundle (workspace/config-bytecode.ts).
|
|
169
|
-
'compiled',
|
|
170
174
|
])
|
|
171
175
|
|
|
172
176
|
/**
|
|
@@ -219,20 +223,28 @@ function unreadableIndexError(dbFile: string, err: unknown): UserError {
|
|
|
219
223
|
* SQLite reports a descriptor it could not get as `SQLITE_CANTOPEN`, never
|
|
220
224
|
* `EMFILE`, so an open under a low `ulimit -n` reached the user as a bare
|
|
221
225
|
* `vx: SQLiteError: unable to open database file` (D's lead, A-56). One
|
|
222
|
-
* open of a file that exists says which it was.
|
|
226
|
+
* open of a file that exists says which it was. Any other `CANTOPEN` is
|
|
227
|
+
* the path itself.
|
|
223
228
|
*/
|
|
224
|
-
function
|
|
229
|
+
function cantOpenIndex(dbFile: string, err: unknown): UserError | undefined {
|
|
225
230
|
const code = (err as { code?: unknown } | null)?.code
|
|
226
231
|
if (typeof code !== 'string' || !code.startsWith('SQLITE_CANTOPEN')) return undefined
|
|
227
232
|
try {
|
|
228
233
|
closeSync(openSync(process.execPath, 'r'))
|
|
229
|
-
return undefined
|
|
230
234
|
} catch (probe) {
|
|
231
235
|
if (!isOutOfFds(probe)) return undefined
|
|
232
236
|
return new UserError(
|
|
233
237
|
`the cache index ${dbFile} could not be opened (${(err as Error).message}) — ${OUT_OF_FDS_HINT}`,
|
|
238
|
+
'VX_E_FDS',
|
|
234
239
|
)
|
|
235
240
|
}
|
|
241
|
+
// Any other: a directory, or a file this user may not open, where the
|
|
242
|
+
// index goes. It reached every verb as a stack (2026-10-28).
|
|
243
|
+
return new UserError(
|
|
244
|
+
`the cache index ${dbFile} could not be opened (${(err as Error).message}) — ` +
|
|
245
|
+
'it is not a file this user can open; move it away and re-run (vx rebuilds the index)',
|
|
246
|
+
'VX_E_FS',
|
|
247
|
+
)
|
|
236
248
|
}
|
|
237
249
|
|
|
238
250
|
// SCHEMA history (drop+recreate on mismatch; pre-alpha, no migrations):
|
|
@@ -335,7 +347,17 @@ function outOfFdsAtOpen(dbFile: string, err: unknown): UserError | undefined {
|
|
|
335
347
|
// The cache KEY is unchanged.
|
|
336
348
|
// v46: runs.keyless_by — the task with no inputs a key folded, so
|
|
337
349
|
// `vx why` names it. The cache KEY is unchanged.
|
|
338
|
-
|
|
350
|
+
// v47: the store's remote_hits — what a remote's hasMany said it holds,
|
|
351
|
+
// hits only, so another process trusts it. The cache KEY is
|
|
352
|
+
// unchanged.
|
|
353
|
+
// v48: runs.def and task_durations.def — the task's definition
|
|
354
|
+
// (taskDefinition), so a task whose command changed under the same
|
|
355
|
+
// name reads none of the old one's timings. The cache KEY is
|
|
356
|
+
// unchanged.
|
|
357
|
+
// v49: runs.cpu_share — the share of a core each runnable thread got
|
|
358
|
+
// where the task ran, so a crowded run's CPU reads as need. The
|
|
359
|
+
// cache KEY is unchanged.
|
|
360
|
+
export const SCHEMA_VERSION = 'v49'
|
|
339
361
|
|
|
340
362
|
/**
|
|
341
363
|
* An entry's key components as its one `entry_inputs` row (v34): JSON
|
|
@@ -366,6 +388,7 @@ const STORE_TABLES = [
|
|
|
366
388
|
'task_outcomes',
|
|
367
389
|
'blob_sizes',
|
|
368
390
|
'plugin_blobs',
|
|
391
|
+
'remote_hits',
|
|
369
392
|
'store_meta',
|
|
370
393
|
'entries',
|
|
371
394
|
]
|
|
@@ -689,7 +712,7 @@ function ensurePrivateDir(dir: string): string | null {
|
|
|
689
712
|
if ((st.mode & 0o022) !== 0) return `${dir} is writable by other users`
|
|
690
713
|
// The user's own directory open to others (a `~/.vx` made at the
|
|
691
714
|
// default umask, 0755) is vx's to close: refusing it left the store
|
|
692
|
-
// unused on a stock macOS home (
|
|
715
|
+
// unused on a stock macOS home (2026-10-06: "you should own it").
|
|
693
716
|
if ((st.mode & 0o077) !== 0) {
|
|
694
717
|
try {
|
|
695
718
|
chmodSync(dir, 0o700)
|
|
@@ -708,12 +731,11 @@ function ensurePrivateDir(dir: string): string | null {
|
|
|
708
731
|
// ignored directory is skipped by the walk entirely. Only written when
|
|
709
732
|
// absent, so a user's own file wins.
|
|
710
733
|
const utf8 = new TextEncoder()
|
|
711
|
-
const IGNORE_ALL = '*\n'
|
|
712
734
|
|
|
713
735
|
/**
|
|
714
736
|
* The ignore file that keeps `cacheDir` out of git, and its text. The
|
|
715
737
|
* default `.vx/cache` is named by `.vx/.gitignore` (`/cache`), committed
|
|
716
|
-
* beside the workspace config and the lock (
|
|
738
|
+
* beside the workspace config and the lock (2026-10-14); any other
|
|
717
739
|
* cache directory holds a `*` one of its own. Written only when missing:
|
|
718
740
|
* an existing file is the user's.
|
|
719
741
|
*/
|
|
@@ -794,15 +816,40 @@ function makeCacheDir(cacheDir: string): void {
|
|
|
794
816
|
try {
|
|
795
817
|
mkdirSync(cacheDir, { recursive: true })
|
|
796
818
|
} catch (err) {
|
|
819
|
+
const where = 'set `cache.dir` in .vx/workspace.ts, or pass --cache-dir <path>'
|
|
820
|
+
const code = (err as NodeJS.ErrnoException).code
|
|
821
|
+
// A file at the path or at a parent of it: "make the workspace
|
|
822
|
+
// writable" sent the user to chmod a tree that was writable.
|
|
823
|
+
if (code === 'EEXIST' || code === 'ENOTDIR') {
|
|
824
|
+
const file = inTheWay(cacheDir)
|
|
825
|
+
throw new UserError(
|
|
826
|
+
`cannot create cache directory ${cacheDir}: ${file} is a file, not a directory — move it away, ${where}`,
|
|
827
|
+
'VX_E_FS',
|
|
828
|
+
)
|
|
829
|
+
}
|
|
797
830
|
// A read-only checkout with no cache yet: every verb opens the cache,
|
|
798
831
|
// so this is the first thing any of them says there.
|
|
832
|
+
const fix = isDiskFull(err) ? 'free space on that disk' : 'make the workspace writable'
|
|
799
833
|
throw new UserError(
|
|
800
|
-
`cannot create cache directory ${cacheDir} (${errorText(err)}) — vx keeps its cache there;
|
|
801
|
-
|
|
834
|
+
`cannot create cache directory ${cacheDir} (${errorText(err)}) — vx keeps its cache there; ${fix}, ${where}`,
|
|
835
|
+
'VX_E_FS',
|
|
802
836
|
)
|
|
803
837
|
}
|
|
804
838
|
}
|
|
805
839
|
|
|
840
|
+
/** The nearest of `dir` and its parents that exists, as itself (a link is not followed). */
|
|
841
|
+
function inTheWay(dir: string): string {
|
|
842
|
+
for (let at = dir; ; at = path.dirname(at)) {
|
|
843
|
+
try {
|
|
844
|
+
lstatSync(at)
|
|
845
|
+
return at
|
|
846
|
+
} catch {
|
|
847
|
+
// quiet: a missing level is the walk's question, not a fault
|
|
848
|
+
if (path.dirname(at) === at) return dir
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
|
|
806
853
|
function errorText(err: unknown): string {
|
|
807
854
|
return err instanceof Error ? err.message : String(err)
|
|
808
855
|
}
|
|
@@ -903,6 +950,8 @@ export class Cache implements CacheLayer {
|
|
|
903
950
|
private readonly outputs: OutputIndex
|
|
904
951
|
private readonly folds: FoldMemo
|
|
905
952
|
private readonly history: RunHistory
|
|
953
|
+
/** What the remotes said they hold, across runs (remote-hits.ts). */
|
|
954
|
+
readonly remoteHits: RemoteHits
|
|
906
955
|
|
|
907
956
|
/**
|
|
908
957
|
* Local-layer read/write gates for the task ARTIFACT path only.
|
|
@@ -930,7 +979,7 @@ export class Cache implements CacheLayer {
|
|
|
930
979
|
* Set when THIS open found an index written by another `SCHEMA_VERSION`
|
|
931
980
|
* and dropped every table. The next open sees the current version and
|
|
932
981
|
* reports null. vx prints nothing for it: the cache is vx's to keep
|
|
933
|
-
* (
|
|
982
|
+
* (2026-10-06).
|
|
934
983
|
*/
|
|
935
984
|
readonly schemaReset: SchemaReset | null = null
|
|
936
985
|
|
|
@@ -1016,7 +1065,7 @@ export class Cache implements CacheLayer {
|
|
|
1016
1065
|
try {
|
|
1017
1066
|
this.db = new Database(memory ? ':memory:' : dbFile, { create: true })
|
|
1018
1067
|
} catch (err) {
|
|
1019
|
-
throw
|
|
1068
|
+
throw cantOpenIndex(dbFile, err) ?? err
|
|
1020
1069
|
}
|
|
1021
1070
|
// busy_timeout makes concurrent writers wait for the lock instead of
|
|
1022
1071
|
// failing immediately with SQLITE_BUSY. Two parallel `vx run`
|
|
@@ -1035,7 +1084,7 @@ export class Cache implements CacheLayer {
|
|
|
1035
1084
|
return read()
|
|
1036
1085
|
} catch (err) {
|
|
1037
1086
|
if (!unreadableIndex(err)) {
|
|
1038
|
-
const fds =
|
|
1087
|
+
const fds = cantOpenIndex(dbFile, err)
|
|
1039
1088
|
if (fds === undefined) throw err
|
|
1040
1089
|
try {
|
|
1041
1090
|
closeDb(this.db)
|
|
@@ -1098,8 +1147,8 @@ export class Cache implements CacheLayer {
|
|
|
1098
1147
|
| undefined
|
|
1099
1148
|
)?.value
|
|
1100
1149
|
// Any other schema is reset by an opener that may write it: the index is
|
|
1101
|
-
// an inventory the artifacts rebuild, whichever vx wrote it
|
|
1102
|
-
// 2026-10-06; a newer one used to be refused, item 896). A reading verb
|
|
1150
|
+
// an inventory the artifacts rebuild, whichever vx wrote it
|
|
1151
|
+
// (2026-10-06; a newer one used to be refused, item 896). A reading verb
|
|
1103
1152
|
// leaves it as it was.
|
|
1104
1153
|
const refuseUnreadable = (found: string): void => {
|
|
1105
1154
|
if (mode !== 'open') {
|
|
@@ -1123,7 +1172,7 @@ export class Cache implements CacheLayer {
|
|
|
1123
1172
|
// keeps it so).
|
|
1124
1173
|
if (found !== undefined) {
|
|
1125
1174
|
this.db.exec(
|
|
1126
|
-
'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.task_durations; DROP TABLE IF EXISTS main.plugin_blobs; DROP TABLE IF EXISTS main.task_outcomes; DROP TABLE IF EXISTS main.store_meta; DROP TABLE IF EXISTS main.fold_memo;',
|
|
1175
|
+
'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.task_durations; DROP TABLE IF EXISTS main.plugin_blobs; DROP TABLE IF EXISTS main.task_outcomes; DROP TABLE IF EXISTS main.store_meta; DROP TABLE IF EXISTS main.fold_memo; DROP TABLE IF EXISTS main.remote_hits;',
|
|
1127
1176
|
)
|
|
1128
1177
|
}
|
|
1129
1178
|
// The tables land under the same write lock as the stamp: made
|
|
@@ -1166,8 +1215,10 @@ export class Cache implements CacheLayer {
|
|
|
1166
1215
|
} | null
|
|
1167
1216
|
)?.value,
|
|
1168
1217
|
)
|
|
1218
|
+
// A dry prune names what the real one takes, so it reads the store a
|
|
1219
|
+
// run opens (today's), not the one the index last recorded.
|
|
1169
1220
|
let storeDir =
|
|
1170
|
-
mode
|
|
1221
|
+
mode === 'inspect'
|
|
1171
1222
|
? (recorded ?? (typeof storeRoot === 'string' ? storeRoot : undefined))
|
|
1172
1223
|
: storeRoot === undefined
|
|
1173
1224
|
? recorded
|
|
@@ -1284,9 +1335,10 @@ export class Cache implements CacheLayer {
|
|
|
1284
1335
|
// The slices: each owns its statements over this handle and its table(s);
|
|
1285
1336
|
// the schema above is the one place every table is declared.
|
|
1286
1337
|
this.files = new FileHashStore(this.db, cacheDir, this.write, repoDir)
|
|
1287
|
-
this.outputs = new OutputIndex(this.db)
|
|
1338
|
+
this.outputs = new OutputIndex(this.db, this.writeBlocked === null && !this.inspecting)
|
|
1288
1339
|
this.folds = new FoldMemo(this.db, this.write && !this.inspecting)
|
|
1289
1340
|
this.history = new RunHistory(this.db, (v) => this.digestValue(v))
|
|
1341
|
+
this.remoteHits = new RemoteHits(this.db, this.writeBlocked === null && !this.inspecting)
|
|
1290
1342
|
|
|
1291
1343
|
// A CACHE_VERSION bump keeps the index but moves every key, so the run
|
|
1292
1344
|
// after an upgrade misses everything. Roadmap 3.3: that is announced,
|
|
@@ -1318,7 +1370,7 @@ export class Cache implements CacheLayer {
|
|
|
1318
1370
|
*/
|
|
1319
1371
|
private guard<T>(op: () => T): T {
|
|
1320
1372
|
const refuse = (err: unknown): never => {
|
|
1321
|
-
throw unreadableIndex(err) ? unreadableIndexError(this.
|
|
1373
|
+
throw unreadableIndex(err) ? unreadableIndexError(this.unreadableFile(), err) : err
|
|
1322
1374
|
}
|
|
1323
1375
|
try {
|
|
1324
1376
|
const out = op()
|
|
@@ -1328,6 +1380,24 @@ export class Cache implements CacheLayer {
|
|
|
1328
1380
|
}
|
|
1329
1381
|
}
|
|
1330
1382
|
|
|
1383
|
+
/**
|
|
1384
|
+
* Which file a "not a database" answer came from: SQLite does not say,
|
|
1385
|
+
* and the entries live in the attached store, so naming the index alone
|
|
1386
|
+
* sent the user to remove a sound file. Asked only on that refusal.
|
|
1387
|
+
*/
|
|
1388
|
+
private unreadableFile(): string {
|
|
1389
|
+
if (this.storeDir === undefined) return this.dbFile
|
|
1390
|
+
const storeFile = path.join(this.storeDir, 'store.db')
|
|
1391
|
+
try {
|
|
1392
|
+
const row = this.db.prepare('PRAGMA store.quick_check(1)').get() as {
|
|
1393
|
+
quick_check?: unknown
|
|
1394
|
+
} | null
|
|
1395
|
+
return row?.quick_check === 'ok' ? this.dbFile : storeFile
|
|
1396
|
+
} catch {
|
|
1397
|
+
return storeFile
|
|
1398
|
+
}
|
|
1399
|
+
}
|
|
1400
|
+
|
|
1331
1401
|
/**
|
|
1332
1402
|
* Attach the shared store as `store`, with the pragmas the index takes:
|
|
1333
1403
|
* WAL for both, so a workspace's run and another's share the store as
|
|
@@ -1363,14 +1433,14 @@ export class Cache implements CacheLayer {
|
|
|
1363
1433
|
}
|
|
1364
1434
|
throw unreadableIndex(err)
|
|
1365
1435
|
? unreadableIndexError(storeFile, err)
|
|
1366
|
-
: (
|
|
1436
|
+
: (cantOpenIndex(storeFile, err) ?? err)
|
|
1367
1437
|
}
|
|
1368
1438
|
}
|
|
1369
1439
|
|
|
1370
1440
|
/**
|
|
1371
1441
|
* The store is an inventory of the artifacts beside it, and only this vx
|
|
1372
1442
|
* reads it: a store another `SCHEMA_VERSION` wrote has its tables dropped
|
|
1373
|
-
* (
|
|
1443
|
+
* (2026-10-06), the artifacts kept. Each is indexed again from its
|
|
1374
1444
|
* own bytes on its next hit (`adopt`). A reading verb reads such a store
|
|
1375
1445
|
* as empty and changes nothing. Creates the store's tables either way.
|
|
1376
1446
|
*/
|
|
@@ -1624,8 +1694,8 @@ export class Cache implements CacheLayer {
|
|
|
1624
1694
|
|
|
1625
1695
|
/**
|
|
1626
1696
|
* Index an artifact the index does not know, from its own bytes: the
|
|
1627
|
-
* artifact is the record, the index only an inventory of it
|
|
1628
|
-
* 2026-10-06). A reset index (a `SCHEMA_VERSION` change, a `cache.db` or
|
|
1697
|
+
* artifact is the record, the index only an inventory of it
|
|
1698
|
+
* (2026-10-06). A reset index (a `SCHEMA_VERSION` change, a `cache.db` or
|
|
1629
1699
|
* `store.db` deleted) loses nothing a task asks for again. The artifact
|
|
1630
1700
|
* is checked as an ingest checks a remote's (its recorded key, its names
|
|
1631
1701
|
* against the declared outputs); one that fails is a miss, and the run
|
|
@@ -1844,7 +1914,7 @@ export class Cache implements CacheLayer {
|
|
|
1844
1914
|
|
|
1845
1915
|
/**
|
|
1846
1916
|
* The subset of `hashes` this store holds, `has` for each: the prefetch
|
|
1847
|
-
* pass asks the remote only about the rest (
|
|
1917
|
+
* pass asks the remote only about the rest (2026-10-15).
|
|
1848
1918
|
*/
|
|
1849
1919
|
async localHasMany(hashes: readonly string[]): Promise<Set<string>> {
|
|
1850
1920
|
const out = new Set<string>()
|
|
@@ -1879,7 +1949,7 @@ export class Cache implements CacheLayer {
|
|
|
1879
1949
|
loadOutputFilesBatch(hashes: readonly string[]): Map<string, OutputFileRow[]> {
|
|
1880
1950
|
return this.guard(() => this.outputs.loadOutputFilesBatch(hashes))
|
|
1881
1951
|
}
|
|
1882
|
-
isOutputsCurrent(projectDir: string, expected: readonly OutputFileRow[]):
|
|
1952
|
+
isOutputsCurrent(projectDir: string, expected: readonly OutputFileRow[]): boolean {
|
|
1883
1953
|
return this.outputs.isOutputsCurrent(projectDir, expected)
|
|
1884
1954
|
}
|
|
1885
1955
|
outputsPath(hash: string): string {
|
|
@@ -1910,7 +1980,7 @@ export class Cache implements CacheLayer {
|
|
|
1910
1980
|
loadOutputDirsBatch(hashes: readonly string[]): Map<string, OutputDirRow[]> {
|
|
1911
1981
|
return this.guard(() => this.outputs.loadOutputDirsBatch(hashes))
|
|
1912
1982
|
}
|
|
1913
|
-
outputDirsCurrent(projectDir: string, rows: readonly OutputDirRow[]):
|
|
1983
|
+
outputDirsCurrent(projectDir: string, rows: readonly OutputDirRow[]): boolean {
|
|
1914
1984
|
return this.outputs.outputDirsCurrent(projectDir, rows)
|
|
1915
1985
|
}
|
|
1916
1986
|
async restoreOutputs(
|
|
@@ -1960,9 +2030,9 @@ export class Cache implements CacheLayer {
|
|
|
1960
2030
|
`its outputs hold ${formatBytes(bytes)}, past the ${formatBytes(this.artifactCeiling)} artifact ceiling`,
|
|
1961
2031
|
)
|
|
1962
2032
|
}
|
|
1963
|
-
if (await this.copyTree(tree, rows, files, projectDir, accept)) return
|
|
2033
|
+
if (await this.copyTree(hash, tree, rows, files, projectDir, accept)) return
|
|
1964
2034
|
await extractArtifactStream(
|
|
1965
|
-
packArtifactStream(
|
|
2035
|
+
packArtifactStream(this.treePlan(hash, tree, rows)),
|
|
1966
2036
|
projectDir,
|
|
1967
2037
|
workspaceRoot,
|
|
1968
2038
|
verify,
|
|
@@ -1971,6 +2041,23 @@ export class Cache implements CacheLayer {
|
|
|
1971
2041
|
)
|
|
1972
2042
|
}
|
|
1973
2043
|
|
|
2044
|
+
/**
|
|
2045
|
+
* The tree packed back into its tar, each file held to its row's size:
|
|
2046
|
+
* the tree form keeps no checksum, and a file cut short at rest (a crash
|
|
2047
|
+
* after its rename, before its data reached the disk) was restored, and
|
|
2048
|
+
* uploaded under a fresh sum.
|
|
2049
|
+
*/
|
|
2050
|
+
private treePlan(hash: string, tree: string, rows: readonly OutputFileRow[]): ArtifactPlan {
|
|
2051
|
+
const plan = planOfTree(tree, this.stdoutText(hash))
|
|
2052
|
+
const size = new Map(rows.map((r) => [treeName(r.path), r.size]))
|
|
2053
|
+
for (const i of plan.inputs) {
|
|
2054
|
+
const want = size.get(i.name)
|
|
2055
|
+
if (want !== undefined && want !== i.size)
|
|
2056
|
+
throw new CorruptArtifactError(hash, `${i.name} holds ${i.size} bytes, recorded ${want}`)
|
|
2057
|
+
}
|
|
2058
|
+
return plan
|
|
2059
|
+
}
|
|
2060
|
+
|
|
1974
2061
|
/**
|
|
1975
2062
|
* The copy-out: false, having written nothing, where it is not the case it
|
|
1976
2063
|
* serves. It writes only where nothing stands: each file's place is absent,
|
|
@@ -1980,6 +2067,7 @@ export class Cache implements CacheLayer {
|
|
|
1980
2067
|
* restore through the tar extractor, ~25 ms a task (2026-10-22).
|
|
1981
2068
|
*/
|
|
1982
2069
|
private async copyTree(
|
|
2070
|
+
hash: string,
|
|
1983
2071
|
tree: string,
|
|
1984
2072
|
rows: readonly OutputFileRow[],
|
|
1985
2073
|
files: readonly OutputFileRow[],
|
|
@@ -2031,6 +2119,34 @@ export class Cache implements CacheLayer {
|
|
|
2031
2119
|
const roots = [...made].filter((d) => ![...made].some((o) => d.startsWith(`${o}/`)))
|
|
2032
2120
|
readUmask()
|
|
2033
2121
|
const plain = restoredMode(0o666)
|
|
2122
|
+
// Another process's re-save of this key renames the tree aside and a
|
|
2123
|
+
// new one in mid-copy: the files copied after came from another build.
|
|
2124
|
+
// A changed inode is that race, a miss (the task runs), and the entry
|
|
2125
|
+
// the other process saved is kept.
|
|
2126
|
+
const ino = lstatSync(tree).ino
|
|
2127
|
+
const swapped = (): boolean => lstatSync(tree, { throwIfNoEntry: false })?.ino !== ino
|
|
2128
|
+
// A standing directory of this user's that withholds its own write (a
|
|
2129
|
+
// task's `chmod 555 dist/ro`) gets owner write and search for the copy
|
|
2130
|
+
// and its mode back after, as the extractor's rename does: else only
|
|
2131
|
+
// root could restore a file into it, and the hit failed EACCES.
|
|
2132
|
+
const reopened = new Map<string, number>()
|
|
2133
|
+
const reopen = (dir: string): boolean => {
|
|
2134
|
+
if (reopened.has(dir)) return true
|
|
2135
|
+
const st = lstatSync(dir, { throwIfNoEntry: false })
|
|
2136
|
+
if (st?.isDirectory() !== true || st.uid !== process.getuid?.()) return false
|
|
2137
|
+
if ((st.mode & 0o300) === 0o300) return false
|
|
2138
|
+
reopened.set(dir, st.mode & 0o7777)
|
|
2139
|
+
chmodSync(dir, (st.mode & 0o7777) | 0o300)
|
|
2140
|
+
return true
|
|
2141
|
+
}
|
|
2142
|
+
const copy = async (to: string, from: string): Promise<number> => {
|
|
2143
|
+
try {
|
|
2144
|
+
return await Bun.write(to, Bun.file(from), { createPath: false })
|
|
2145
|
+
} catch (err) {
|
|
2146
|
+
if ((err as NodeJS.ErrnoException).code !== 'EACCES' || !reopen(path.dirname(to))) throw err
|
|
2147
|
+
return await Bun.write(to, Bun.file(from), { createPath: false })
|
|
2148
|
+
}
|
|
2149
|
+
}
|
|
2034
2150
|
try {
|
|
2035
2151
|
const dirs = new Set<string>()
|
|
2036
2152
|
for (const r of rows) {
|
|
@@ -2040,19 +2156,32 @@ export class Cache implements CacheLayer {
|
|
|
2040
2156
|
await Promise.all(
|
|
2041
2157
|
files.map(async (r) => {
|
|
2042
2158
|
const to = path.join(projectDir, r.path)
|
|
2043
|
-
await
|
|
2159
|
+
const n = await copy(to, path.join(tree, treeName(r.path)))
|
|
2160
|
+
if (n !== r.size)
|
|
2161
|
+
throw new CorruptArtifactError(hash, `${r.path} holds ${n} bytes, recorded ${r.size}`)
|
|
2044
2162
|
const mode = restoredMode(r.mode)
|
|
2045
2163
|
if (mode !== plain) await chmod(to, mode)
|
|
2046
2164
|
const at = new Date(r.mtimeMs)
|
|
2047
2165
|
await utimes(to, at, at)
|
|
2048
2166
|
}),
|
|
2049
2167
|
)
|
|
2168
|
+
if (swapped()) throw new ArtifactVanishedError(hash)
|
|
2050
2169
|
} catch (err) {
|
|
2051
2170
|
await Promise.all([
|
|
2052
2171
|
...roots.map((d) => rm(path.join(projectDir, d), { recursive: true, force: true })),
|
|
2053
2172
|
...placed.map((f) => rm(path.join(projectDir, f), { force: true })),
|
|
2054
2173
|
])
|
|
2174
|
+
if (!(err instanceof ArtifactVanishedError) && swapped())
|
|
2175
|
+
throw new ArtifactVanishedError(hash)
|
|
2055
2176
|
throw err
|
|
2177
|
+
} finally {
|
|
2178
|
+
for (const [dir, mode] of reopened) {
|
|
2179
|
+
try {
|
|
2180
|
+
chmodSync(dir, mode)
|
|
2181
|
+
} catch {
|
|
2182
|
+
// gone with the task's own cleanup since
|
|
2183
|
+
}
|
|
2184
|
+
}
|
|
2056
2185
|
}
|
|
2057
2186
|
return true
|
|
2058
2187
|
}
|
|
@@ -2147,6 +2276,7 @@ export class Cache implements CacheLayer {
|
|
|
2147
2276
|
.filter((r) => workspaceRoot !== undefined || !r.path.startsWith(WORKSPACE_OUTPUT_PREFIX))
|
|
2148
2277
|
.map((r) => (r.path.startsWith(WORKSPACE_OUTPUT_PREFIX) ? r.path : `outputs/${r.path}`))
|
|
2149
2278
|
endRows()
|
|
2279
|
+
const moded = absentModedDirs(rows, projectDir, workspaceRoot)
|
|
2150
2280
|
const verify = (provided: ReadonlySet<string>): void => {
|
|
2151
2281
|
const missing = expected.filter((name) => !provided.has(name))
|
|
2152
2282
|
if (missing.length > 0) {
|
|
@@ -2182,10 +2312,16 @@ export class Cache implements CacheLayer {
|
|
|
2182
2312
|
const tar = await decodedTar(inline ?? Bun.file(src), hash, this.artifactCeiling)
|
|
2183
2313
|
await extractArtifactStream(tar, projectDir, workspaceRoot, verify, accept, workspaceRoot)
|
|
2184
2314
|
}
|
|
2315
|
+
for (const [dir, mode] of moded) await chmod(dir, restoredMode(mode))
|
|
2185
2316
|
} catch (err) {
|
|
2186
2317
|
// A UserError here is the extractor naming the tree's fault (an
|
|
2187
2318
|
// output directory that links out of the project).
|
|
2188
|
-
if (
|
|
2319
|
+
if (
|
|
2320
|
+
err instanceof CorruptArtifactError ||
|
|
2321
|
+
err instanceof UserError ||
|
|
2322
|
+
err instanceof ArtifactVanishedError
|
|
2323
|
+
)
|
|
2324
|
+
throw err
|
|
2189
2325
|
// The save and the ingest proved every name safe, so one that reads
|
|
2190
2326
|
// unsafe now is damaged or tampered bytes: a long name's pax record
|
|
2191
2327
|
// has no header checksum, and the name is judged before the CRC at
|
|
@@ -2264,6 +2400,15 @@ export class Cache implements CacheLayer {
|
|
|
2264
2400
|
if (code === 'ENOENT' && (err as NodeJS.ErrnoException).path === src) {
|
|
2265
2401
|
throw new ArtifactVanishedError(hash)
|
|
2266
2402
|
}
|
|
2403
|
+
// So is a tree file: a prune or another process's re-save moved the
|
|
2404
|
+
// tree aside mid-copy.
|
|
2405
|
+
if (
|
|
2406
|
+
code === 'ENOENT' &&
|
|
2407
|
+
tree !== undefined &&
|
|
2408
|
+
inTree((err as NodeJS.ErrnoException).path, tree)
|
|
2409
|
+
) {
|
|
2410
|
+
throw new ArtifactVanishedError(hash)
|
|
2411
|
+
}
|
|
2267
2412
|
// A staged file the restore itself just wrote is gone before its
|
|
2268
2413
|
// commit: another process cleaned the same outputs under us (two runs
|
|
2269
2414
|
// on one workspace, a `vx watch` beside a `vx run` — a clean landing
|
|
@@ -2300,7 +2445,7 @@ export class Cache implements CacheLayer {
|
|
|
2300
2445
|
|
|
2301
2446
|
/**
|
|
2302
2447
|
* A run over a cache directory it cannot write: one holding entries
|
|
2303
|
-
* serves its hits and saves nothing (
|
|
2448
|
+
* serves its hits and saves nothing (2026-10-16), and the line this
|
|
2304
2449
|
* returns says so; an empty one has nothing to serve and refuses.
|
|
2305
2450
|
*/
|
|
2306
2451
|
readOnlyNotice(): string | null {
|
|
@@ -2320,6 +2465,7 @@ export class Cache implements CacheLayer {
|
|
|
2320
2465
|
outputFiles: string[]
|
|
2321
2466
|
outputDirs?: string[]
|
|
2322
2467
|
workspaceOutputFiles?: string[]
|
|
2468
|
+
workspaceOutputDirs?: string[]
|
|
2323
2469
|
workspaceRoot?: string
|
|
2324
2470
|
inputComponents?: readonly TaskInputRow[]
|
|
2325
2471
|
/** See `CacheLayer.save`. */
|
|
@@ -2410,12 +2556,17 @@ export class Cache implements CacheLayer {
|
|
|
2410
2556
|
const pinned = this.tempPath(hash)
|
|
2411
2557
|
try {
|
|
2412
2558
|
linkSync(this.tarPath(hash), pinned)
|
|
2559
|
+
// The link shares the artifact's mtime, as adopt's does: an artifact
|
|
2560
|
+
// saved over an hour ago made the pin a crashed save's temp to another
|
|
2561
|
+
// process's orphan sweep, which took it mid-upload.
|
|
2562
|
+
const now = new Date()
|
|
2563
|
+
utimesSync(pinned, now, now)
|
|
2413
2564
|
} catch (err) {
|
|
2414
2565
|
const tree = this.treePath(hash)
|
|
2415
2566
|
if ((err as NodeJS.ErrnoException).code !== 'ENOENT' || !existsSync(tree)) throw err
|
|
2416
2567
|
// A tree is packed into the artifact a remote expects here, in the
|
|
2417
2568
|
// upload queue, off the save; a small one in memory.
|
|
2418
|
-
const plan =
|
|
2569
|
+
const plan = this.treePlan(hash, tree, this.outputs.rowsOf(hash))
|
|
2419
2570
|
if (plan.size <= STREAM_DECODE_FROM) {
|
|
2420
2571
|
const bytes = await zstdCompress(await packArtifactBytes(plan))
|
|
2421
2572
|
return { body: new Blob([bytes]), release: async () => {} }
|
|
@@ -2513,6 +2664,7 @@ export class Cache implements CacheLayer {
|
|
|
2513
2664
|
outputFiles: string[]
|
|
2514
2665
|
outputDirs?: string[]
|
|
2515
2666
|
workspaceOutputFiles?: string[]
|
|
2667
|
+
workspaceOutputDirs?: string[]
|
|
2516
2668
|
workspaceRoot?: string
|
|
2517
2669
|
}): Map<string, string> {
|
|
2518
2670
|
const outputs = new Map<string, string>()
|
|
@@ -2546,6 +2698,7 @@ export class Cache implements CacheLayer {
|
|
|
2546
2698
|
outputFiles: string[]
|
|
2547
2699
|
outputDirs?: string[]
|
|
2548
2700
|
workspaceOutputFiles?: string[]
|
|
2701
|
+
workspaceOutputDirs?: string[]
|
|
2549
2702
|
workspaceRoot?: string
|
|
2550
2703
|
}): Promise<Uint8Array> {
|
|
2551
2704
|
// stdout is ALWAYS present in the artifact, even if empty, so the
|
|
@@ -2568,9 +2721,15 @@ export class Cache implements CacheLayer {
|
|
|
2568
2721
|
key: args.hash,
|
|
2569
2722
|
stdout: args.entry.stdout ?? '',
|
|
2570
2723
|
outputs: this.outputsOf(args),
|
|
2571
|
-
dirs:
|
|
2724
|
+
dirs: [
|
|
2725
|
+
...(args.outputDirs ?? []).map((d) => `outputs/${relPosix(args.projectDir, d)}`),
|
|
2726
|
+
...(args.workspaceOutputDirs ?? []).map(
|
|
2727
|
+
(d) => `${WORKSPACE_OUTPUT_PREFIX}${relPosix(args.workspaceRoot!, d)}`,
|
|
2728
|
+
),
|
|
2729
|
+
],
|
|
2572
2730
|
exec: usageOfEntry(args.entry),
|
|
2573
2731
|
root: args.workspaceRoot ?? args.projectDir,
|
|
2732
|
+
projectDir: args.projectDir,
|
|
2574
2733
|
})
|
|
2575
2734
|
if (plan.size > this.artifactCeiling) {
|
|
2576
2735
|
throw new Error(
|
|
@@ -2786,7 +2945,7 @@ export class Cache implements CacheLayer {
|
|
|
2786
2945
|
throw new CorruptArtifactError(hash, 'artifact is not a readable archive', err)
|
|
2787
2946
|
}
|
|
2788
2947
|
const { entries } = scanned
|
|
2789
|
-
|
|
2948
|
+
let totalBytes =
|
|
2790
2949
|
saved !== undefined
|
|
2791
2950
|
? saved.bytes
|
|
2792
2951
|
: compressed instanceof Uint8Array
|
|
@@ -2820,6 +2979,12 @@ export class Cache implements CacheLayer {
|
|
|
2820
2979
|
tmpPath = undefined
|
|
2821
2980
|
tree = dir
|
|
2822
2981
|
}
|
|
2982
|
+
// A tree keeps its files as they are: counted as a saved tree counts
|
|
2983
|
+
// them, not as the archive it was unpacked from (`--max-size`, stats).
|
|
2984
|
+
if (tree !== undefined && saved === undefined) {
|
|
2985
|
+
totalBytes = 0
|
|
2986
|
+
for (const e of entries) totalBytes += e.size
|
|
2987
|
+
}
|
|
2823
2988
|
const outputFileRows: Array<[string, number, number, number]> = []
|
|
2824
2989
|
// Per-output-file fingerprint rows feed the skip-restore check.
|
|
2825
2990
|
// Row paths: project entries store the bare rel (`outputs/`
|
|
@@ -2845,15 +3010,21 @@ export class Cache implements CacheLayer {
|
|
|
2845
3010
|
outputFileRows.push([rowPath, e.size, e.mode & 0o777, Math.floor(e.mtimeMs)])
|
|
2846
3011
|
}
|
|
2847
3012
|
// An empty directory's row is its rel with a trailing slash: the hit's
|
|
2848
|
-
// check asks only that a directory stands there.
|
|
2849
|
-
|
|
3013
|
+
// check asks only that a directory stands there. So is a directory with
|
|
3014
|
+
// a mode to restore, which carries it beside `DIR_MODE`.
|
|
3015
|
+
const dirModes = new Map(scanned.dirModes)
|
|
3016
|
+
const dirRow = (d: string): void => {
|
|
3017
|
+
const mode = dirModes.get(d)
|
|
3018
|
+
dirModes.delete(d)
|
|
2850
3019
|
outputFileRows.push([
|
|
2851
3020
|
`${d.startsWith('outputs/') ? d.slice('outputs/'.length) : d}/`,
|
|
2852
3021
|
0,
|
|
2853
|
-
0,
|
|
3022
|
+
mode === undefined ? 0 : DIR_MODE | mode,
|
|
2854
3023
|
0,
|
|
2855
3024
|
])
|
|
2856
3025
|
}
|
|
3026
|
+
for (const d of scanned.dirs) dirRow(d)
|
|
3027
|
+
for (const d of [...dirModes.keys()]) dirRow(d)
|
|
2857
3028
|
// A link's row: its rel, the target's length and CRC-32 where a file
|
|
2858
3029
|
// has its size and mtime, and the link type in the mode, so the hit's
|
|
2859
3030
|
// check compares the target and never follows it.
|
|
@@ -2931,7 +3102,7 @@ export class Cache implements CacheLayer {
|
|
|
2931
3102
|
}
|
|
2932
3103
|
return moved
|
|
2933
3104
|
}
|
|
2934
|
-
const
|
|
3105
|
+
const index = () => {
|
|
2935
3106
|
if (inline !== null) {
|
|
2936
3107
|
aside = moveAside()
|
|
2937
3108
|
upsertBlob.run(hash, now, inline)
|
|
@@ -2977,10 +3148,13 @@ export class Cache implements CacheLayer {
|
|
|
2977
3148
|
if (inputComponents !== undefined && inputComponents.length > 0) {
|
|
2978
3149
|
insertEntryInput.run(hash, encodeEntryInputs(inputComponents))
|
|
2979
3150
|
}
|
|
2980
|
-
}
|
|
3151
|
+
}
|
|
2981
3152
|
const endTx = span('save: index tx')
|
|
2982
3153
|
try {
|
|
2983
|
-
|
|
3154
|
+
// Built inside the try: on a database a stopping run closed while a
|
|
3155
|
+
// download streamed in, building it throws, and the temp or tree
|
|
3156
|
+
// the body went to stayed behind.
|
|
3157
|
+
this.db.transaction(index).immediate()
|
|
2984
3158
|
} catch (err) {
|
|
2985
3159
|
endTx()
|
|
2986
3160
|
const left = renamed ?? tree ?? tmpPath
|
|
@@ -3063,6 +3237,7 @@ export class Cache implements CacheLayer {
|
|
|
3063
3237
|
return this.guard(() => this.history.recordRunBundle(bundle))
|
|
3064
3238
|
}
|
|
3065
3239
|
recordRemoteErrors(runId: string, errors: readonly (readonly [string, string])[]): void {
|
|
3240
|
+
if (this.writeBlocked !== null) return
|
|
3066
3241
|
return this.guard(() => this.history.recordRemoteErrors(runId, errors))
|
|
3067
3242
|
}
|
|
3068
3243
|
stats(opts: CacheStatsOptions = {}): CacheStats {
|
|
@@ -3110,7 +3285,7 @@ export class Cache implements CacheLayer {
|
|
|
3110
3285
|
// orphan rows in either order: landed first, the cascade takes them;
|
|
3111
3286
|
// landed after, the flush's own entry check drops them (item 628).
|
|
3112
3287
|
this.outputs.flushOutputDirs()
|
|
3113
|
-
const { olderThanMs, maxBytes,
|
|
3288
|
+
const { olderThanMs, maxBytes, dry = false } = options
|
|
3114
3289
|
if (olderThanMs === undefined && maxBytes === undefined) {
|
|
3115
3290
|
throw new Error('prune: pass at least one of `olderThanMs` or `maxBytes`')
|
|
3116
3291
|
}
|
|
@@ -3140,7 +3315,7 @@ export class Cache implements CacheLayer {
|
|
|
3140
3315
|
// Promise.all over the unlinks. The hash list binds as one `json_each`
|
|
3141
3316
|
// parameter, so a huge eviction stays under any build's bound-parameter
|
|
3142
3317
|
// ceiling.
|
|
3143
|
-
if (
|
|
3318
|
+
if (dry) {
|
|
3144
3319
|
return { evicted: victims.size, bytesFreed, orphans: picked.files.length, orphanBytes }
|
|
3145
3320
|
}
|
|
3146
3321
|
if (victims.size > 0 || stale.length > 0) {
|
|
@@ -3181,17 +3356,35 @@ export class Cache implements CacheLayer {
|
|
|
3181
3356
|
if (!isIndexFull(err)) throw err
|
|
3182
3357
|
}
|
|
3183
3358
|
// History and memo rows a month old go with a prune, never a run's
|
|
3184
|
-
// close: five DELETEs on every run's exit was its slowest step
|
|
3185
|
-
// 2026-10-18). `invocations` shares the window with `runs`, or `vx last`
|
|
3359
|
+
// close: five DELETEs on every run's exit was its slowest step
|
|
3360
|
+
// (2026-10-18). `invocations` shares the window with `runs`, or `vx last`
|
|
3186
3361
|
// would list a run whose task rows are gone.
|
|
3187
3362
|
const cutoff = now - HISTORY_RETENTION_MS
|
|
3188
3363
|
this.history.pruneOlderThan(cutoff)
|
|
3189
3364
|
this.files.pruneOlderThan(cutoff)
|
|
3190
3365
|
this.folds.prune(cutoff)
|
|
3191
3366
|
const orphans = await this.reapOrphans(picked.files)
|
|
3367
|
+
// An inline artifact's deleted pages go to SQLite's free list and the
|
|
3368
|
+
// file keeps its size: give them back, or the bytes said freed are not.
|
|
3369
|
+
if ([...victims].some((h) => inline.has(h))) this.releaseFreePages()
|
|
3192
3370
|
return { evicted: victims.size, bytesFreed, ...orphans }
|
|
3193
3371
|
}
|
|
3194
3372
|
|
|
3373
|
+
/** VACUUM the schema holding inline artifacts; busy or no room to rewrite keeps the pages for reuse. */
|
|
3374
|
+
private releaseFreePages(): void {
|
|
3375
|
+
const schema = this.storeDir === undefined ? 'main' : 'store'
|
|
3376
|
+
try {
|
|
3377
|
+
const { freelist_count: free } = this.db.prepare(`PRAGMA ${schema}.freelist_count`).get() as {
|
|
3378
|
+
freelist_count: number
|
|
3379
|
+
}
|
|
3380
|
+
if (free === 0) return
|
|
3381
|
+
this.db.exec(`VACUUM ${schema}`)
|
|
3382
|
+
this.db.exec(`PRAGMA ${schema}.wal_checkpoint(TRUNCATE)`)
|
|
3383
|
+
} catch {
|
|
3384
|
+
// Another process holds the database, or the disk has no room for the copy.
|
|
3385
|
+
}
|
|
3386
|
+
}
|
|
3387
|
+
|
|
3195
3388
|
/**
|
|
3196
3389
|
* Unlink the row-less files `pickVictims` took: a `<hash>.tar.zst` with
|
|
3197
3390
|
* no `entries` row (another vx version's open dropped it, a `cache.db`
|
|
@@ -3370,11 +3563,7 @@ export class Cache implements CacheLayer {
|
|
|
3370
3563
|
}
|
|
3371
3564
|
}
|
|
3372
3565
|
|
|
3373
|
-
/**
|
|
3374
|
-
* SQLite's file-control opcode (sqlite3.h). Spelled here, not imported from
|
|
3375
|
-
* `bun:sqlite`'s `constants`: the playground bundles this file against a
|
|
3376
|
-
* shim that exports only `Database`.
|
|
3377
|
-
*/
|
|
3566
|
+
/** SQLite's file-control opcode (sqlite3.h). */
|
|
3378
3567
|
const SQLITE_FCNTL_PERSIST_WAL = 10
|
|
3379
3568
|
|
|
3380
3569
|
/**
|
|
@@ -3440,6 +3629,32 @@ function assertArtifactNames(
|
|
|
3440
3629
|
}
|
|
3441
3630
|
}
|
|
3442
3631
|
|
|
3632
|
+
/**
|
|
3633
|
+
* The directories with a mode to restore (`DIR_MODE` rows) that are absent
|
|
3634
|
+
* before the restore, deepest first: the restore makes them with mkdir's
|
|
3635
|
+
* default, and each is then given the mode the miss left, minus the umask
|
|
3636
|
+
* as a file's is. One that stands is the tree's, left as it is. A mode
|
|
3637
|
+
* only ever narrows mkdir's default, whatever an artifact says.
|
|
3638
|
+
*/
|
|
3639
|
+
function absentModedDirs(
|
|
3640
|
+
rows: readonly OutputFileRow[],
|
|
3641
|
+
projectDir: string,
|
|
3642
|
+
workspaceRoot: string | undefined,
|
|
3643
|
+
): Array<[string, number]> {
|
|
3644
|
+
const out: Array<[string, number]> = []
|
|
3645
|
+
for (const r of rows) {
|
|
3646
|
+
if ((r.mode & 0o170000) !== DIR_MODE || !r.path.endsWith('/')) continue
|
|
3647
|
+
const rel = r.path.slice(0, -1)
|
|
3648
|
+
const ws = rel.startsWith(WORKSPACE_OUTPUT_PREFIX)
|
|
3649
|
+
if (ws && workspaceRoot === undefined) continue
|
|
3650
|
+
const dir = ws
|
|
3651
|
+
? path.join(workspaceRoot!, rel.slice(WORKSPACE_OUTPUT_PREFIX.length))
|
|
3652
|
+
: path.join(projectDir, rel)
|
|
3653
|
+
if (lstatSync(dir, { throwIfNoEntry: false }) === undefined) out.push([dir, r.mode & 0o777])
|
|
3654
|
+
}
|
|
3655
|
+
return out.sort((a, b) => b[0].length - a[0].length)
|
|
3656
|
+
}
|
|
3657
|
+
|
|
3443
3658
|
/** A row's path as the tree names its file. */
|
|
3444
3659
|
function treeName(rowPath: string): string {
|
|
3445
3660
|
return rowPath.startsWith(WORKSPACE_OUTPUT_PREFIX) ? rowPath : `outputs/${rowPath}`
|