@vzn/vx 0.0.645 → 0.0.648
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 +39 -50
- 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 +127 -5
- package/schemas/plan.json +28 -2
- package/schemas/show.json +10 -1
- package/skills/vx/SKILL.md +19 -3
- package/src/bin.ts +54 -13
- package/src/cache/archive.ts +324 -23
- package/src/cache/cache.ts +330 -65
- package/src/cache/git-inputs.ts +274 -97
- package/src/cache/index.ts +7 -2
- package/src/cache/inputs.ts +515 -83
- package/src/cache/key-fold.ts +15 -6
- package/src/cache/layer.ts +74 -11
- package/src/cache/layered-cache.ts +154 -59
- package/src/cache/linked-inputs.ts +1 -1
- package/src/cache/output-index.ts +44 -14
- package/src/cache/plugin-cache.ts +49 -23
- package/src/cache/remote-hits.ts +72 -0
- package/src/cache/remote-lane.ts +100 -15
- package/src/cache/run-history.ts +77 -23
- package/src/cache/run-store.ts +11 -10
- package/src/cache/schema.ts +42 -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/core-alias.ts +5 -2
- package/src/cli/docs.ts +42 -16
- package/src/cli/help.ts +62 -25
- package/src/cli/index.ts +79 -45
- package/src/cli/info.ts +12 -6
- package/src/cli/init.ts +222 -34
- package/src/cli/last.ts +443 -47
- package/src/cli/lock.ts +197 -52
- package/src/cli/plan-format.ts +91 -49
- package/src/cli/plugin-commands.ts +160 -11
- package/src/cli/plugin-templates.ts +13 -13
- package/src/cli/registry.ts +209 -0
- package/src/cli/run.ts +111 -41
- package/src/cli/select.ts +148 -49
- package/src/cli/show.ts +59 -16
- package/src/cli/task-verb.ts +16 -6
- package/src/cli/upgrade.ts +155 -54
- 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 +262 -15
- package/src/exec/index.ts +8 -2
- package/src/exec/kill-tree.ts +15 -0
- package/src/exec/local-executor.ts +1 -1
- package/src/exec/proc-sample.ts +70 -6
- package/src/exec/runner.ts +256 -53
- package/src/exec/sandbox-binds.ts +7 -3
- package/src/exec/sandbox-paths.ts +1 -1
- package/src/exec/sandbox-runtime.ts +222 -28
- 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 +326 -65
- 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 +268 -50
- package/src/history/index.ts +2 -0
- package/src/history/metrics.ts +142 -45
- package/src/index.ts +42 -10
- 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 +6 -0
- package/src/orchestrator/execute-task.ts +155 -42
- 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 +86 -0
- package/src/orchestrator/hit-restore.ts +227 -122
- package/src/orchestrator/index.ts +20 -3
- package/src/orchestrator/local-floor.ts +198 -0
- package/src/orchestrator/local-shortcircuit.ts +51 -8
- package/src/orchestrator/lockfile-claim.ts +96 -16
- package/src/orchestrator/miss-save.ts +52 -12
- 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 +97 -28
- package/src/orchestrator/persistent.ts +16 -4
- package/src/orchestrator/placement.ts +257 -14
- package/src/orchestrator/plan.ts +64 -7
- package/src/orchestrator/plugin-host.ts +230 -40
- package/src/orchestrator/plugin.ts +150 -37
- package/src/orchestrator/prepare.ts +178 -32
- 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 +300 -0
- package/src/orchestrator/run-live.ts +294 -0
- package/src/orchestrator/run-lock.ts +13 -7
- package/src/orchestrator/run-records.ts +35 -23
- 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 +409 -79
- package/src/orchestrator/sandbox-fix.ts +7 -5
- package/src/orchestrator/sandbox-request.ts +78 -22
- 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 +206 -0
- package/src/orchestrator/task-hash.ts +89 -14
- package/src/orchestrator/task-log-buffer.ts +72 -36
- package/src/orchestrator/task-process.ts +22 -0
- package/src/orchestrator/telemetry-host.ts +24 -3
- package/src/orchestrator/telemetry.ts +113 -38
- package/src/orchestrator/upstream.ts +15 -0
- package/src/report/events.ts +32 -13
- package/src/report/forecast.ts +36 -35
- package/src/report/framed-output.ts +173 -46
- package/src/report/index.ts +1 -0
- package/src/report/logger.ts +217 -186
- package/src/report/path-links.ts +12 -4
- package/src/report/plain-output.ts +13 -5
- package/src/report/status-line.ts +94 -46
- package/src/report/summary.ts +174 -38
- package/src/report/terminal-signals.ts +13 -2
- package/src/settings.ts +14 -6
- package/src/util/bun-version.ts +4 -1
- package/src/util/colors.ts +20 -3
- 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 +22 -5
- package/src/util/num.ts +8 -0
- package/src/util/paths.ts +121 -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 +65 -4
- 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 +282 -0
- 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 -2
- package/src/workspace/json-data.ts +3 -4
- package/src/workspace/lockfile.ts +57 -8
- package/src/workspace/migration.ts +72 -49
- package/src/workspace/package-graph.ts +5 -4
- package/src/workspace/project-loader.ts +314 -47
- package/src/workspace/repo-id.ts +1 -1
- package/src/workspace/workspace.ts +176 -33
- package/src/cli/foreign-flags.ts +0 -727
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,8 +164,12 @@ 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
174
|
])
|
|
169
175
|
|
|
@@ -217,20 +223,28 @@ function unreadableIndexError(dbFile: string, err: unknown): UserError {
|
|
|
217
223
|
* SQLite reports a descriptor it could not get as `SQLITE_CANTOPEN`, never
|
|
218
224
|
* `EMFILE`, so an open under a low `ulimit -n` reached the user as a bare
|
|
219
225
|
* `vx: SQLiteError: unable to open database file` (D's lead, A-56). One
|
|
220
|
-
* 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.
|
|
221
228
|
*/
|
|
222
|
-
function
|
|
229
|
+
function cantOpenIndex(dbFile: string, err: unknown): UserError | undefined {
|
|
223
230
|
const code = (err as { code?: unknown } | null)?.code
|
|
224
231
|
if (typeof code !== 'string' || !code.startsWith('SQLITE_CANTOPEN')) return undefined
|
|
225
232
|
try {
|
|
226
233
|
closeSync(openSync(process.execPath, 'r'))
|
|
227
|
-
return undefined
|
|
228
234
|
} catch (probe) {
|
|
229
235
|
if (!isOutOfFds(probe)) return undefined
|
|
230
236
|
return new UserError(
|
|
231
237
|
`the cache index ${dbFile} could not be opened (${(err as Error).message}) — ${OUT_OF_FDS_HINT}`,
|
|
238
|
+
'VX_E_FDS',
|
|
232
239
|
)
|
|
233
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
|
+
)
|
|
234
248
|
}
|
|
235
249
|
|
|
236
250
|
// SCHEMA history (drop+recreate on mismatch; pre-alpha, no migrations):
|
|
@@ -324,7 +338,26 @@ function outOfFdsAtOpen(dbFile: string, err: unknown): UserError | undefined {
|
|
|
324
338
|
// is unchanged.
|
|
325
339
|
// v42: fold_memo — each task's last key by its fold up to the files and
|
|
326
340
|
// a token of their git records. The cache KEY is unchanged.
|
|
327
|
-
|
|
341
|
+
// v43: runs.peak_cores / avg_rss_bytes — what a sampled run burned at
|
|
342
|
+
// its peak and held on average. The cache KEY is unchanged.
|
|
343
|
+
// v44: runs.placement — where an executor ran the task. The cache KEY
|
|
344
|
+
// is unchanged.
|
|
345
|
+
// v45: a 'remote' task_durations row also carries the task's measured
|
|
346
|
+
// cores, memory and saturation, for a run with no local history.
|
|
347
|
+
// The cache KEY is unchanged.
|
|
348
|
+
// v46: runs.keyless_by — the task with no inputs a key folded, so
|
|
349
|
+
// `vx why` names it. The cache KEY is unchanged.
|
|
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'
|
|
328
361
|
|
|
329
362
|
/**
|
|
330
363
|
* An entry's key components as its one `entry_inputs` row (v34): JSON
|
|
@@ -355,6 +388,7 @@ const STORE_TABLES = [
|
|
|
355
388
|
'task_outcomes',
|
|
356
389
|
'blob_sizes',
|
|
357
390
|
'plugin_blobs',
|
|
391
|
+
'remote_hits',
|
|
358
392
|
'store_meta',
|
|
359
393
|
'entries',
|
|
360
394
|
]
|
|
@@ -678,7 +712,7 @@ function ensurePrivateDir(dir: string): string | null {
|
|
|
678
712
|
if ((st.mode & 0o022) !== 0) return `${dir} is writable by other users`
|
|
679
713
|
// The user's own directory open to others (a `~/.vx` made at the
|
|
680
714
|
// default umask, 0755) is vx's to close: refusing it left the store
|
|
681
|
-
// unused on a stock macOS home (
|
|
715
|
+
// unused on a stock macOS home (2026-10-06: "you should own it").
|
|
682
716
|
if ((st.mode & 0o077) !== 0) {
|
|
683
717
|
try {
|
|
684
718
|
chmodSync(dir, 0o700)
|
|
@@ -697,12 +731,11 @@ function ensurePrivateDir(dir: string): string | null {
|
|
|
697
731
|
// ignored directory is skipped by the walk entirely. Only written when
|
|
698
732
|
// absent, so a user's own file wins.
|
|
699
733
|
const utf8 = new TextEncoder()
|
|
700
|
-
const IGNORE_ALL = '*\n'
|
|
701
734
|
|
|
702
735
|
/**
|
|
703
736
|
* The ignore file that keeps `cacheDir` out of git, and its text. The
|
|
704
737
|
* default `.vx/cache` is named by `.vx/.gitignore` (`/cache`), committed
|
|
705
|
-
* beside the workspace config and the lock (
|
|
738
|
+
* beside the workspace config and the lock (2026-10-14); any other
|
|
706
739
|
* cache directory holds a `*` one of its own. Written only when missing:
|
|
707
740
|
* an existing file is the user's.
|
|
708
741
|
*/
|
|
@@ -783,15 +816,40 @@ function makeCacheDir(cacheDir: string): void {
|
|
|
783
816
|
try {
|
|
784
817
|
mkdirSync(cacheDir, { recursive: true })
|
|
785
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
|
+
}
|
|
786
830
|
// A read-only checkout with no cache yet: every verb opens the cache,
|
|
787
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'
|
|
788
833
|
throw new UserError(
|
|
789
|
-
`cannot create cache directory ${cacheDir} (${errorText(err)}) — vx keeps its cache there;
|
|
790
|
-
|
|
834
|
+
`cannot create cache directory ${cacheDir} (${errorText(err)}) — vx keeps its cache there; ${fix}, ${where}`,
|
|
835
|
+
'VX_E_FS',
|
|
791
836
|
)
|
|
792
837
|
}
|
|
793
838
|
}
|
|
794
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
|
+
|
|
795
853
|
function errorText(err: unknown): string {
|
|
796
854
|
return err instanceof Error ? err.message : String(err)
|
|
797
855
|
}
|
|
@@ -892,6 +950,8 @@ export class Cache implements CacheLayer {
|
|
|
892
950
|
private readonly outputs: OutputIndex
|
|
893
951
|
private readonly folds: FoldMemo
|
|
894
952
|
private readonly history: RunHistory
|
|
953
|
+
/** What the remotes said they hold, across runs (remote-hits.ts). */
|
|
954
|
+
readonly remoteHits: RemoteHits
|
|
895
955
|
|
|
896
956
|
/**
|
|
897
957
|
* Local-layer read/write gates for the task ARTIFACT path only.
|
|
@@ -919,7 +979,7 @@ export class Cache implements CacheLayer {
|
|
|
919
979
|
* Set when THIS open found an index written by another `SCHEMA_VERSION`
|
|
920
980
|
* and dropped every table. The next open sees the current version and
|
|
921
981
|
* reports null. vx prints nothing for it: the cache is vx's to keep
|
|
922
|
-
* (
|
|
982
|
+
* (2026-10-06).
|
|
923
983
|
*/
|
|
924
984
|
readonly schemaReset: SchemaReset | null = null
|
|
925
985
|
|
|
@@ -1005,7 +1065,7 @@ export class Cache implements CacheLayer {
|
|
|
1005
1065
|
try {
|
|
1006
1066
|
this.db = new Database(memory ? ':memory:' : dbFile, { create: true })
|
|
1007
1067
|
} catch (err) {
|
|
1008
|
-
throw
|
|
1068
|
+
throw cantOpenIndex(dbFile, err) ?? err
|
|
1009
1069
|
}
|
|
1010
1070
|
// busy_timeout makes concurrent writers wait for the lock instead of
|
|
1011
1071
|
// failing immediately with SQLITE_BUSY. Two parallel `vx run`
|
|
@@ -1024,7 +1084,7 @@ export class Cache implements CacheLayer {
|
|
|
1024
1084
|
return read()
|
|
1025
1085
|
} catch (err) {
|
|
1026
1086
|
if (!unreadableIndex(err)) {
|
|
1027
|
-
const fds =
|
|
1087
|
+
const fds = cantOpenIndex(dbFile, err)
|
|
1028
1088
|
if (fds === undefined) throw err
|
|
1029
1089
|
try {
|
|
1030
1090
|
closeDb(this.db)
|
|
@@ -1087,8 +1147,8 @@ export class Cache implements CacheLayer {
|
|
|
1087
1147
|
| undefined
|
|
1088
1148
|
)?.value
|
|
1089
1149
|
// Any other schema is reset by an opener that may write it: the index is
|
|
1090
|
-
// an inventory the artifacts rebuild, whichever vx wrote it
|
|
1091
|
-
// 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
|
|
1092
1152
|
// leaves it as it was.
|
|
1093
1153
|
const refuseUnreadable = (found: string): void => {
|
|
1094
1154
|
if (mode !== 'open') {
|
|
@@ -1112,7 +1172,7 @@ export class Cache implements CacheLayer {
|
|
|
1112
1172
|
// keeps it so).
|
|
1113
1173
|
if (found !== undefined) {
|
|
1114
1174
|
this.db.exec(
|
|
1115
|
-
'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;',
|
|
1116
1176
|
)
|
|
1117
1177
|
}
|
|
1118
1178
|
// The tables land under the same write lock as the stamp: made
|
|
@@ -1155,8 +1215,10 @@ export class Cache implements CacheLayer {
|
|
|
1155
1215
|
} | null
|
|
1156
1216
|
)?.value,
|
|
1157
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.
|
|
1158
1220
|
let storeDir =
|
|
1159
|
-
mode
|
|
1221
|
+
mode === 'inspect'
|
|
1160
1222
|
? (recorded ?? (typeof storeRoot === 'string' ? storeRoot : undefined))
|
|
1161
1223
|
: storeRoot === undefined
|
|
1162
1224
|
? recorded
|
|
@@ -1273,9 +1335,10 @@ export class Cache implements CacheLayer {
|
|
|
1273
1335
|
// The slices: each owns its statements over this handle and its table(s);
|
|
1274
1336
|
// the schema above is the one place every table is declared.
|
|
1275
1337
|
this.files = new FileHashStore(this.db, cacheDir, this.write, repoDir)
|
|
1276
|
-
this.outputs = new OutputIndex(this.db)
|
|
1338
|
+
this.outputs = new OutputIndex(this.db, this.writeBlocked === null && !this.inspecting)
|
|
1277
1339
|
this.folds = new FoldMemo(this.db, this.write && !this.inspecting)
|
|
1278
1340
|
this.history = new RunHistory(this.db, (v) => this.digestValue(v))
|
|
1341
|
+
this.remoteHits = new RemoteHits(this.db, this.writeBlocked === null && !this.inspecting)
|
|
1279
1342
|
|
|
1280
1343
|
// A CACHE_VERSION bump keeps the index but moves every key, so the run
|
|
1281
1344
|
// after an upgrade misses everything. Roadmap 3.3: that is announced,
|
|
@@ -1307,7 +1370,7 @@ export class Cache implements CacheLayer {
|
|
|
1307
1370
|
*/
|
|
1308
1371
|
private guard<T>(op: () => T): T {
|
|
1309
1372
|
const refuse = (err: unknown): never => {
|
|
1310
|
-
throw unreadableIndex(err) ? unreadableIndexError(this.
|
|
1373
|
+
throw unreadableIndex(err) ? unreadableIndexError(this.unreadableFile(), err) : err
|
|
1311
1374
|
}
|
|
1312
1375
|
try {
|
|
1313
1376
|
const out = op()
|
|
@@ -1317,6 +1380,24 @@ export class Cache implements CacheLayer {
|
|
|
1317
1380
|
}
|
|
1318
1381
|
}
|
|
1319
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
|
+
|
|
1320
1401
|
/**
|
|
1321
1402
|
* Attach the shared store as `store`, with the pragmas the index takes:
|
|
1322
1403
|
* WAL for both, so a workspace's run and another's share the store as
|
|
@@ -1352,14 +1433,14 @@ export class Cache implements CacheLayer {
|
|
|
1352
1433
|
}
|
|
1353
1434
|
throw unreadableIndex(err)
|
|
1354
1435
|
? unreadableIndexError(storeFile, err)
|
|
1355
|
-
: (
|
|
1436
|
+
: (cantOpenIndex(storeFile, err) ?? err)
|
|
1356
1437
|
}
|
|
1357
1438
|
}
|
|
1358
1439
|
|
|
1359
1440
|
/**
|
|
1360
1441
|
* The store is an inventory of the artifacts beside it, and only this vx
|
|
1361
1442
|
* reads it: a store another `SCHEMA_VERSION` wrote has its tables dropped
|
|
1362
|
-
* (
|
|
1443
|
+
* (2026-10-06), the artifacts kept. Each is indexed again from its
|
|
1363
1444
|
* own bytes on its next hit (`adopt`). A reading verb reads such a store
|
|
1364
1445
|
* as empty and changes nothing. Creates the store's tables either way.
|
|
1365
1446
|
*/
|
|
@@ -1613,8 +1694,8 @@ export class Cache implements CacheLayer {
|
|
|
1613
1694
|
|
|
1614
1695
|
/**
|
|
1615
1696
|
* Index an artifact the index does not know, from its own bytes: the
|
|
1616
|
-
* artifact is the record, the index only an inventory of it
|
|
1617
|
-
* 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
|
|
1618
1699
|
* `store.db` deleted) loses nothing a task asks for again. The artifact
|
|
1619
1700
|
* is checked as an ingest checks a remote's (its recorded key, its names
|
|
1620
1701
|
* against the declared outputs); one that fails is a miss, and the run
|
|
@@ -1699,6 +1780,10 @@ export class Cache implements CacheLayer {
|
|
|
1699
1780
|
return this.guard(() => this.readEntry(hash))
|
|
1700
1781
|
}
|
|
1701
1782
|
|
|
1783
|
+
getSaved(hash: string): Promise<CacheEntry | null> {
|
|
1784
|
+
return this.getIngested(hash)
|
|
1785
|
+
}
|
|
1786
|
+
|
|
1702
1787
|
private async readEntry(hash: string): Promise<CacheEntry | null> {
|
|
1703
1788
|
const row = this.selectEntry.get(hash) as EntryRow | null
|
|
1704
1789
|
if (row === null) return null
|
|
@@ -1829,7 +1914,7 @@ export class Cache implements CacheLayer {
|
|
|
1829
1914
|
|
|
1830
1915
|
/**
|
|
1831
1916
|
* The subset of `hashes` this store holds, `has` for each: the prefetch
|
|
1832
|
-
* pass asks the remote only about the rest (
|
|
1917
|
+
* pass asks the remote only about the rest (2026-10-15).
|
|
1833
1918
|
*/
|
|
1834
1919
|
async localHasMany(hashes: readonly string[]): Promise<Set<string>> {
|
|
1835
1920
|
const out = new Set<string>()
|
|
@@ -1864,7 +1949,7 @@ export class Cache implements CacheLayer {
|
|
|
1864
1949
|
loadOutputFilesBatch(hashes: readonly string[]): Map<string, OutputFileRow[]> {
|
|
1865
1950
|
return this.guard(() => this.outputs.loadOutputFilesBatch(hashes))
|
|
1866
1951
|
}
|
|
1867
|
-
isOutputsCurrent(projectDir: string, expected: readonly OutputFileRow[]):
|
|
1952
|
+
isOutputsCurrent(projectDir: string, expected: readonly OutputFileRow[]): boolean {
|
|
1868
1953
|
return this.outputs.isOutputsCurrent(projectDir, expected)
|
|
1869
1954
|
}
|
|
1870
1955
|
outputsPath(hash: string): string {
|
|
@@ -1895,7 +1980,7 @@ export class Cache implements CacheLayer {
|
|
|
1895
1980
|
loadOutputDirsBatch(hashes: readonly string[]): Map<string, OutputDirRow[]> {
|
|
1896
1981
|
return this.guard(() => this.outputs.loadOutputDirsBatch(hashes))
|
|
1897
1982
|
}
|
|
1898
|
-
outputDirsCurrent(projectDir: string, rows: readonly OutputDirRow[]):
|
|
1983
|
+
outputDirsCurrent(projectDir: string, rows: readonly OutputDirRow[]): boolean {
|
|
1899
1984
|
return this.outputs.outputDirsCurrent(projectDir, rows)
|
|
1900
1985
|
}
|
|
1901
1986
|
async restoreOutputs(
|
|
@@ -1945,9 +2030,9 @@ export class Cache implements CacheLayer {
|
|
|
1945
2030
|
`its outputs hold ${formatBytes(bytes)}, past the ${formatBytes(this.artifactCeiling)} artifact ceiling`,
|
|
1946
2031
|
)
|
|
1947
2032
|
}
|
|
1948
|
-
if (await this.copyTree(tree, rows, files, projectDir, accept)) return
|
|
2033
|
+
if (await this.copyTree(hash, tree, rows, files, projectDir, accept)) return
|
|
1949
2034
|
await extractArtifactStream(
|
|
1950
|
-
packArtifactStream(
|
|
2035
|
+
packArtifactStream(this.treePlan(hash, tree, rows)),
|
|
1951
2036
|
projectDir,
|
|
1952
2037
|
workspaceRoot,
|
|
1953
2038
|
verify,
|
|
@@ -1956,32 +2041,112 @@ export class Cache implements CacheLayer {
|
|
|
1956
2041
|
)
|
|
1957
2042
|
}
|
|
1958
2043
|
|
|
1959
|
-
/**
|
|
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
|
+
|
|
2061
|
+
/**
|
|
2062
|
+
* The copy-out: false, having written nothing, where it is not the case it
|
|
2063
|
+
* serves. It writes only where nothing stands: each file's place is absent,
|
|
2064
|
+
* and every directory on the way either is a real directory or is absent
|
|
2065
|
+
* and made here. solid's build outputs `store/dist/**` beside the sources
|
|
2066
|
+
* in `store/`; asking that each output's TOP be absent sent every such
|
|
2067
|
+
* restore through the tar extractor, ~25 ms a task (2026-10-22).
|
|
2068
|
+
*/
|
|
1960
2069
|
private async copyTree(
|
|
2070
|
+
hash: string,
|
|
1961
2071
|
tree: string,
|
|
1962
2072
|
rows: readonly OutputFileRow[],
|
|
1963
2073
|
files: readonly OutputFileRow[],
|
|
1964
2074
|
projectDir: string,
|
|
1965
2075
|
accept: ((name: string) => boolean) | undefined,
|
|
1966
2076
|
): Promise<boolean> {
|
|
1967
|
-
const tops = new Set<string>()
|
|
1968
2077
|
for (const r of rows) {
|
|
1969
2078
|
if (r.path.startsWith(WORKSPACE_OUTPUT_PREFIX) || r.mode === LINK_MODE) return false
|
|
1970
2079
|
if (accept !== undefined && !r.path.endsWith('/') && !accept(`outputs/${r.path}`))
|
|
1971
2080
|
return false
|
|
1972
|
-
tops.add(r.path.split('/')[0]!)
|
|
1973
2081
|
}
|
|
1974
|
-
|
|
1975
|
-
|
|
1976
|
-
|
|
1977
|
-
|
|
1978
|
-
|
|
1979
|
-
|
|
1980
|
-
|
|
2082
|
+
// What stands at each path, asked once: a real directory, nothing, or
|
|
2083
|
+
// anything else (a file, a link), which the extractor's rules decide.
|
|
2084
|
+
const standing = new Map<string, Promise<'dir' | 'absent' | 'other'>>()
|
|
2085
|
+
const at = (rel: string): Promise<'dir' | 'absent' | 'other'> => {
|
|
2086
|
+
let known = standing.get(rel)
|
|
2087
|
+
if (known === undefined) {
|
|
2088
|
+
known = lstat(path.join(projectDir, rel)).then(
|
|
2089
|
+
(st) => (st.isDirectory() ? 'dir' : 'other'),
|
|
2090
|
+
(err: NodeJS.ErrnoException) => (err.code === 'ENOENT' ? 'absent' : 'other'),
|
|
2091
|
+
)
|
|
2092
|
+
standing.set(rel, known)
|
|
2093
|
+
}
|
|
2094
|
+
return known
|
|
2095
|
+
}
|
|
2096
|
+
// The topmost absent directories this copy makes, and the files it
|
|
2097
|
+
// writes into a directory that stood: what a failure removes, and only that.
|
|
2098
|
+
const made = new Set<string>()
|
|
2099
|
+
const placed: string[] = []
|
|
2100
|
+
const fits = await Promise.all(
|
|
2101
|
+
rows.map(async (r) => {
|
|
2102
|
+
const segs = r.path.replace(/\/$/, '').split('/')
|
|
2103
|
+
for (let i = 1; i <= segs.length; i++) {
|
|
2104
|
+
const rel = segs.slice(0, i).join('/')
|
|
2105
|
+
const isLeaf = i === segs.length
|
|
2106
|
+
const state = await at(rel)
|
|
2107
|
+
if (state === 'absent') {
|
|
2108
|
+
if (!isLeaf || r.path.endsWith('/')) made.add(rel)
|
|
2109
|
+
else placed.push(rel)
|
|
2110
|
+
return true
|
|
2111
|
+
}
|
|
2112
|
+
if (state === 'other' || (isLeaf && !r.path.endsWith('/'))) return false
|
|
2113
|
+
}
|
|
2114
|
+
return true
|
|
2115
|
+
}),
|
|
1981
2116
|
)
|
|
1982
|
-
if (
|
|
2117
|
+
if (fits.includes(false)) return false
|
|
2118
|
+
// A directory made under another one made here is the outer one's.
|
|
2119
|
+
const roots = [...made].filter((d) => ![...made].some((o) => d.startsWith(`${o}/`)))
|
|
1983
2120
|
readUmask()
|
|
1984
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
|
+
}
|
|
1985
2150
|
try {
|
|
1986
2151
|
const dirs = new Set<string>()
|
|
1987
2152
|
for (const r of rows) {
|
|
@@ -1991,18 +2156,32 @@ export class Cache implements CacheLayer {
|
|
|
1991
2156
|
await Promise.all(
|
|
1992
2157
|
files.map(async (r) => {
|
|
1993
2158
|
const to = path.join(projectDir, r.path)
|
|
1994
|
-
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}`)
|
|
1995
2162
|
const mode = restoredMode(r.mode)
|
|
1996
2163
|
if (mode !== plain) await chmod(to, mode)
|
|
1997
2164
|
const at = new Date(r.mtimeMs)
|
|
1998
2165
|
await utimes(to, at, at)
|
|
1999
2166
|
}),
|
|
2000
2167
|
)
|
|
2168
|
+
if (swapped()) throw new ArtifactVanishedError(hash)
|
|
2001
2169
|
} catch (err) {
|
|
2002
|
-
await Promise.all(
|
|
2003
|
-
|
|
2004
|
-
|
|
2170
|
+
await Promise.all([
|
|
2171
|
+
...roots.map((d) => rm(path.join(projectDir, d), { recursive: true, force: true })),
|
|
2172
|
+
...placed.map((f) => rm(path.join(projectDir, f), { force: true })),
|
|
2173
|
+
])
|
|
2174
|
+
if (!(err instanceof ArtifactVanishedError) && swapped())
|
|
2175
|
+
throw new ArtifactVanishedError(hash)
|
|
2005
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
|
+
}
|
|
2006
2185
|
}
|
|
2007
2186
|
return true
|
|
2008
2187
|
}
|
|
@@ -2097,6 +2276,7 @@ export class Cache implements CacheLayer {
|
|
|
2097
2276
|
.filter((r) => workspaceRoot !== undefined || !r.path.startsWith(WORKSPACE_OUTPUT_PREFIX))
|
|
2098
2277
|
.map((r) => (r.path.startsWith(WORKSPACE_OUTPUT_PREFIX) ? r.path : `outputs/${r.path}`))
|
|
2099
2278
|
endRows()
|
|
2279
|
+
const moded = absentModedDirs(rows, projectDir, workspaceRoot)
|
|
2100
2280
|
const verify = (provided: ReadonlySet<string>): void => {
|
|
2101
2281
|
const missing = expected.filter((name) => !provided.has(name))
|
|
2102
2282
|
if (missing.length > 0) {
|
|
@@ -2132,10 +2312,16 @@ export class Cache implements CacheLayer {
|
|
|
2132
2312
|
const tar = await decodedTar(inline ?? Bun.file(src), hash, this.artifactCeiling)
|
|
2133
2313
|
await extractArtifactStream(tar, projectDir, workspaceRoot, verify, accept, workspaceRoot)
|
|
2134
2314
|
}
|
|
2315
|
+
for (const [dir, mode] of moded) await chmod(dir, restoredMode(mode))
|
|
2135
2316
|
} catch (err) {
|
|
2136
2317
|
// A UserError here is the extractor naming the tree's fault (an
|
|
2137
2318
|
// output directory that links out of the project).
|
|
2138
|
-
if (
|
|
2319
|
+
if (
|
|
2320
|
+
err instanceof CorruptArtifactError ||
|
|
2321
|
+
err instanceof UserError ||
|
|
2322
|
+
err instanceof ArtifactVanishedError
|
|
2323
|
+
)
|
|
2324
|
+
throw err
|
|
2139
2325
|
// The save and the ingest proved every name safe, so one that reads
|
|
2140
2326
|
// unsafe now is damaged or tampered bytes: a long name's pax record
|
|
2141
2327
|
// has no header checksum, and the name is judged before the CRC at
|
|
@@ -2214,6 +2400,15 @@ export class Cache implements CacheLayer {
|
|
|
2214
2400
|
if (code === 'ENOENT' && (err as NodeJS.ErrnoException).path === src) {
|
|
2215
2401
|
throw new ArtifactVanishedError(hash)
|
|
2216
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
|
+
}
|
|
2217
2412
|
// A staged file the restore itself just wrote is gone before its
|
|
2218
2413
|
// commit: another process cleaned the same outputs under us (two runs
|
|
2219
2414
|
// on one workspace, a `vx watch` beside a `vx run` — a clean landing
|
|
@@ -2250,7 +2445,7 @@ export class Cache implements CacheLayer {
|
|
|
2250
2445
|
|
|
2251
2446
|
/**
|
|
2252
2447
|
* A run over a cache directory it cannot write: one holding entries
|
|
2253
|
-
* serves its hits and saves nothing (
|
|
2448
|
+
* serves its hits and saves nothing (2026-10-16), and the line this
|
|
2254
2449
|
* returns says so; an empty one has nothing to serve and refuses.
|
|
2255
2450
|
*/
|
|
2256
2451
|
readOnlyNotice(): string | null {
|
|
@@ -2270,6 +2465,7 @@ export class Cache implements CacheLayer {
|
|
|
2270
2465
|
outputFiles: string[]
|
|
2271
2466
|
outputDirs?: string[]
|
|
2272
2467
|
workspaceOutputFiles?: string[]
|
|
2468
|
+
workspaceOutputDirs?: string[]
|
|
2273
2469
|
workspaceRoot?: string
|
|
2274
2470
|
inputComponents?: readonly TaskInputRow[]
|
|
2275
2471
|
/** See `CacheLayer.save`. */
|
|
@@ -2360,12 +2556,17 @@ export class Cache implements CacheLayer {
|
|
|
2360
2556
|
const pinned = this.tempPath(hash)
|
|
2361
2557
|
try {
|
|
2362
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)
|
|
2363
2564
|
} catch (err) {
|
|
2364
2565
|
const tree = this.treePath(hash)
|
|
2365
2566
|
if ((err as NodeJS.ErrnoException).code !== 'ENOENT' || !existsSync(tree)) throw err
|
|
2366
2567
|
// A tree is packed into the artifact a remote expects here, in the
|
|
2367
2568
|
// upload queue, off the save; a small one in memory.
|
|
2368
|
-
const plan =
|
|
2569
|
+
const plan = this.treePlan(hash, tree, this.outputs.rowsOf(hash))
|
|
2369
2570
|
if (plan.size <= STREAM_DECODE_FROM) {
|
|
2370
2571
|
const bytes = await zstdCompress(await packArtifactBytes(plan))
|
|
2371
2572
|
return { body: new Blob([bytes]), release: async () => {} }
|
|
@@ -2463,6 +2664,7 @@ export class Cache implements CacheLayer {
|
|
|
2463
2664
|
outputFiles: string[]
|
|
2464
2665
|
outputDirs?: string[]
|
|
2465
2666
|
workspaceOutputFiles?: string[]
|
|
2667
|
+
workspaceOutputDirs?: string[]
|
|
2466
2668
|
workspaceRoot?: string
|
|
2467
2669
|
}): Map<string, string> {
|
|
2468
2670
|
const outputs = new Map<string, string>()
|
|
@@ -2496,6 +2698,7 @@ export class Cache implements CacheLayer {
|
|
|
2496
2698
|
outputFiles: string[]
|
|
2497
2699
|
outputDirs?: string[]
|
|
2498
2700
|
workspaceOutputFiles?: string[]
|
|
2701
|
+
workspaceOutputDirs?: string[]
|
|
2499
2702
|
workspaceRoot?: string
|
|
2500
2703
|
}): Promise<Uint8Array> {
|
|
2501
2704
|
// stdout is ALWAYS present in the artifact, even if empty, so the
|
|
@@ -2518,9 +2721,15 @@ export class Cache implements CacheLayer {
|
|
|
2518
2721
|
key: args.hash,
|
|
2519
2722
|
stdout: args.entry.stdout ?? '',
|
|
2520
2723
|
outputs: this.outputsOf(args),
|
|
2521
|
-
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
|
+
],
|
|
2522
2730
|
exec: usageOfEntry(args.entry),
|
|
2523
2731
|
root: args.workspaceRoot ?? args.projectDir,
|
|
2732
|
+
projectDir: args.projectDir,
|
|
2524
2733
|
})
|
|
2525
2734
|
if (plan.size > this.artifactCeiling) {
|
|
2526
2735
|
throw new Error(
|
|
@@ -2736,7 +2945,7 @@ export class Cache implements CacheLayer {
|
|
|
2736
2945
|
throw new CorruptArtifactError(hash, 'artifact is not a readable archive', err)
|
|
2737
2946
|
}
|
|
2738
2947
|
const { entries } = scanned
|
|
2739
|
-
|
|
2948
|
+
let totalBytes =
|
|
2740
2949
|
saved !== undefined
|
|
2741
2950
|
? saved.bytes
|
|
2742
2951
|
: compressed instanceof Uint8Array
|
|
@@ -2770,6 +2979,12 @@ export class Cache implements CacheLayer {
|
|
|
2770
2979
|
tmpPath = undefined
|
|
2771
2980
|
tree = dir
|
|
2772
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
|
+
}
|
|
2773
2988
|
const outputFileRows: Array<[string, number, number, number]> = []
|
|
2774
2989
|
// Per-output-file fingerprint rows feed the skip-restore check.
|
|
2775
2990
|
// Row paths: project entries store the bare rel (`outputs/`
|
|
@@ -2795,15 +3010,21 @@ export class Cache implements CacheLayer {
|
|
|
2795
3010
|
outputFileRows.push([rowPath, e.size, e.mode & 0o777, Math.floor(e.mtimeMs)])
|
|
2796
3011
|
}
|
|
2797
3012
|
// An empty directory's row is its rel with a trailing slash: the hit's
|
|
2798
|
-
// check asks only that a directory stands there.
|
|
2799
|
-
|
|
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)
|
|
2800
3019
|
outputFileRows.push([
|
|
2801
3020
|
`${d.startsWith('outputs/') ? d.slice('outputs/'.length) : d}/`,
|
|
2802
3021
|
0,
|
|
2803
|
-
0,
|
|
3022
|
+
mode === undefined ? 0 : DIR_MODE | mode,
|
|
2804
3023
|
0,
|
|
2805
3024
|
])
|
|
2806
3025
|
}
|
|
3026
|
+
for (const d of scanned.dirs) dirRow(d)
|
|
3027
|
+
for (const d of [...dirModes.keys()]) dirRow(d)
|
|
2807
3028
|
// A link's row: its rel, the target's length and CRC-32 where a file
|
|
2808
3029
|
// has its size and mtime, and the link type in the mode, so the hit's
|
|
2809
3030
|
// check compares the target and never follows it.
|
|
@@ -2881,7 +3102,7 @@ export class Cache implements CacheLayer {
|
|
|
2881
3102
|
}
|
|
2882
3103
|
return moved
|
|
2883
3104
|
}
|
|
2884
|
-
const
|
|
3105
|
+
const index = () => {
|
|
2885
3106
|
if (inline !== null) {
|
|
2886
3107
|
aside = moveAside()
|
|
2887
3108
|
upsertBlob.run(hash, now, inline)
|
|
@@ -2927,10 +3148,13 @@ export class Cache implements CacheLayer {
|
|
|
2927
3148
|
if (inputComponents !== undefined && inputComponents.length > 0) {
|
|
2928
3149
|
insertEntryInput.run(hash, encodeEntryInputs(inputComponents))
|
|
2929
3150
|
}
|
|
2930
|
-
}
|
|
3151
|
+
}
|
|
2931
3152
|
const endTx = span('save: index tx')
|
|
2932
3153
|
try {
|
|
2933
|
-
|
|
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()
|
|
2934
3158
|
} catch (err) {
|
|
2935
3159
|
endTx()
|
|
2936
3160
|
const left = renamed ?? tree ?? tmpPath
|
|
@@ -3013,6 +3237,7 @@ export class Cache implements CacheLayer {
|
|
|
3013
3237
|
return this.guard(() => this.history.recordRunBundle(bundle))
|
|
3014
3238
|
}
|
|
3015
3239
|
recordRemoteErrors(runId: string, errors: readonly (readonly [string, string])[]): void {
|
|
3240
|
+
if (this.writeBlocked !== null) return
|
|
3016
3241
|
return this.guard(() => this.history.recordRemoteErrors(runId, errors))
|
|
3017
3242
|
}
|
|
3018
3243
|
stats(opts: CacheStatsOptions = {}): CacheStats {
|
|
@@ -3060,7 +3285,7 @@ export class Cache implements CacheLayer {
|
|
|
3060
3285
|
// orphan rows in either order: landed first, the cascade takes them;
|
|
3061
3286
|
// landed after, the flush's own entry check drops them (item 628).
|
|
3062
3287
|
this.outputs.flushOutputDirs()
|
|
3063
|
-
const { olderThanMs, maxBytes,
|
|
3288
|
+
const { olderThanMs, maxBytes, dry = false } = options
|
|
3064
3289
|
if (olderThanMs === undefined && maxBytes === undefined) {
|
|
3065
3290
|
throw new Error('prune: pass at least one of `olderThanMs` or `maxBytes`')
|
|
3066
3291
|
}
|
|
@@ -3090,7 +3315,7 @@ export class Cache implements CacheLayer {
|
|
|
3090
3315
|
// Promise.all over the unlinks. The hash list binds as one `json_each`
|
|
3091
3316
|
// parameter, so a huge eviction stays under any build's bound-parameter
|
|
3092
3317
|
// ceiling.
|
|
3093
|
-
if (
|
|
3318
|
+
if (dry) {
|
|
3094
3319
|
return { evicted: victims.size, bytesFreed, orphans: picked.files.length, orphanBytes }
|
|
3095
3320
|
}
|
|
3096
3321
|
if (victims.size > 0 || stale.length > 0) {
|
|
@@ -3131,17 +3356,35 @@ export class Cache implements CacheLayer {
|
|
|
3131
3356
|
if (!isIndexFull(err)) throw err
|
|
3132
3357
|
}
|
|
3133
3358
|
// History and memo rows a month old go with a prune, never a run's
|
|
3134
|
-
// close: five DELETEs on every run's exit was its slowest step
|
|
3135
|
-
// 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`
|
|
3136
3361
|
// would list a run whose task rows are gone.
|
|
3137
3362
|
const cutoff = now - HISTORY_RETENTION_MS
|
|
3138
3363
|
this.history.pruneOlderThan(cutoff)
|
|
3139
3364
|
this.files.pruneOlderThan(cutoff)
|
|
3140
3365
|
this.folds.prune(cutoff)
|
|
3141
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()
|
|
3142
3370
|
return { evicted: victims.size, bytesFreed, ...orphans }
|
|
3143
3371
|
}
|
|
3144
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
|
+
|
|
3145
3388
|
/**
|
|
3146
3389
|
* Unlink the row-less files `pickVictims` took: a `<hash>.tar.zst` with
|
|
3147
3390
|
* no `entries` row (another vx version's open dropped it, a `cache.db`
|
|
@@ -3320,11 +3563,7 @@ export class Cache implements CacheLayer {
|
|
|
3320
3563
|
}
|
|
3321
3564
|
}
|
|
3322
3565
|
|
|
3323
|
-
/**
|
|
3324
|
-
* SQLite's file-control opcode (sqlite3.h). Spelled here, not imported from
|
|
3325
|
-
* `bun:sqlite`'s `constants`: the playground bundles this file against a
|
|
3326
|
-
* shim that exports only `Database`.
|
|
3327
|
-
*/
|
|
3566
|
+
/** SQLite's file-control opcode (sqlite3.h). */
|
|
3328
3567
|
const SQLITE_FCNTL_PERSIST_WAL = 10
|
|
3329
3568
|
|
|
3330
3569
|
/**
|
|
@@ -3390,6 +3629,32 @@ function assertArtifactNames(
|
|
|
3390
3629
|
}
|
|
3391
3630
|
}
|
|
3392
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
|
+
|
|
3393
3658
|
/** A row's path as the tree names its file. */
|
|
3394
3659
|
function treeName(rowPath: string): string {
|
|
3395
3660
|
return rowPath.startsWith(WORKSPACE_OUTPUT_PREFIX) ? rowPath : `outputs/${rowPath}`
|