@vzn/vx 0.0.16 → 0.0.21

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.
Files changed (138) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +135 -124
  3. package/index.ts +10 -0
  4. package/launcher.mjs +10 -11
  5. package/package.json +11 -7
  6. package/src/bin.ts +14 -4
  7. package/src/cache/archive.ts +659 -0
  8. package/src/cache/cache.ts +769 -1101
  9. package/src/cache/chained-cache.ts +194 -0
  10. package/src/cache/config-evals.ts +89 -0
  11. package/src/cache/file-hashes.ts +262 -0
  12. package/src/cache/git-inputs.ts +637 -0
  13. package/src/cache/index.ts +24 -5
  14. package/src/cache/inputs.ts +373 -456
  15. package/src/cache/layer.ts +604 -0
  16. package/src/cache/layered-cache.ts +96 -43
  17. package/src/cache/output-index.ts +224 -0
  18. package/src/cache/policy.ts +84 -0
  19. package/src/cache/run-history.ts +147 -0
  20. package/src/cache/tar-stream.ts +358 -0
  21. package/src/cache/zstd.ts +160 -0
  22. package/src/cli/cache.ts +75 -21
  23. package/src/cli/completions.ts +104 -0
  24. package/src/cli/core-alias.ts +25 -0
  25. package/src/cli/help.ts +195 -128
  26. package/src/cli/index.ts +116 -46
  27. package/src/cli/info.ts +122 -63
  28. package/src/cli/init.ts +75 -0
  29. package/src/cli/last.ts +172 -0
  30. package/src/cli/lock.ts +2 -1
  31. package/src/cli/plan-format.ts +72 -6
  32. package/src/cli/plugin-commands.ts +103 -0
  33. package/src/cli/run.ts +245 -271
  34. package/src/cli/select.ts +332 -0
  35. package/src/cli/show.ts +144 -69
  36. package/src/cli/upgrade.ts +9 -7
  37. package/src/cli/watch.ts +858 -110
  38. package/src/cli/why.ts +223 -0
  39. package/src/cli/workspace-config.ts +126 -0
  40. package/src/config.ts +187 -150
  41. package/src/exec/env.ts +18 -1
  42. package/src/exec/executor.ts +224 -0
  43. package/src/exec/index.ts +24 -2
  44. package/src/exec/local-executor.ts +41 -0
  45. package/src/exec/runner.ts +166 -40
  46. package/src/exec/sandbox-binds.ts +182 -0
  47. package/src/exec/sandbox-paths.ts +50 -0
  48. package/src/exec/sandbox-runtime.ts +642 -260
  49. package/src/exec/sandbox-violations.ts +280 -0
  50. package/src/graph/index.ts +4 -10
  51. package/src/graph/scheduler.ts +232 -203
  52. package/src/graph/task-graph.ts +222 -8
  53. package/src/index.ts +146 -153
  54. package/src/orchestrator/admission.ts +137 -0
  55. package/src/orchestrator/deferred-outputs.ts +147 -0
  56. package/src/orchestrator/doctor.ts +255 -0
  57. package/src/orchestrator/download-policy.ts +171 -0
  58. package/src/orchestrator/events.ts +72 -8
  59. package/src/orchestrator/execute-task.ts +374 -549
  60. package/src/orchestrator/failure-mode.ts +202 -0
  61. package/src/orchestrator/framed-output.ts +126 -69
  62. package/src/orchestrator/history.ts +126 -59
  63. package/src/orchestrator/hit-restore.ts +221 -0
  64. package/src/orchestrator/index.ts +77 -93
  65. package/src/orchestrator/local-shortcircuit.ts +22 -0
  66. package/src/orchestrator/lockfile-claim.ts +281 -0
  67. package/src/orchestrator/logger.ts +208 -35
  68. package/src/orchestrator/metrics.ts +102 -1388
  69. package/src/orchestrator/miss-save.ts +169 -0
  70. package/src/orchestrator/options.ts +55 -34
  71. package/src/orchestrator/persistent.ts +79 -0
  72. package/src/orchestrator/placement.ts +182 -0
  73. package/src/orchestrator/plan.ts +144 -1
  74. package/src/orchestrator/plugin-host.ts +357 -120
  75. package/src/orchestrator/plugin.ts +298 -58
  76. package/src/orchestrator/prepare.ts +202 -106
  77. package/src/orchestrator/projects.ts +259 -0
  78. package/src/orchestrator/remote-prefetch.ts +12 -8
  79. package/src/orchestrator/run-artifacts.ts +63 -16
  80. package/src/orchestrator/run-context.ts +167 -29
  81. package/src/orchestrator/run-records.ts +149 -0
  82. package/src/orchestrator/run-report.ts +74 -47
  83. package/src/orchestrator/run.ts +534 -417
  84. package/src/orchestrator/sandbox-request.ts +243 -0
  85. package/src/orchestrator/save-lane.ts +47 -0
  86. package/src/orchestrator/signals.ts +111 -0
  87. package/src/orchestrator/stable-keys.ts +129 -31
  88. package/src/orchestrator/status-line.ts +49 -9
  89. package/src/orchestrator/summary.ts +90 -26
  90. package/src/orchestrator/tally.ts +78 -35
  91. package/src/orchestrator/task-hash.ts +111 -15
  92. package/src/orchestrator/task-log-buffer.ts +288 -0
  93. package/src/orchestrator/telemetry-host.ts +34 -6
  94. package/src/orchestrator/telemetry.ts +182 -36
  95. package/src/orchestrator/upstream.ts +72 -31
  96. package/src/util/cgroup.ts +160 -0
  97. package/src/util/edit-distance.ts +70 -0
  98. package/src/util/errors.ts +15 -1
  99. package/src/util/index.ts +15 -2
  100. package/src/util/num.ts +37 -0
  101. package/src/util/paths.ts +64 -0
  102. package/src/util/settle.ts +67 -0
  103. package/src/util/size.ts +5 -1
  104. package/src/util/tail.ts +66 -0
  105. package/src/util/timing.ts +63 -0
  106. package/src/util/verbs.ts +39 -0
  107. package/src/workspace/affected.ts +250 -23
  108. package/src/workspace/config-cache.ts +313 -0
  109. package/src/workspace/config-eval.ts +217 -0
  110. package/src/workspace/config-imports.ts +162 -0
  111. package/src/workspace/config-schema.ts +837 -0
  112. package/src/workspace/filter.ts +65 -10
  113. package/src/workspace/fingerprint.ts +53 -4
  114. package/src/workspace/index.ts +42 -3
  115. package/src/workspace/lockfile.ts +25 -9
  116. package/src/workspace/migrate-scripts.ts +134 -0
  117. package/src/workspace/migration.ts +314 -0
  118. package/src/workspace/nested-dirs.ts +21 -7
  119. package/src/workspace/package-graph.ts +114 -41
  120. package/src/workspace/project-loader.ts +224 -450
  121. package/src/workspace/workspace.ts +298 -72
  122. package/src/cache/cas-backend.ts +0 -97
  123. package/src/cache/digest.ts +0 -47
  124. package/src/cache/tar.ts +0 -343
  125. package/src/cli/backend.ts +0 -23
  126. package/src/cli/mcp-rpc.ts +0 -292
  127. package/src/cli/mcp.ts +0 -92
  128. package/src/cli/migrate-nx.ts +0 -450
  129. package/src/cli/migrate-turbo.ts +0 -364
  130. package/src/cli/migrate.ts +0 -219
  131. package/src/orchestrator/devframe-surface.ts +0 -74
  132. package/src/orchestrator/predict.ts +0 -106
  133. package/src/orchestrator/protocol.ts +0 -156
  134. package/src/orchestrator/resources.ts +0 -59
  135. package/src/orchestrator/run-state.ts +0 -145
  136. package/src/orchestrator/verify.ts +0 -164
  137. package/src/orchestrator/wire-render.ts +0 -92
  138. package/src/orchestrator/wire.ts +0 -212
@@ -20,23 +20,48 @@
20
20
  // for the user to see — but on a cache hit there's nothing to replay
21
21
  // (the original run was successful and stderr typically empty).
22
22
  //
23
- // Replace this module to plug in remote storage. The contract is:
24
- // key() : derive a stable hash from a task's identity + inputs
25
- // get(hash, ctx?) : retrieve a previous run's metadata, or null
26
- // restoreOutputs : extract the artifact's outputs/ into the project dir
27
- // save : persist outputs + stdout under a hash
28
- // ingest : adopt an artifact produced elsewhere (remote-hit path)
29
- // recordRun : append a row to the run history table (for stats)
30
- // close : release the SQLite handle
23
+ // This is core's FLOOR, not a module to replace: remote storage is a
24
+ // `RemoteCacheLayer` (has / get / put) that `LayeredCache` wraps around
25
+ // this handle, declared by a plugin's `cache` hook. The contract every
26
+ // layer speaks is `CacheLayer` in layer.ts; `plugin-host.ts` enforces it.
31
27
 
32
28
  import { Database, type SQLQueryBindings } from 'bun:sqlite'
33
- import { mkdirSync, statSync } from 'node:fs'
34
- import { mkdir, mkdtemp, rename, rm, stat, utimes } from 'node:fs/promises'
35
- import os from 'node:os'
29
+ import { existsSync, mkdirSync, writeFileSync } from 'node:fs'
30
+ import { mkdir, readdir, rename, rm, stat, unlink, writeFile } from 'node:fs/promises'
36
31
  import path from 'node:path'
37
- import { relPosix, UserError, xxh3, xxh3hex } from '../util/index.js'
38
- import { FsCASBackend } from './cas-backend.js'
39
- import { extractOutputs, parseTarHeaders, readTarText } from './tar.js'
32
+ import { relPosix, UserError, xxh3, xxh3hex, span } from '../util/index.js'
33
+ import {
34
+ ArchiveSecurityError,
35
+ type ExecUsage,
36
+ extractArtifactStream,
37
+ scanArtifact,
38
+ packArtifactBytes,
39
+ packArtifactStream,
40
+ planArtifact,
41
+ } from './archive.js'
42
+ import {
43
+ type CacheEntry,
44
+ type CacheGetContext,
45
+ type CacheKeyInput,
46
+ type CacheLayer,
47
+ type CacheStats,
48
+ type CacheStatsOptions,
49
+ CorruptArtifactError,
50
+ type IngestMeta,
51
+ type InvocationRecord,
52
+ type OutputDirRow,
53
+ type OutputFileRow,
54
+ type PruneOptions,
55
+ type PruneResult,
56
+ type RunRecord,
57
+ type SaveArgs,
58
+ type TaskInputRow,
59
+ } from './layer.js'
60
+ import { bytesOf, decodedTar, STREAM_DECODE_FROM, zstdEncoder } from './zstd.js'
61
+ import { ConfigEvalTable } from './config-evals.js'
62
+ import { FileHashStore } from './file-hashes.js'
63
+ import { OutputIndex } from './output-index.js'
64
+ import { RunHistory } from './run-history.js'
40
65
 
41
66
  // v17: artifact carries only logs + outputs (stdout + outputs/<rel>).
42
67
  // Local and remote layers transport the SAME tar.zst bytes — no
@@ -75,7 +100,51 @@ import { extractOutputs, parseTarHeaders, readTarText } from './tar.js'
75
100
  // inside the key fold (the new `captureInto` sink is a pure
76
101
  // side-channel). A task's hash is byte-identical before and after, so
77
102
  // existing artifacts stay valid and there is no cache-version bump.
78
- const CACHE_VERSION = 'vx-cache-v24'
103
+ // v25: the ARTIFACT BYTES are wrong in every existing entry, while the
104
+ // key that addresses them is unchanged — the one situation a version
105
+ // bump exists for. Two defects: (a) `packArtifact` staged outputs with
106
+ // `Bun.write`, which does not carry the source mode, so every artifact
107
+ // records 0644 and a cache hit strips the executable bit off any binary
108
+ // or generated script; (b) it packed `--format=ustar`, which splits a
109
+ // name over 100 bytes into prefix+name, and the reader read only `name`
110
+ // — so those entries lost their `outputs/` prefix and were silently
111
+ // dropped from both the restore and the output_files index. Neither is
112
+ // self-healing: the stored bytes/headers are already wrong, so without a
113
+ // new namespace the fixed code would keep replaying them forever.
114
+ // v26: same situation, different producer. A task whose child was killed by
115
+ // a shutdown signal reported `aborted`, but `aborted` did not propagate to
116
+ // dependents — so a dependent ran against PARTIAL outputs, succeeded, and
117
+ // cached what it built. The fold takes the upstream's INPUT key, which a
118
+ // signal does not change, so that entry sits under exactly the key a healthy
119
+ // run derives and replays forever as a green hit. Fixing the propagation
120
+ // stops new poison but cannot reach entries already written, and a
121
+ // `LayeredCache` uploads them, so the reach is a whole team's shared cache
122
+ // rather than one developer's disk. Pre-alpha, so one cold rebuild is the
123
+ // cheap side of that trade.
124
+ export const CACHE_VERSION = 'vx-cache-v27'
125
+
126
+ /**
127
+ * An artifact or temp file without an `entries` row is reaped by
128
+ * `prune()` only once it is this old. A save renames the artifact into
129
+ * place and commits its row in the same tick; a crashed save's temp is
130
+ * stale long before this. Generous on purpose — a false orphan costs a
131
+ * re-run, a leaked temp costs disk.
132
+ */
133
+ const ORPHAN_GRACE_MS = 60 * 60 * 1000
134
+
135
+ export interface SchemaReset {
136
+ from: string
137
+ to: string
138
+ }
139
+
140
+ /** Say once, on the channel the opener has, that an upgrade emptied the index. */
141
+ export function noteSchemaReset(cache: Cache, warn: (message: string) => void): void {
142
+ if (cache.schemaReset === null) return
143
+ const { from, to } = cache.schemaReset
144
+ warn(
145
+ `[vx] cache index reset: schema ${from} → ${to} (vx upgraded); every cached task misses once and re-saves, and \`vx cache prune\` reclaims the old artifacts`,
146
+ )
147
+ }
79
148
  // SCHEMA history (drop+recreate on mismatch; pre-alpha, no migrations):
80
149
  // v20: file_hashes.content_hash (git blob OIDs).
81
150
  // v21: dropped the unused outputs_hash column (pure-input hashing).
@@ -94,578 +163,74 @@ const CACHE_VERSION = 'vx-cache-v24'
94
163
  // passed under identical inputs is nondeterministic by definition;
95
164
  // no cross-run inference needed). Nullable; NULL for a once-run
96
165
  // task. Analytics-only — the cache KEY is unchanged.
97
- const SCHEMA_VERSION = 'v23'
166
+ // v24: file_hashes.ctime_ms + .ino — the stat memo's guard against a
167
+ // content change that PRESERVES mtime (`tar -x`, `cp -p`,
168
+ // `rsync --times`, SOURCE_DATE_EPOCH). (mtime, size) alone
169
+ // returned the previous run's digest for different bytes, i.e. a
170
+ // stale cache hit. `utimes` cannot suppress ctime unprivileged
171
+ // and an atomic write-then-rename changes the inode, so the two
172
+ // together close it; git's index keys on ctime+ino+dev for the
173
+ // same reason. Both come free from the stat already taken. The
174
+ // cache KEY derivation is unchanged (CACHE_VERSION not bumped) —
175
+ // the memo simply stops answering wrongly, so an affected task's
176
+ // key moves from a WRONG value to the right one: it misses once,
177
+ // re-runs, re-caches. Self-healing, never a wrong hit.
178
+ // v25: runs.cached — whether the task declared a `cache` block. An
179
+ // uncached task's row looked like any executed miss, so `vx why`
180
+ // headlined "cache key changed (inputs differ)" for a task that
181
+ // runs every time by design, and `vx last` could not mark it the
182
+ // way the terminal summary does (`no-cache`). Analytics-only —
183
+ // the cache KEY is unchanged.
184
+ // v26: entries.cpu_ms / peak_rss_bytes — what the producing execution
185
+ // used, read out of the artifact's sidecar at save and ingest
186
+ // alike, so a hit (a remote one on a fresh runner above all) can
187
+ // tell the history what the task needs. The cache KEY and the
188
+ // artifact container are unchanged (no CACHE_VERSION bump: an
189
+ // artifact without the field reads as before).
190
+ export const SCHEMA_VERSION = 'v26'
98
191
 
99
192
  /**
100
- * Artifact + `output_files` namespace prefix for workspace-root-
101
- * anchored outputs (`cache.outputs.workspaceFiles`). Project outputs
102
- * keep their bare project-relative `path` rows; workspace rows store
103
- * the full `workspace-outputs/<rel-to-root>` tar entry name as the
104
- * discriminator — least-invasive row format, no schema change. A
105
- * project output dir literally named `workspace-outputs/` would
106
- * collide with the namespace; the name is reserved.
107
- */
108
- export const WORKSPACE_OUTPUT_PREFIX = 'workspace-outputs/'
109
-
110
- /**
111
- * Independent read/write control over the two cache layers (local +
112
- * remote). Replaces the old single `noCache` boolean: each axis can be
113
- * toggled on its own so `--force` (re-execute but still refresh the
114
- * cache) is distinct from `--no-cache` (disable everything).
193
+ * SQL predicate selecting `runs` rows that record an EXECUTION.
115
194
  *
116
- * Only the task-artifact get/save path is gated. `recordRun`, `stats`,
117
- * `prune`, key derivation, and prefetch-ingest are never affected — they
118
- * are bookkeeping/analytics that a run policy has no business disabling.
119
- */
120
- export interface CachePolicy {
121
- localRead: boolean
122
- localWrite: boolean
123
- remoteRead: boolean
124
- remoteWrite: boolean
125
- }
126
-
127
- /** All four axes on — the default when no cache flag is passed. */
128
- export const FULL_CACHE_POLICY: CachePolicy = {
129
- localRead: true,
130
- localWrite: true,
131
- remoteRead: true,
132
- remoteWrite: true,
133
- }
134
-
135
- /**
136
- * Parse a `--cache=<spec>` value into a `CachePolicy`, starting from a
137
- * base (defaults to {@link FULL_CACHE_POLICY}). The spec is a
138
- * comma-separated list of `layer:flags` segments where `layer` is
139
- * `local` or `remote` and `flags` is any subset of `r` (read) and `w`
140
- * (write), order-independent and possibly empty.
141
- *
142
- * A mentioned layer is set EXACTLY to its flags (read = includes `r`,
143
- * write = includes `w`); an unmentioned layer keeps its base value.
144
- * So `local:rw,remote:r` = remote read-only, `remote:` = remote fully
145
- * off, `local:r` = local read-only with remote untouched.
195
+ * Every non-group, non-aborted outcome of a run gets a row, so the header's
196
+ * `task_count` matches `COUNT(*)` and the run-detail timeline is complete.
197
+ * But a `skipped` row is a task the run never executed — its upstream failed,
198
+ * so it has no exit of its own, no duration, and made no cache decision.
199
+ * Counting it in a RATE or a MEAN dilutes that figure with a non-event: a
200
+ * task skipped as often as it succeeds reads 50% success, and a zero-duration
201
+ * row drags every average toward zero.
146
202
  *
147
- * Throws a {@link UserError} on an unknown layer, an unknown flag, a
148
- * duplicated flag, a missing colon, or a repeated layer.
203
+ * Lives here, beside the schema, because the same figure is computed in more
204
+ * than one place (`Cache.stats` and the run-history queries both answer
205
+ * "runs in the last 24h") and two copies of a rule are how they drift apart.
149
206
  */
150
- export function parseCachePolicy(spec: string, base: CachePolicy = FULL_CACHE_POLICY): CachePolicy {
151
- const out: CachePolicy = { ...base }
152
- const seen = new Set<string>()
153
- for (const rawSeg of spec.split(',')) {
154
- const seg = rawSeg.trim()
155
- if (seg.length === 0) continue
156
- const colon = seg.indexOf(':')
157
- if (colon < 0) {
158
- throw new UserError(`invalid --cache segment '${seg}': expected '<layer>:<flags>'`)
159
- }
160
- const layer = seg.slice(0, colon)
161
- const flags = seg.slice(colon + 1)
162
- if (layer !== 'local' && layer !== 'remote') {
163
- throw new UserError(`invalid --cache layer '${layer}': expected 'local' or 'remote'`)
164
- }
165
- if (seen.has(layer)) {
166
- throw new UserError(`--cache layer '${layer}' specified twice`)
167
- }
168
- seen.add(layer)
169
- const flagSet = new Set<string>()
170
- for (const ch of flags) {
171
- if (ch !== 'r' && ch !== 'w') {
172
- throw new UserError(`invalid --cache flag '${ch}' for '${layer}': expected 'r' and/or 'w'`)
173
- }
174
- if (flagSet.has(ch)) {
175
- throw new UserError(`--cache flag '${ch}' repeated for '${layer}'`)
176
- }
177
- flagSet.add(ch)
178
- }
179
- if (layer === 'local') {
180
- out.localRead = flagSet.has('r')
181
- out.localWrite = flagSet.has('w')
182
- } else {
183
- out.remoteRead = flagSet.has('r')
184
- out.remoteWrite = flagSet.has('w')
185
- }
186
- }
187
- return out
188
- }
189
-
190
- export interface CacheKeyInput {
191
- taskId: string
192
- /**
193
- * Hash of the resolved task config (post-evaluation). Folds in everything
194
- * the user wrote — command, env declarations (passThrough names + define
195
- * key/value pairs), dependsOn, cache.inputs declarations, outputs — including
196
- * values that arrived via `import` at config-load time.
197
- */
198
- taskConfigHash: string
199
- /**
200
- * Runtime values of declared cache-input env names (from parent at hash
201
- * time). Independent of `exec.env`; lives here for cache identity.
202
- */
203
- envValues: Array<[name: string, value: string]>
204
- /**
205
- * Resolved `cache.inputs.runtime` commands as [command, output] pairs
206
- * (output = trimmed stdout+stderr, resolved live at hash time). Folded
207
- * into the key in a namespace distinct from workspaceRuntimeValues.
208
- */
209
- runtimeValues?: Array<[command: string, output: string]>
210
- /** Resolved `cache.inputs.workspaceRuntime` pairs (root-cwd commands). */
211
- workspaceRuntimeValues?: Array<[command: string, output: string]>
212
- /** Absolute paths to input files. */
213
- inputFiles: string[]
214
- workspaceRoot: string
215
- /** Cache keys of upstream tasks this one depends on, sorted. */
216
- upstreamHashes: string[]
217
- /**
218
- * Upstream hash → upstream task id, for `captureInto` row NAMING only.
219
- * Never folded into the digest — the key already folds the sorted
220
- * hashes. Lets the persisted `entry_inputs` row name which upstream
221
- * task a hash came from (the diff reads better than a bare hash). When
222
- * absent, the captured `upstream` row falls back to `name = hash`.
223
- */
224
- upstreamIds?: ReadonlyMap<string, string>
225
- /**
226
- * Workspace-level fingerprint — typically a hash of `pnpm-lock.yaml` +
227
- * `pnpm-workspace.yaml`. Folds resolved dep versions and workspace shape
228
- * into every task's key, so a lockfile bump invalidates everything.
229
- */
230
- workspaceFingerprint: string
231
- /**
232
- * CLI args forwarded to the task (after `--`). Folded into the key so that
233
- * the same command with different forwarded args is treated as a distinct
234
- * run, never a spurious cache hit.
235
- */
236
- forwardArgs?: readonly string[]
237
- /**
238
- * Hash of the project's `package.json` bytes. Folded into the key
239
- * implicitly (Turbo / Nx parity) so dep changes invalidate every
240
- * task in that project, even when `cache.inputs.files` doesn't
241
- * cover package.json. Empty string when the project has no
242
- * package.json (impossible in practice — workspace discovery
243
- * requires one — but we don't fail-loud here).
244
- */
245
- projectPackageJsonHash: string
246
- /**
247
- * Precomputed content hashes (git blob OIDs) keyed by absolute
248
- * path — typically the trusted-index OID map harvested by the
249
- * run's bulk `git ls-files -s`. Paths present here skip `hashFile`
250
- * entirely (no stat, no SQLite, no read); missing paths fall back
251
- * to `hashFile`, which computes the byte-identical blob OID from
252
- * disk. Pure fast path: the derived key never depends on whether a
253
- * hash arrived via the map or the fallback.
254
- */
255
- fileHashes?: ReadonlyMap<string, string>
256
- /**
257
- * When set, `key()` pushes each component (kind, name, hash) it folds
258
- * — at the same fold sites, in fold order. Pure SIDE-CHANNEL: it does
259
- * not change the returned digest in any way. On a cache MISS the
260
- * orchestrator allocates an array, passes it here, and persists the
261
- * rows to `entry_inputs` (inside the entry-save transaction) so a
262
- * later run can diff its inputs against this one (the Tier-3 "why did
263
- * this re-run?" moat). Capturing at the fold sites keeps the
264
- * recorded set in lockstep with the key by construction: a future
265
- * component that forgets to capture is a one-line miss, not silent
266
- * drift. The per-file OIDs are already awaited here, so file rows cost
267
- * zero extra I/O — just array pushes.
268
- */
269
- captureInto?: Array<{ kind: string; name: string; hash: string }>
270
- }
271
-
272
- export interface CacheEntry {
273
- hash: string
274
- taskId: string
275
- command: string
276
- exitCode: number
277
- durationMs: number
278
- outputFiles: string[]
279
- /** Captured stdout, always present (may be empty). stderr is not cached. */
280
- stdout: string
281
- storedAt: string
282
- /**
283
- * Where this hit was resolved from. `'local'` for a SQLite-backed
284
- * Cache; `'remote'` when LayeredCache pulled the artifact from the
285
- * remote layer this lookup (even though it's been materialized into
286
- * local for next time). Lets the orchestrator surface
287
- * `cache-hit-remote` so users see when remote caching actually saved
288
- * them work vs. a stale-local replay.
289
- */
290
- source?: 'local' | 'remote'
291
- }
292
-
293
- export interface RunRecord {
294
- hash: string
295
- project: string
296
- task: string
297
- status: 'success' | 'failed' | 'cache-hit' | 'cache-hit-remote' | 'skipped'
298
- exitCode: number
299
- durationMs: number
300
- forwardArgs?: readonly string[]
301
- startedAt: number // ms-epoch wall clock
302
- endedAt: number // ms-epoch wall clock
303
- /**
304
- * Optional analytics columns. Populated by the orchestrator/runner;
305
- * stored as NULL on rows from older runs. Surfaced via `vx stats`
306
- * and consumable from CI by reading cache.db directly.
307
- */
308
- runId?: string // ULID shared across every task in one `vx run` invocation
309
- cpuMs?: number // sum of user + system CPU time for the child process
310
- peakRssBytes?: number // peak resident set size of the child process
311
- wallclockStartNs?: bigint // hrtime span relative to run t=0
312
- wallclockEndNs?: bigint
313
- cacheHit?: boolean // convenience for flamegraph color; derivable from status
314
- attempts?: number // >1 when the task retried; the direct within-run flaky signal
315
- }
316
-
317
- /**
318
- * One header row per `vx run` invocation (the `invocations` table). All
319
- * fields mirror the columns; nullable VCS/host columns are `null` when
320
- * the probe failed (not a git repo, hostname unavailable). Recorded
321
- * once per run inside the same transaction as the per-task `runs` rows.
322
- */
323
- export interface InvocationRecord {
324
- runId: string
325
- command: string
326
- /** JSON-serialized `string[]` of the requested task names. */
327
- requestedTasks: string
328
- /** Compact policy flags, e.g. `'lR,lW,rR,rW'` (an axis omitted = off). */
329
- cachePolicy: string
330
- concurrency: number
331
- flow: 'focused' | 'broad' | null
332
- startedAt: number
333
- endedAt: number
334
- totalDurationMs: number
335
- taskCount: number
336
- failedCount: number
337
- hitCount: number
338
- hitLocalCount: number
339
- hitRemoteCount: number
340
- exitOk: boolean
341
- commitSha: string | null
342
- branch: string | null
343
- dirty: boolean | null
344
- ci: boolean
345
- ciProvider: string | null
346
- host: string | null
347
- os: string | null
348
- arch: string | null
349
- vxVersion: string
350
- /** JSON object `{k:v}` of `--tag` pairs. */
351
- tags: string
352
- }
353
-
354
- /**
355
- * One cache-key component row for the `entry_inputs` table — keyed by
356
- * the cache-entry HASH it belongs to, not a run. Written inside the
357
- * entry-save transaction (`writeArtifactAndIndex`) on a miss/save; a
358
- * cache HIT does not save, so it persists nothing (warm runs are free).
359
- * The diff (Phase B/B1) reads these by the run's task hash
360
- * (`runs.hash → entry_inputs[entry_hash]`).
361
- */
362
- export interface TaskInputRow {
363
- entryHash: string
364
- kind: string
365
- name: string
366
- hash: string
367
- }
368
-
369
- export interface CacheStats {
370
- entryCount: number
371
- totalBytes: number
372
- runCountLast24h: number
373
- hitCountLast24h: number
374
- }
375
-
376
- export interface PruneOptions {
377
- /** Drop entries last accessed before this ms-epoch threshold. */
378
- olderThanMs?: number
379
- /**
380
- * After applying olderThanMs, if the cache still exceeds this size in
381
- * bytes, evict LRU (smallest `accessed_at` first) until under it.
382
- */
383
- maxBytes?: number
384
- }
385
-
386
- export interface PruneResult {
387
- evicted: number
388
- bytesFreed: number
389
- }
207
+ export const EXECUTED_RUNS_SQL = "status <> 'skipped'"
390
208
 
391
209
  /**
392
- * Per-output-file fingerprint, scoped by the cache entry that
393
- * produced it. Batch-loaded once at the top of a run via
394
- * `loadOutputFilesBatch(hashes)` so the orchestrator's "is this
395
- * tree already current?" probe becomes an in-memory Map lookup
396
- * plus N parallel stat calls.
397
- *
398
- * `path` is project-relative (e.g. `dist/index.js`), matching how
399
- * outputs are addressed under `<projectDir>/` — except workspace
400
- * outputs, which carry the full `workspace-outputs/<rel-to-root>`
401
- * tar entry name (see `WORKSPACE_OUTPUT_PREFIX`); callers split on
402
- * the prefix and anchor those at the workspace root.
210
+ * SQL predicate selecting `runs` rows that recorded a cache key. `hash` is
211
+ * `''` for an outcome that never derived one (see {@link RunRecord.hash}),
212
+ * and `''` is not a key: it can neither corroborate flakiness nor say the
213
+ * inputs changed. Mirrors the `hash <> ''` guards in the cloud analytics copy.
403
214
  */
404
- export interface OutputFileRow {
405
- path: string
406
- size: number
407
- mode: number
408
- mtimeMs: number
409
- }
215
+ export const KEYED_RUNS_SQL = "hash <> ''"
410
216
 
411
217
  /**
412
- * The shape every cache implementation honors. `Cache` (the local v10
413
- * implementation) and `LayeredCache` both `implements` this so the
414
- * orchestrator's `executeTask` can take either without a discriminated
415
- * union and we get a compile-time guarantee the surfaces stay congruent.
416
- */
417
- /**
418
- * Context passed to `get()`. Optional, but required when the lookup
419
- * may resolve through the remote layer — the local SQL row inserted on
420
- * remote-hit needs `taskId` + `command` to be queryable later (the
421
- * artifact itself doesn't carry them). `Cache` (local) ignores this
422
- * field; `LayeredCache` forwards it to `Cache.ingest`.
423
- */
424
- export interface CacheGetContext {
425
- taskId: string
426
- command: string
427
- }
428
-
429
- /** Metadata supplied at ingest time — values the artifact does not carry. */
430
- export interface IngestMeta {
431
- taskId: string
432
- command: string
433
- /** Wall-clock time of the original task execution. */
434
- durationMs: number
435
- /**
436
- * Cache-key components (Tier-3 input fingerprint) to persist into
437
- * `entry_inputs` in the same transaction as the entry row. Omitted on
438
- * the remote-hit ingest path (the artifact doesn't carry them — the
439
- * fingerprint is the saving machine's local moat). Only computed on a
440
- * miss/save, so a cache hit never reaches here.
441
- */
442
- inputComponents?: readonly TaskInputRow[]
443
- }
444
-
445
- /**
446
- * The supplied artifact bytes don't decompress/parse as a vx artifact.
447
- * Thrown by `save`/`ingest` BEFORE anything reaches the final cache
448
- * path — a rejected artifact leaves no `<hash>.tar.zst` and no SQL row.
449
- * The LayeredCache treats this as a remote fault on the remote-hit
450
- * path (degrades to a cache miss).
451
- */
452
- export class CorruptArtifactError extends Error {
453
- constructor(
454
- public readonly hash: string,
455
- reason: string,
456
- public override readonly cause?: unknown,
457
- ) {
458
- super(`cache: corrupt artifact for ${hash}: ${reason}`)
459
- this.name = 'CorruptArtifactError'
460
- }
461
- }
462
-
463
- /**
464
- * A decompressed artifact above this is refused as a zstd bomb rather than
465
- * expanded into memory. 2 GiB comfortably exceeds any real build output while
466
- * bounding a malicious/compromised remote's ability to OOM a victim who takes
467
- * a cache hit.
468
- */
469
- export const MAX_DECOMPRESSED_ARTIFACT_BYTES = 2 * 1024 * 1024 * 1024
470
-
471
- /**
472
- * Read a zstd frame's declared Frame_Content_Size (RFC 8878 §3.1.1) WITHOUT
473
- * decompressing. Returns null when the frame omits it (streaming frames) or
474
- * the header is too short to parse. vx's own producer (single-shot
475
- * `Bun.zstdCompress` of a known buffer) always writes it, so an artifact that
476
- * declares an enormous size can be rejected before a byte is allocated.
477
- *
478
- * Exported for tests: pins the per-`fcsFlag` byte layouts (incl. the 2-byte
479
- * `+256` adjustment and the `dictIdFlag` offset) that a bomb-refusal e2e can't
480
- * discriminate (their max declarable sizes sit below the ceiling).
481
- */
482
- export function zstdContentSize(b: Uint8Array): bigint | null {
483
- if (b.length < 5) return null
484
- // Magic_Number 0xFD2FB528, little-endian.
485
- if (b[0] !== 0x28 || b[1] !== 0xb5 || b[2] !== 0x2f || b[3] !== 0xfd) return null
486
- const desc = b[4]!
487
- const fcsFlag = desc >> 6
488
- const singleSegment = (desc >> 5) & 1
489
- const dictIdFlag = desc & 3
490
- let off = 5
491
- if (singleSegment === 0) off += 1 // Window_Descriptor byte
492
- off += dictIdFlag === 3 ? 4 : dictIdFlag // Dictionary_ID: 0,1,2,4 bytes
493
- let fcsSize: number
494
- if (fcsFlag === 0) fcsSize = singleSegment === 1 ? 1 : 0
495
- else if (fcsFlag === 1) fcsSize = 2
496
- else if (fcsFlag === 2) fcsSize = 4
497
- else fcsSize = 8
498
- if (fcsSize === 0 || b.length < off + fcsSize) return null
499
- let v = 0n
500
- for (let i = 0; i < fcsSize; i++) v |= BigInt(b[off + i]!) << BigInt(8 * i)
501
- if (fcsSize === 2) v += 256n // per spec, the 2-byte field stores value − 256
502
- return v
503
- }
504
-
505
- /**
506
- * Decompress a zstd artifact with a hard output ceiling. `trusted` marks a
507
- * locally-produced artifact (always carries a declared content size); an
508
- * UNTRUSTED (remote-sourced) frame that declares no content size is refused,
509
- * since our producer and the Turbo servers we interop with always write one —
510
- * a sizeless frame from the network is the shape a bomb takes to dodge the
511
- * pre-decompress check.
218
+ * Artifact + `output_files` namespace prefix for workspace-root-
219
+ * anchored outputs (`cache.outputs.workspaceFiles`). Project outputs
220
+ * keep their bare project-relative `path` rows; workspace rows store
221
+ * the full `workspace-outputs/<rel-to-root>` tar entry name as the
222
+ * discriminator — least-invasive row format, no schema change. A
223
+ * project output dir literally named `workspace-outputs/` would
224
+ * collide with the namespace; the name is reserved.
512
225
  */
513
- async function zstdDecompressBounded(
514
- compressed: Uint8Array,
515
- hash: string,
516
- trusted: boolean,
517
- ): Promise<Uint8Array> {
518
- const declared = zstdContentSize(compressed)
519
- if (declared !== null && declared > BigInt(MAX_DECOMPRESSED_ARTIFACT_BYTES)) {
520
- throw new CorruptArtifactError(
521
- hash,
522
- `declares ${declared} decompressed bytes (> ${MAX_DECOMPRESSED_ARTIFACT_BYTES} cap)`,
523
- )
524
- }
525
- if (declared === null && !trusted) {
526
- throw new CorruptArtifactError(hash, 'remote zstd frame carries no declared content size')
527
- }
528
- const out = await Bun.zstdDecompress(compressed)
529
- if (out.length > MAX_DECOMPRESSED_ARTIFACT_BYTES) {
530
- throw new CorruptArtifactError(
531
- hash,
532
- `decompressed to ${out.length} bytes (> ${MAX_DECOMPRESSED_ARTIFACT_BYTES} cap)`,
533
- )
534
- }
535
- return out
536
- }
537
-
538
- export interface CacheLayer {
539
- key(input: CacheKeyInput): Promise<string>
540
- get(hash: string, ctx?: CacheGetContext): Promise<CacheEntry | null>
541
- /**
542
- * Lightweight existence probe. `'local'` / `'remote'` names the layer
543
- * that holds the artifact; `null` is a miss. NEVER moves bytes: no
544
- * artifact read, no remote download, no local ingest, no accessed_at
545
- * bump (the LayeredCache's remote side is an HTTP HEAD). Planning
546
- * (`--dry` / `--graph`) predicts hits with this instead of `get` so a
547
- * dry run can't pull N artifacts over the network. Remote errors
548
- * degrade to `null` — an existence probe never fails anything.
549
- */
550
- has(hash: string): Promise<'local' | 'remote' | null>
551
- /**
552
- * Best-effort warm of `hash` from a slower layer (the remote cache)
553
- * into this one, so a later `get(hash)` resolves locally without a
554
- * round-trip on the task's critical path. Returns `true` if the
555
- * artifact is now present locally, `false` on a miss / error
556
- * (degrades, never throws). The local `Cache` is a no-op (nothing
557
- * slower to warm from); `LayeredCache` owns the real implementation
558
- * plus the in-flight de-dup so prefetch + get share ONE remote GET.
559
- */
560
- prefetch(hash: string, ctx?: CacheGetContext): Promise<boolean>
561
- /**
562
- * Batched lookup of per-output-file fingerprints for many cache
563
- * entries in one SQL round-trip. Returns a Map keyed by entry hash.
564
- *
565
- * Orchestrator pattern: call this once at `prepareRun` for every
566
- * task whose hash is known up-front, then per-task `executeCachedTask`
567
- * does a Map.get (O(1)) + parallel stat checks to decide whether the
568
- * on-disk tree is already current.
569
- *
570
- * Hashes with no rows (cache misses) are absent from the result.
571
- */
572
- loadOutputFilesBatch(hashes: readonly string[]): Map<string, OutputFileRow[]>
573
- /**
574
- * Stat each `expected` row's target under `projectDir` and return
575
- * `true` iff every (size, mode, mtime) matches. Missing files,
576
- * stat errors, or any mismatch → `false`.
577
- *
578
- * Pure FS check — no DB access. Caller batches the expected rows
579
- * via `loadOutputFilesBatch`. Lets the orchestrator skip
580
- * `cleanOutputs + restoreOutputs` entirely when the cached
581
- * snapshot is already in place. Integrity-preserving: detects
582
- * out-of-band file edits or deletions and falls through to a real
583
- * restore.
584
- */
585
- isOutputsCurrent(projectDir: string, expected: readonly OutputFileRow[]): Promise<boolean>
586
- /**
587
- * Extract the artifact's `outputs/` entries into `projectDir` and —
588
- * when `workspaceRoot` is given — its `workspace-outputs/` entries
589
- * into the workspace root. Callers restoring entries that may carry
590
- * workspace outputs must pass `workspaceRoot`.
591
- */
592
- restoreOutputs(hash: string, projectDir: string, workspaceRoot?: string): Promise<void>
593
- save(args: {
594
- hash: string
595
- entry: Omit<CacheEntry, 'hash' | 'storedAt' | 'outputFiles'>
596
- projectDir: string
597
- outputFiles: string[]
598
- /**
599
- * Resolved `outputs.workspaceFiles` (absolute paths) + the root
600
- * they're relative to. Packed under `workspace-outputs/<rel>`.
601
- * Omitted → artifact bytes identical to the pre-workspaceFiles
602
- * format.
603
- */
604
- workspaceOutputFiles?: string[]
605
- workspaceRoot?: string
606
- /**
607
- * Cache-key components for this entry (the Tier-3 input
608
- * fingerprint). Persisted to `entry_inputs` inside the same
609
- * transaction as the entry row, via `INSERT OR IGNORE` (a re-save
610
- * of the same hash is a no-op). Omitted/empty → nothing written.
611
- * Only computed on the miss/save path — never on a hit.
612
- */
613
- inputComponents?: readonly TaskInputRow[]
614
- }): Promise<void>
615
- /**
616
- * Adopt an artifact produced elsewhere — the remote-hit path. Writes
617
- * the compressed bytes to `<cacheDir>/<hash>.tar.zst`, parses the
618
- * tar headers to populate the `output_files` rows, and inserts the
619
- * `entries` row using the caller-supplied `meta`. After this returns,
620
- * the next `get(hash)` resolves locally.
621
- */
622
- ingest(hash: string, compressed: Uint8Array, meta: IngestMeta): Promise<void>
623
- recordRun(run: RunRecord): void
624
- /**
625
- * Append every run in `runs` to the history in a single SQLite
626
- * transaction. ~10× faster than calling `recordRun` in a loop when
627
- * `runs.length > ~50` (one fsync vs. N).
628
- */
629
- recordRuns(runs: readonly RunRecord[]): void
630
- /**
631
- * Record a whole `vx run` atomically: the per-task `runs` rows and
632
- * the one `invocations` header row — in a SINGLE transaction (one
633
- * fsync). Replaces the bare `recordRuns` call in the orchestrator's
634
- * end-of-run path. Input-fingerprint rows are NOT written here — they
635
- * ride the entry-save transaction (`save`/`ingest`) so a warm
636
- * all-cache-hit run writes nothing.
637
- */
638
- recordRunBundle(bundle: { runs: readonly RunRecord[]; invocation: InvocationRecord }): void
639
- stats(): CacheStats
640
- /**
641
- * Content-hash a file with an mtime+size fast path. If the
642
- * `(mtime_ms, size_bytes)` of `filePath` match a previously seen
643
- * row, return the stored xxh3 digest instead of re-reading the
644
- * bytes. Otherwise read + hash + upsert. The hash is byte-for-byte
645
- * identical to what a fresh content-hash would produce — pure
646
- * optimization, no cache-key change.
647
- */
648
- hashFile(filePath: string): Promise<string>
649
- /**
650
- * Absolute path to the on-disk outputs artifact for a hash —
651
- * `<cacheDir>/<hash>.tar.zst`. Returns the path whether or
652
- * not the artifact exists. Exposed for telemetry / dashboards;
653
- * `restoreOutputs` is the canonical way to materialize the bytes.
654
- */
655
- outputsPath(hash: string): string
656
- prune(options: PruneOptions): Promise<PruneResult>
657
- close(): void
658
- }
226
+ export const WORKSPACE_OUTPUT_PREFIX = 'workspace-outputs/'
659
227
 
660
- /**
661
- * Convenience alias for the `save()` args. Used by `LayeredCache` to
662
- * forward call args without redeclaring the structural shape — NOT
663
- * part of the conceptual cache contract; consumers should call
664
- * `CacheLayer.save({ ... })` directly.
665
- *
666
- * @internal
667
- */
668
- export type SaveArgs = Parameters<CacheLayer['save']>[0]
228
+ // The contract, the policy grammar and the zstd framing live beside this
229
+ // file; they are re-exported here so an importer of `./cache.js` — the
230
+ // module index, the sibling layers, the tests — keeps one path.
231
+ export * from './layer.js'
232
+ export * from './policy.js'
233
+ export { zstdContentSize } from './zstd.js'
669
234
 
670
235
  interface EntryRow {
671
236
  hash: string
@@ -678,23 +243,51 @@ interface EntryRow {
678
243
  stdout: string
679
244
  created_at: number
680
245
  accessed_at: number
246
+ cpu_ms: number | null
247
+ peak_rss_bytes: number | null
248
+ }
249
+
250
+ function entryOf(row: EntryRow, fileRows: OutputFileRow[]): CacheEntry {
251
+ return {
252
+ hash: row.hash,
253
+ taskId: `${row.project}#${row.task}`,
254
+ command: row.command,
255
+ exitCode: row.exit_code,
256
+ durationMs: row.duration_ms,
257
+ ...(row.cpu_ms !== null ? { cpuMs: row.cpu_ms } : {}),
258
+ ...(row.peak_rss_bytes !== null ? { peakRssBytes: row.peak_rss_bytes } : {}),
259
+ outputFiles: fileRows.map((r) => r.path),
260
+ outputRows: fileRows,
261
+ stdout: row.stdout,
262
+ storedAt: new Date(row.created_at).toISOString(),
263
+ source: 'local',
264
+ }
265
+ }
266
+
267
+ /** The sidecar's `exec` from a save's entry: only the axes the runner reported. */
268
+ function usageOfEntry(entry: { cpuMs?: number; peakRssBytes?: number }): ExecUsage | undefined {
269
+ if (entry.cpuMs === undefined && entry.peakRssBytes === undefined) return undefined
270
+ return {
271
+ ...(entry.cpuMs !== undefined ? { cpuMs: entry.cpuMs } : {}),
272
+ ...(entry.peakRssBytes !== undefined ? { peakRssBytes: entry.peakRssBytes } : {}),
273
+ }
681
274
  }
682
275
 
683
276
  export class Cache implements CacheLayer {
277
+ /** This IS the local layer — there is nothing slower behind it. */
278
+ readonly hasRemote = false
279
+
684
280
  private readonly db: Database
685
281
  private readonly insertEntry: ReturnType<Database['prepare']>
686
282
  private readonly selectEntry: ReturnType<Database['prepare']>
687
283
  private readonly bumpAccessed: ReturnType<Database['prepare']>
688
284
  private readonly touched = new Set<string>()
689
- private readonly insertRun: ReturnType<Database['prepare']>
690
- private readonly insertInvocation: ReturnType<Database['prepare']>
691
285
  private readonly insertEntryInput: ReturnType<Database['prepare']>
692
- private readonly selectFileHash: ReturnType<Database['prepare']>
693
- private readonly upsertFileHash: ReturnType<Database['prepare']>
694
- private readonly insertOutputFile: ReturnType<Database['prepare']>
695
- private readonly deleteOutputFiles: ReturnType<Database['prepare']>
696
- /** Memoized repo object format for blob-OID hashing (lazy-detected). */
697
- private objectFormat: 'sha1' | 'sha256' | null = null
286
+ /** The per-file (mtime, size) → blob-OID memo behind `hashFile`. */
287
+ private readonly files: FileHashStore
288
+ private readonly configEvals: ConfigEvalTable
289
+ private readonly outputs: OutputIndex
290
+ private readonly history: RunHistory
698
291
 
699
292
  /**
700
293
  * Local-layer read/write gates for the task ARTIFACT path only.
@@ -706,6 +299,16 @@ export class Cache implements CacheLayer {
706
299
  private readonly read: boolean
707
300
  private readonly write: boolean
708
301
 
302
+ /**
303
+ * Set when THIS open found an index written by another `SCHEMA_VERSION`
304
+ * and dropped every table. The next open sees the current version and
305
+ * reports null, so whoever opened first is the one that can say so —
306
+ * `noteSchemaReset` is that one line, at every opener that has a
307
+ * channel. Without it an upgrade empties the cache and the history in
308
+ * silence, and the all-miss run that follows looks like a bug.
309
+ */
310
+ readonly schemaReset: SchemaReset | null = null
311
+
709
312
  constructor(
710
313
  private readonly cacheDir: string,
711
314
  localPolicy: { read: boolean; write: boolean } = { read: true, write: true },
@@ -717,6 +320,15 @@ export class Cache implements CacheLayer {
717
320
  // because callers use `new Cache(...)` directly; `mkdirSync` keeps
718
321
  // that property without a subprocess fork.
719
322
  mkdirSync(cacheDir, { recursive: true })
323
+ // Make the cache dir invisible to git, every time it is created: a
324
+ // `*` .gitignore inside it (the Cargo / Nx convention). Two reasons,
325
+ // both measured. A cache nobody ignored gets COMMITTED by the next
326
+ // `git add -A`; and vx's own `git status -uall` walks it — 1000
327
+ // artifacts doubled the enumeration on the bench workspace before
328
+ // its generator ignored `.vx`. An ignored directory is skipped by the
329
+ // walk entirely. Only written when absent, so a user's own file wins.
330
+ const ignore = path.join(cacheDir, '.gitignore')
331
+ if (!existsSync(ignore)) writeFileSync(ignore, '*\n')
720
332
  this.db = new Database(path.join(cacheDir, 'cache.db'), { create: true })
721
333
  this.db.exec('PRAGMA journal_mode = WAL')
722
334
  this.db.exec('PRAGMA synchronous = NORMAL')
@@ -737,14 +349,17 @@ export class Cache implements CacheLayer {
737
349
  // Schema-version gate runs BEFORE the rest of the schema lands so
738
350
  // a column rename (e.g. v15's `sha256` → `content_hash`) actually
739
351
  // takes effect on stale DBs. Pre-alpha: no migrations, just drop
740
- // and recreate. Outputs on disk become orphans; they'll be ignored
741
- // on next miss and reaped by `vx cache prune`.
352
+ // and recreate. Artifacts on disk become orphans: a lookup never
353
+ // sees them (rows first, then the file), the next save of the same
354
+ // key renames over them, and `prune()`'s orphan sweep unlinks the
355
+ // rest.
742
356
  const meta = this.db.prepare("SELECT value FROM schema_meta WHERE key = 'version'").get() as
743
357
  | { value: string }
744
358
  | undefined
745
359
  if (meta && meta.value !== SCHEMA_VERSION) {
360
+ this.schemaReset = { from: meta.value, to: SCHEMA_VERSION }
746
361
  this.db.exec(
747
- 'DROP TABLE IF EXISTS entries; DROP TABLE IF EXISTS runs; DROP TABLE IF EXISTS file_hashes; DROP TABLE IF EXISTS output_files; DROP TABLE IF EXISTS invocations; DROP TABLE IF EXISTS run_task_inputs; DROP TABLE IF EXISTS entry_inputs;',
362
+ 'DROP TABLE IF EXISTS entries; DROP TABLE IF EXISTS runs; DROP TABLE IF EXISTS file_hashes; DROP TABLE IF EXISTS output_files; DROP TABLE IF EXISTS invocations; DROP TABLE IF EXISTS run_task_inputs; DROP TABLE IF EXISTS entry_inputs; DROP TABLE IF EXISTS config_evals;',
748
363
  )
749
364
  this.db.prepare("UPDATE schema_meta SET value = ? WHERE key = 'version'").run(SCHEMA_VERSION)
750
365
  } else if (!meta) {
@@ -753,6 +368,17 @@ export class Cache implements CacheLayer {
753
368
  .run(SCHEMA_VERSION)
754
369
  }
755
370
 
371
+ // Cached config evaluations (workspace/config-cache.ts): the validated
372
+ // config as JSON, keyed by everything the evaluation could observe. A
373
+ // separate exec so the artifact schema above stays byte-identical.
374
+ this.db.exec(`
375
+ CREATE TABLE IF NOT EXISTS config_evals (
376
+ key TEXT PRIMARY KEY,
377
+ json TEXT NOT NULL,
378
+ created_at INTEGER NOT NULL
379
+ );
380
+ `)
381
+
756
382
  this.db.exec(`
757
383
  -- stdout/stderr live in the <hash>.tar.zst artifact, not here
758
384
  -- (v14+) — so they survive remote round-trips. The entries
@@ -768,10 +394,15 @@ export class Cache implements CacheLayer {
768
394
  size_bytes INTEGER NOT NULL,
769
395
  stdout TEXT NOT NULL DEFAULT '',
770
396
  created_at INTEGER NOT NULL,
771
- accessed_at INTEGER NOT NULL
397
+ accessed_at INTEGER NOT NULL,
398
+ -- v26: the producing execution's usage, from the artifact's sidecar.
399
+ cpu_ms INTEGER,
400
+ peak_rss_bytes INTEGER
772
401
  );
773
402
  CREATE TABLE IF NOT EXISTS runs (
774
403
  id INTEGER PRIMARY KEY AUTOINCREMENT,
404
+ -- '' when the outcome derived no cache key (skipped / persistent).
405
+ -- Every reader that must not mistake it for a key guards hash != ''.
775
406
  hash TEXT NOT NULL,
776
407
  project TEXT NOT NULL,
777
408
  task TEXT NOT NULL,
@@ -792,13 +423,37 @@ export class Cache implements CacheLayer {
792
423
  cache_hit INTEGER,
793
424
  -- v23: attempts a retried task took (>1); NULL for a once-run task.
794
425
  -- The direct within-run flaky signal.
795
- attempts INTEGER
426
+ attempts INTEGER,
427
+ -- v25: 1 when the task declared a cache block, 0 when it runs every
428
+ -- time by design; NULL on rows older than the column.
429
+ cached INTEGER
796
430
  );
797
- CREATE INDEX IF NOT EXISTS runs_hash ON runs(hash);
431
+ -- Two whole-table indexes only, and both APPEND: every row of a run carries the
432
+ -- same run_id and a started_at newer than everything before it, so
433
+ -- 1,000 inserts touch a handful of leaf pages. The dropped ones did
434
+ -- not — the DROPs shed them from existing databases (a schema-meta
435
+ -- bump is for stored shapes, and an index is not one):
436
+ -- runs_hash (2026-09-03) had no reader; 11.5 → 3.9 ms per 1,000.
437
+ -- runs_project (project, task) (2026-09-09) scattered every run's
438
+ -- rows over one leaf per pair: the record stage of a 1,000-hit
439
+ -- warm run was 57–79 ms with it and 14–19 ms without, at 166k
440
+ -- rows, and it bought its readers nothing — the history reader
441
+ -- now scans a rowid slice (history.ts), vx why walks
442
+ -- started_at newest-first and stops at the first match.
443
+ -- runs_ended (2026-09-09) served only the retention DELETE, which
444
+ -- prunes on started_at now (a row ends after it starts, so the
445
+ -- 30-day window moves by at most one task's duration).
446
+ DROP INDEX IF EXISTS runs_hash;
447
+ DROP INDEX IF EXISTS runs_project;
448
+ DROP INDEX IF EXISTS runs_ended;
798
449
  CREATE INDEX IF NOT EXISTS runs_started_at ON runs(started_at);
799
- CREATE INDEX IF NOT EXISTS runs_project ON runs(project, task);
800
- CREATE INDEX IF NOT EXISTS runs_ended ON runs(ended_at);
801
450
  CREATE INDEX IF NOT EXISTS runs_run_id ON runs(run_id);
451
+ -- The one keyed index, PARTIAL over failed rows: a green run's
452
+ -- inserts only evaluate its predicate, so the append-only cost above
453
+ -- holds, and the flakiness probe after a miss (failure-mode.ts,
454
+ -- "did this key ever fail?") reads a handful of leaves instead of
455
+ -- scanning the table (2026-09-10: 10–95 ms at 170k rows without it).
456
+ CREATE INDEX IF NOT EXISTS runs_failed ON runs(hash) WHERE status = 'failed';
802
457
  -- Per-file (mtime, size, content_hash) cache. Lets Cache.key()
803
458
  -- skip the content-hash on inputs whose stat hasn't changed
804
459
  -- since the last run. Pure performance optimization; the stored
@@ -808,6 +463,8 @@ export class Cache implements CacheLayer {
808
463
  path TEXT PRIMARY KEY,
809
464
  mtime_ms INTEGER NOT NULL,
810
465
  size_bytes INTEGER NOT NULL,
466
+ ctime_ms INTEGER NOT NULL,
467
+ ino INTEGER NOT NULL,
811
468
  content_hash TEXT NOT NULL,
812
469
  seen_at INTEGER NOT NULL
813
470
  );
@@ -829,6 +486,27 @@ export class Cache implements CacheLayer {
829
486
  PRIMARY KEY (entry_hash, path),
830
487
  FOREIGN KEY (entry_hash) REFERENCES entries(hash) ON DELETE CASCADE
831
488
  );
489
+ -- Every directory under a whole-subtree output glob, with its mtime
490
+ -- as of the last save/restore on THIS machine (2026-09-03). On a warm
491
+ -- hit, unchanged mtimes prove the output SET is unchanged, replacing
492
+ -- the glob walk that cost 0.36 ms per hit. Machine-local: a remote
493
+ -- ingest writes none, and the first hit after it walks and records.
494
+ -- Each config's ORDERED import closure (the config first), so a warm
495
+ -- load keys it by stat-hashing the list (the file_hashes memo) instead
496
+ -- of reading and scanning every file. Machine-local; pruned with
497
+ -- config_evals (2026-09-03).
498
+ CREATE TABLE IF NOT EXISTS config_closures (
499
+ config_path TEXT PRIMARY KEY,
500
+ files_json TEXT NOT NULL,
501
+ created_at INTEGER NOT NULL
502
+ );
503
+ CREATE TABLE IF NOT EXISTS output_dirs (
504
+ entry_hash TEXT NOT NULL,
505
+ path TEXT NOT NULL,
506
+ mtime_ms INTEGER NOT NULL,
507
+ PRIMARY KEY (entry_hash, path),
508
+ FOREIGN KEY (entry_hash) REFERENCES entries(hash) ON DELETE CASCADE
509
+ );
832
510
  -- v22 (Tier 3): one header row per vx-run invocation. The runs
833
511
  -- table is per-task; this is the per-invocation record that
834
512
  -- carries git/CI/host context, the command, tags, and run-level
@@ -885,41 +563,22 @@ export class Cache implements CacheLayer {
885
563
  `)
886
564
 
887
565
  this.insertEntry = this.db.prepare(`
888
- INSERT INTO entries(hash, project, task, command, exit_code, duration_ms, size_bytes, stdout, created_at, accessed_at)
889
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
566
+ INSERT INTO entries(hash, project, task, command, exit_code, duration_ms, size_bytes, stdout, created_at, accessed_at, cpu_ms, peak_rss_bytes)
567
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
890
568
  ON CONFLICT(hash) DO UPDATE SET
891
- stdout = excluded.stdout,
892
- project = excluded.project,
893
- task = excluded.task,
894
- command = excluded.command,
895
- exit_code = excluded.exit_code,
896
- duration_ms = excluded.duration_ms,
897
- size_bytes = excluded.size_bytes,
898
- accessed_at = excluded.accessed_at
569
+ stdout = excluded.stdout,
570
+ project = excluded.project,
571
+ task = excluded.task,
572
+ command = excluded.command,
573
+ exit_code = excluded.exit_code,
574
+ duration_ms = excluded.duration_ms,
575
+ size_bytes = excluded.size_bytes,
576
+ accessed_at = excluded.accessed_at,
577
+ cpu_ms = excluded.cpu_ms,
578
+ peak_rss_bytes = excluded.peak_rss_bytes
899
579
  `)
900
580
  this.selectEntry = this.db.prepare('SELECT * FROM entries WHERE hash = ?')
901
581
  this.bumpAccessed = this.db.prepare('UPDATE entries SET accessed_at = ? WHERE hash = ?')
902
- this.insertRun = this.db.prepare(`
903
- INSERT INTO runs(
904
- hash, project, task, status, exit_code, duration_ms, forward_args,
905
- started_at, ended_at,
906
- run_id, cpu_ms, peak_rss_bytes, wallclock_start_ns, wallclock_end_ns,
907
- cache_hit, attempts
908
- )
909
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
910
- `)
911
- this.insertInvocation = this.db.prepare(`
912
- INSERT INTO invocations(
913
- run_id, command, requested_tasks, cache_policy, concurrency, flow,
914
- started_at, ended_at, total_duration_ms,
915
- task_count, failed_count, hit_count, hit_local_count, hit_remote_count,
916
- exit_ok,
917
- commit_sha, branch, dirty, ci, ci_provider,
918
- host, os, arch, vx_version, tags
919
- )
920
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
921
- ON CONFLICT(run_id) DO NOTHING
922
- `)
923
582
  // INSERT OR IGNORE: re-saving the same hash (idempotent ingest /
924
583
  // overlapping concurrent saves) leaves the existing rows untouched —
925
584
  // identical inputs derive the identical hash, so the rows are too.
@@ -927,111 +586,37 @@ export class Cache implements CacheLayer {
927
586
  INSERT OR IGNORE INTO entry_inputs(entry_hash, kind, name, hash)
928
587
  VALUES (?, ?, ?, ?)
929
588
  `)
930
- this.selectFileHash = this.db.prepare(
931
- 'SELECT mtime_ms, size_bytes, content_hash FROM file_hashes WHERE path = ?',
932
- )
933
- this.upsertFileHash = this.db.prepare(`
934
- INSERT INTO file_hashes(path, mtime_ms, size_bytes, content_hash, seen_at)
935
- VALUES (?, ?, ?, ?, ?)
936
- ON CONFLICT(path) DO UPDATE SET
937
- mtime_ms = excluded.mtime_ms,
938
- size_bytes = excluded.size_bytes,
939
- content_hash = excluded.content_hash,
940
- seen_at = excluded.seen_at
941
- `)
942
- this.insertOutputFile = this.db.prepare(`
943
- INSERT INTO output_files(entry_hash, path, size_bytes, mode, mtime_ms)
944
- VALUES (?, ?, ?, ?, ?)
945
- ON CONFLICT(entry_hash, path) DO UPDATE SET
946
- size_bytes = excluded.size_bytes,
947
- mode = excluded.mode,
948
- mtime_ms = excluded.mtime_ms
949
- `)
950
- this.deleteOutputFiles = this.db.prepare('DELETE FROM output_files WHERE entry_hash = ?')
589
+ // The slices: each owns its statements over this handle and its table(s);
590
+ // the schema above is the one place every table is declared.
591
+ this.files = new FileHashStore(this.db, cacheDir)
592
+ this.configEvals = new ConfigEvalTable(this.db, localPolicy)
593
+ this.outputs = new OutputIndex(this.db)
594
+ this.history = new RunHistory(this.db)
951
595
  }
952
596
 
953
- /**
954
- * Content-hash a file (as a git blob OID, v20) with an mtime+size
955
- * fast path. If the `file_hashes` table has a row for `path` whose
956
- * `(mtime_ms, size_bytes)` match the current stat, we reuse the
957
- * stored content_hash (a memory + SQLite lookup, no disk read).
958
- * Otherwise we read + hash + upsert.
959
- *
960
- * The OID is byte-identical to what `git hash-object` (and the git
961
- * index) computes for the same content, so this fallback and the
962
- * `CacheKeyInput.fileHashes` index-OID fast path never diverge —
963
- * a file's key contribution can't flip across dirty↔clean
964
- * transitions.
965
- */
966
- async hashFile(filePath: string): Promise<string> {
967
- // statSync intentional: a single stat is ~1.6µs (Bun 1.3); the
968
- // async-stat equivalent adds ~75µs of Promise machinery per call.
969
- // Promise.all over the batched callers (key derivation) gives no
970
- // I/O parallelism benefit because the stat is faster than the
971
- // threadpool dispatch overhead.
972
- let st
973
- try {
974
- st = statSync(filePath)
975
- } catch {
976
- // Caller is responsible for skipping files that don't exist;
977
- // fall through to the content-hash path which will throw with
978
- // a more useful error.
979
- return await this.hashFileFromDisk(filePath)
980
- }
981
- const mtimeMs = Math.floor(st.mtimeMs)
982
- const size = st.size
983
- const row = this.selectFileHash.get(filePath) as
984
- | { mtime_ms: number; size_bytes: number; content_hash: string }
985
- | undefined
986
- if (row && row.mtime_ms === mtimeMs && row.size_bytes === size) {
987
- return row.content_hash
988
- }
989
- const ch = await this.hashFileFromDisk(filePath)
990
- this.upsertFileHash.run(filePath, mtimeMs, size, ch, Date.now())
991
- return ch
597
+ // --- config evaluations: `ConfigEvalStore`, delegated to `ConfigEvalTable` ---
598
+ getConfigEval(key: string): string | null {
599
+ return this.configEvals.getConfigEval(key)
992
600
  }
993
-
994
- /**
995
- * Git blob OID of the file's bytes:
996
- * `hex(HASH("blob " + byteLength + "\0" + content))`, where HASH is
997
- * the repo's object format. Same value `git hash-object` prints and
998
- * the same value the index stores. Computed in-process — no git
999
- * spawn per file.
1000
- */
1001
- private async hashFileFromDisk(filePath: string): Promise<string> {
1002
- const bytes = await Bun.file(filePath).bytes()
1003
- const hasher = new Bun.CryptoHasher(this.objectFormat ?? this.detectObjectFormat(filePath))
1004
- hasher.update(`blob ${bytes.byteLength}\0`)
1005
- hasher.update(bytes)
1006
- return hasher.digest('hex')
601
+ getConfigClosures(configPaths: readonly string[]): Map<string, string[]> {
602
+ return this.configEvals.getConfigClosures(configPaths)
1007
603
  }
1008
-
1009
- /**
1010
- * Repo object format — sha1 unless the repo was created with
1011
- * `--object-format=sha256`. One `git rev-parse` spawn per Cache
1012
- * lifetime, and only when at least one file misses the mtime+size
1013
- * memo. Outside a repo (unit fixtures) we default to sha1, which is
1014
- * still a deterministic blob-OID domain.
1015
- */
1016
- private detectObjectFormat(nearPath: string): 'sha1' | 'sha256' {
1017
- let detected: 'sha1' | 'sha256' = 'sha1'
1018
- try {
1019
- const proc = Bun.spawnSync({
1020
- cmd: ['git', 'rev-parse', '--show-object-format'],
1021
- cwd: path.dirname(nearPath),
1022
- stdout: 'pipe',
1023
- stderr: 'pipe',
1024
- })
1025
- if (proc.exitCode === 0 && new TextDecoder().decode(proc.stdout).trim() === 'sha256') {
1026
- detected = 'sha256'
1027
- }
1028
- } catch {
1029
- // git unavailable → sha1 default keeps hashing deterministic.
1030
- }
1031
- this.objectFormat = detected
1032
- return detected
604
+ putConfigClosure(configPath: string, files: readonly string[]): void {
605
+ this.configEvals.putConfigClosure(configPath, files)
606
+ }
607
+ getConfigEvals(keys: readonly string[]): Map<string, string> {
608
+ return this.configEvals.getConfigEvals(keys)
609
+ }
610
+ putConfigEval(key: string, json: string): void {
611
+ this.configEvals.putConfigEval(key, json)
612
+ }
613
+ // --- input file hashes: delegated to `FileHashStore` (see file-hashes.ts) ---
614
+ hashFile(filePath: string): Promise<string> {
615
+ return this.files.hashFile(filePath)
616
+ }
617
+ hashFiles(paths: readonly string[]): Promise<Map<string, string>> {
618
+ return this.files.hashFiles(paths)
1033
619
  }
1034
-
1035
620
  async key(input: CacheKeyInput): Promise<string> {
1036
621
  // Seed-chained xxHash3: each step folds one field into the
1037
622
  // running digest via `xxh3(part, prevDigest)`. Equivalent to the
@@ -1098,14 +683,27 @@ export class Cache implements CacheLayer {
1098
683
  }
1099
684
  }
1100
685
 
686
+ const pluginParts = input.pluginParts ?? []
687
+ if (pluginParts.length > 0) {
688
+ h = xxh3(`plugin:${pluginParts.length}`, h)
689
+ for (const [n, v] of pluginParts) h = xxh3(`${n}\0${v}`, h)
690
+ if (cap) {
691
+ for (const [n, v] of pluginParts) cap.push({ kind: 'plugin', name: n, hash: xxh3hex(v) })
692
+ }
693
+ }
694
+
1101
695
  const sortedInputs = [...input.inputFiles].sort()
1102
696
  h = xxh3(`inputs:${sortedInputs.length}`, h)
1103
697
  // Per-file hash source, in preference order: the caller-supplied
1104
698
  // index-OID map (clean tracked files — zero I/O), then hashFile's
1105
- // mtime+size memo (no read), then a full in-process blob-OID
1106
- // computation. All three produce identical bytes for identical
1107
- // content. The fold order is locked to `sortedInputs` so results
1108
- // are stable across runs.
699
+ // stat memo (no read), then a full in-process blob-OID computation.
700
+ // All three describe the WORKTREE bytes, which is what the task
701
+ // reads — the index-OID map is only populated for paths whose
702
+ // worktree form matches their index form, because a clean filter
703
+ // (`text`/`eol`/`ident`) makes the index blob a DIFFERENT sequence
704
+ // of bytes and folding it would let two distinct worktree contents
705
+ // share a key. The fold order is locked to `sortedInputs` so
706
+ // results are stable across runs.
1109
707
  const fileHashes = await Promise.all(
1110
708
  sortedInputs.map((f) => input.fileHashes?.get(f) ?? this.hashFile(f)),
1111
709
  )
@@ -1128,12 +726,29 @@ export class Cache implements CacheLayer {
1128
726
  // Local reads disabled (e.g. `--force` / `--cache=local:w`): report a
1129
727
  // miss so the task re-executes. The artifact + index are untouched.
1130
728
  if (!this.read) return null
729
+ return this.readEntry(hash)
730
+ }
731
+
732
+ /**
733
+ * Read an entry BYPASSING the local read gate. The gate means "don't
734
+ * serve hits out of the PRE-EXISTING local cache" — it must not throw
735
+ * away bytes this run just downloaded. `LayeredCache` calls this to
736
+ * deliver an artifact it ingested from the remote; under
737
+ * `--cache=local:,remote:rw` the gated `get` returned null for the row
738
+ * `ingest` had just written, so the task re-executed and re-uploaded on
739
+ * every single run.
740
+ */
741
+ getIngested(hash: string): Promise<CacheEntry | null> {
742
+ return this.readEntry(hash)
743
+ }
744
+
745
+ private async readEntry(hash: string): Promise<CacheEntry | null> {
1131
746
  const row = this.selectEntry.get(hash) as EntryRow | undefined
1132
747
  if (!row) return null
1133
748
 
1134
749
  // Verify the tar artifact actually exists. The DB and the
1135
750
  // filesystem can drift if someone manually deletes the cache dir.
1136
- if (!(await Bun.file(this.tarPath(hash)).exists())) return null
751
+ if (!existsSync(this.tarPath(hash))) return null
1137
752
 
1138
753
  // Deferred: per-hit UPDATEs cost ~60 ms across 2000+ probes on a
1139
754
  // full-cache run. Hashes are collected and flushed as ONE batched
@@ -1147,18 +762,45 @@ export class Cache implements CacheLayer {
1147
762
  // for four up-to-date hits on ~70 MB binaries). restoreOutputs
1148
763
  // reads the artifact itself, only when extraction actually runs.
1149
764
  const fileRows = this.loadOutputFilesBatch([hash]).get(hash) ?? []
765
+ const entry = entryOf(row, fileRows)
766
+ entry.outputDirRows = this.loadOutputDirsBatch([hash]).get(hash) ?? []
767
+ return entry
768
+ }
1150
769
 
1151
- return {
1152
- hash: row.hash,
1153
- taskId: `${row.project}#${row.task}`,
1154
- command: row.command,
1155
- exitCode: row.exit_code,
1156
- durationMs: row.duration_ms,
1157
- outputFiles: fileRows.map((r) => r.path),
1158
- stdout: row.stdout,
1159
- storedAt: new Date(row.created_at).toISOString(),
1160
- source: 'local',
770
+ /**
771
+ * `get` for many hashes at once: one `entries` query and one
772
+ * `output_files` query per chunk instead of two per hash, with the
773
+ * artifact-existence stats in flight together. Same answers as N calls
774
+ * to `get` — a hash whose artifact is gone is simply absent from the map.
775
+ * The up-front short-circuit probe is the caller; per-hit `get` stays for
776
+ * the lazy path.
777
+ */
778
+ async getMany(hashes: readonly string[]): Promise<Map<string, CacheEntry>> {
779
+ const out = new Map<string, CacheEntry>()
780
+ if (!this.read || hashes.length === 0) return out
781
+ const rows: EntryRow[] = []
782
+ for (let i = 0; i < hashes.length; i += 900) {
783
+ const chunk = hashes.slice(i, i + 900)
784
+ const placeholders = chunk.map(() => '?').join(',')
785
+ rows.push(
786
+ ...(this.db
787
+ .query(`SELECT * FROM entries WHERE hash IN (${placeholders})`)
788
+ .all(...(chunk as readonly SQLQueryBindings[])) as EntryRow[]),
789
+ )
1161
790
  }
791
+ if (rows.length === 0) return out
792
+ const present = rows.map((r) => existsSync(this.tarPath(r.hash)))
793
+ const live = rows.filter((_r, i) => present[i])
794
+ const liveHashes = live.map((r) => r.hash)
795
+ const fileRows = this.loadOutputFilesBatch(liveHashes)
796
+ const dirRows = this.loadOutputDirsBatch(liveHashes)
797
+ for (const row of live) {
798
+ this.touched.add(row.hash)
799
+ const entry = entryOf(row, fileRows.get(row.hash) ?? [])
800
+ entry.outputDirRows = dirRows.get(row.hash) ?? []
801
+ out.set(row.hash, entry)
802
+ }
803
+ return out
1162
804
  }
1163
805
 
1164
806
  // Existence probe: SQL row + artifact-on-disk check, no byte reads
@@ -1167,7 +809,7 @@ export class Cache implements CacheLayer {
1167
809
  if (!this.read) return null
1168
810
  const row = this.selectEntry.get(hash) as EntryRow | undefined
1169
811
  if (!row) return null
1170
- return (await Bun.file(this.tarPath(hash)).exists()) ? 'local' : null
812
+ return existsSync(this.tarPath(hash)) ? 'local' : null
1171
813
  }
1172
814
 
1173
815
  // Local cache has no slower layer to warm from — prefetch is a no-op.
@@ -1177,74 +819,36 @@ export class Cache implements CacheLayer {
1177
819
  return false
1178
820
  }
1179
821
 
822
+ // --- output fingerprints: delegated to `OutputIndex` (see output-index.ts) ---
823
+ // The slices' proofs and hashes are returned as the slice's own promise —
824
+ // no `async` wrapper, no `return await`: each of those added a promise and
825
+ // a microtask hop per call, and a 1,000-hit warm run pays the proofs
826
+ // 2,000 times (measured 2026-09-10: +3 ms on `classify + probe`).
1180
827
  loadOutputFilesBatch(hashes: readonly string[]): Map<string, OutputFileRow[]> {
1181
- const out = new Map<string, OutputFileRow[]>()
1182
- if (hashes.length === 0) return out
1183
- // Inline placeholders for an IN-list — bun:sqlite doesn't ship
1184
- // rarray, but `IN (?, ?, …)` with N≤~999 is fast and avoids per-
1185
- // hash select.get() overhead. `db.query` (not `db.prepare`) caches the
1186
- // compiled statement keyed by the SQL text — so the dominant single-hash
1187
- // warm-hit path (called up to 3× per hit) reuses one statement instead of
1188
- // recompiling on every call.
1189
- const placeholders = hashes.map(() => '?').join(',')
1190
- const stmt = this.db.query(
1191
- `SELECT entry_hash, path, size_bytes, mode, mtime_ms FROM output_files WHERE entry_hash IN (${placeholders})`,
1192
- )
1193
- const rows = stmt.all(...(hashes as readonly SQLQueryBindings[])) as Array<{
1194
- entry_hash: string
1195
- path: string
1196
- size_bytes: number
1197
- mode: number
1198
- mtime_ms: number
1199
- }>
1200
- for (const r of rows) {
1201
- let list = out.get(r.entry_hash)
1202
- if (!list) {
1203
- list = []
1204
- out.set(r.entry_hash, list)
1205
- }
1206
- list.push({ path: r.path, size: r.size_bytes, mode: r.mode, mtimeMs: r.mtime_ms })
1207
- }
1208
- return out
828
+ return this.outputs.loadOutputFilesBatch(hashes)
1209
829
  }
1210
-
1211
- async isOutputsCurrent(projectDir: string, expected: readonly OutputFileRow[]): Promise<boolean> {
1212
- // Empty manifest case (a task produced no outputs) → trivially
1213
- // current; the on-disk tree under projectDir is whatever it was,
1214
- // and nothing was supposed to land there.
1215
- if (expected.length === 0) return true
1216
- const results = await Promise.all(
1217
- expected.map(async (e) => {
1218
- try {
1219
- const s = await stat(path.join(projectDir, e.path))
1220
- return (
1221
- s.size === e.size &&
1222
- (s.mode & 0o777) === (e.mode & 0o777) &&
1223
- // MILLISECOND comparison (sub-ms tolerance for the float
1224
- // round-trip through utimes). Save rows carry stat-ms and
1225
- // restoreOutputs re-syncs restored files to the row value,
1226
- // so equality holds exactly in steady state; legacy
1227
- // second-precision rows converge on their first restore.
1228
- // Residual blind spot: a same-size edit landing in the SAME
1229
- // millisecond as the recorded write, or a deliberately
1230
- // forged mtime (touch -r) — the trade every mtime-based
1231
- // skip check accepts.
1232
- Math.abs(s.mtimeMs - e.mtimeMs) < 1
1233
- )
1234
- } catch {
1235
- return false
1236
- }
1237
- }),
1238
- )
1239
- return results.every(Boolean)
830
+ isOutputsCurrent(projectDir: string, expected: readonly OutputFileRow[]): Promise<boolean> {
831
+ return this.outputs.isOutputsCurrent(projectDir, expected)
1240
832
  }
1241
-
1242
833
  outputsPath(hash: string): string {
1243
834
  return this.tarPath(hash)
1244
835
  }
1245
836
 
837
+ async recordOutputDirs(
838
+ hash: string,
839
+ projectDir: string,
840
+ prefixes: readonly string[],
841
+ ): Promise<void> {
842
+ await this.outputs.recordOutputDirs(hash, projectDir, prefixes)
843
+ }
844
+ loadOutputDirsBatch(hashes: readonly string[]): Map<string, OutputDirRow[]> {
845
+ return this.outputs.loadOutputDirsBatch(hashes)
846
+ }
847
+ outputDirsCurrent(projectDir: string, rows: readonly OutputDirRow[]): Promise<boolean> {
848
+ return this.outputs.outputDirsCurrent(projectDir, rows)
849
+ }
1246
850
  async restoreOutputs(hash: string, projectDir: string, workspaceRoot?: string): Promise<void> {
1247
- // In-process tar extraction — no fork+exec on the hot path
851
+ // In-process extraction — no fork+exec on the hot path
1248
852
  // (~5-10ms reclaimed per hit vs the prior subprocess `tar -xf`).
1249
853
  //
1250
854
  // The "tree is already current" skip-everything check happens at
@@ -1256,52 +860,84 @@ export class Cache implements CacheLayer {
1256
860
  // path — they're surfaced via `get()` for the orchestrator to
1257
861
  // replay through the logger.
1258
862
  const src = this.tarPath(hash)
1259
- if (!(await Bun.file(src).exists())) return
1260
- const compressed = await Bun.file(src).bytes()
1261
- // Local artifact (already validated at ingest): trusted, so a missing
1262
- // declared size is allowed; the oversize ceiling still applies.
1263
- const tarBytes = await zstdDecompressBounded(compressed, hash, true)
1264
-
1265
- const headers = parseTarHeaders(tarBytes)
1266
- if (
1267
- !headers.some(
1268
- (h) => h.name.startsWith('outputs/') || h.name.startsWith(WORKSPACE_OUTPUT_PREFIX),
1269
- )
1270
- ) {
1271
- return
863
+ // The caller already committed to this hit and WIPED the declared
864
+ // outputs, so returning quietly here would report a green cache hit over
865
+ // an emptied output tree. The artifact existed when `get()` probed it, so
866
+ // its absence now means something removed it underneath us (a concurrent
867
+ // `vx cache prune` is the documented way) — fail loud; the task re-runs.
868
+ const endExists = span('restore: exists')
869
+ const exists = await Bun.file(src).exists()
870
+ endExists()
871
+ if (!exists) {
872
+ throw new CorruptArtifactError(hash, 'artifact file vanished before restore')
873
+ }
874
+ const endRows = span('restore: rows')
875
+ // The index says exactly which files this entry materializes. If the
876
+ // archive cannot produce one of them, restoring "successfully" leaves a
877
+ // hole that no later run detects: the skip-restore check compares the
878
+ // same truncated expectation against the same truncated tree and agrees
879
+ // forever. Refuse instead of silently under-restoring — checked before
880
+ // anything is renamed into place.
881
+ const rows = this.loadOutputFilesBatch([hash]).get(hash) ?? []
882
+ const expected = rows
883
+ .filter((r) => workspaceRoot !== undefined || !r.path.startsWith(WORKSPACE_OUTPUT_PREFIX))
884
+ .map((r) => (r.path.startsWith(WORKSPACE_OUTPUT_PREFIX) ? r.path : `outputs/${r.path}`))
885
+ endRows()
886
+ const verify = (provided: ReadonlySet<string>): void => {
887
+ const missing = expected.filter((name) => !provided.has(name))
888
+ if (missing.length > 0) {
889
+ throw new CorruptArtifactError(
890
+ hash,
891
+ `artifact is missing ${missing.length} recorded output(s): ${missing.slice(0, 3).join(', ')}`,
892
+ )
893
+ }
1272
894
  }
1273
895
 
1274
- await extractOutputs(tarBytes, projectDir, workspaceRoot)
1275
-
1276
- // Re-sync restored files' mtimes to the manifest rows (extract set
1277
- // them from the tar's SECOND-precision headers). After this, disk
1278
- // matches the rows at millisecond precision, so isOutputsCurrent
1279
- // compares exactly — and any later edit moves the mtime off the
1280
- // recorded historical value and is detected.
1281
- const rows = this.loadOutputFilesBatch([hash]).get(hash)
1282
- if (rows !== undefined) {
1283
- await Promise.all(
1284
- rows.map(async (r) => {
1285
- try {
1286
- const abs = r.path.startsWith(WORKSPACE_OUTPUT_PREFIX)
1287
- ? path.join(workspaceRoot ?? projectDir, r.path.slice(WORKSPACE_OUTPUT_PREFIX.length))
1288
- : path.join(projectDir, r.path)
1289
- const t = r.mtimeMs / 1000
1290
- await utimes(abs, t, t)
1291
- } catch {
1292
- // File absent from this extract (or unwritable) — the next
1293
- // probe simply restores again; never fail a hit over mtimes.
1294
- }
1295
- }),
1296
- )
896
+ // One tar reader and one extractor either way; only the decode differs
897
+ // by size. A large artifact is decoded and read as it streams, so the
898
+ // restore costs a chunk of memory, not 4× the artifact (measured
899
+ // 2026-09-03 on 150 MiB: +644 MiB in memory, +49 MiB streamed, same
900
+ // wall time). A small one is decoded in one call: the stream setup
901
+ // costs ~35 µs per artifact, which is 4% of the headline restore row
902
+ // when every artifact is a one-file `dist/` (measured: 390 vs 355 ms
903
+ // per 1 000). The local artifact was validated at ingest, so a missing
904
+ // declared size is allowed; the output ceiling applies to both.
905
+ const endExtract = span('restore: extract')
906
+ try {
907
+ // Every step here is an async filesystem call on purpose: a
908
+ // synchronous restore of a small artifact is 2× faster ALONE (1.25 →
909
+ // 0.62 ms sequential, 2026-09-10) and 30% slower in the run, where
910
+ // four workers overlap their round trips and a blocking one stalls
911
+ // the other three (1,000-project restore row 1.1–1.2 s → 1.5 s).
912
+ const tar = await decodedTar(Bun.file(src), hash)
913
+ await extractArtifactStream(tar, projectDir, workspaceRoot, verify)
914
+ } catch (err) {
915
+ if (err instanceof ArchiveSecurityError || err instanceof CorruptArtifactError) throw err
916
+ // What is on disk, not what is in the archive: a directory standing
917
+ // where the entry holds a file, or a file where it needs a directory.
918
+ // The clean removes everything the output globs cover, so this is a
919
+ // stray they do not — name it as such, not as a corrupt artifact.
920
+ const code = (err as NodeJS.ErrnoException).code
921
+ if (code === 'EISDIR' || code === 'ENOTDIR' || code === 'EEXIST' || code === 'ENOTEMPTY') {
922
+ throw new UserError(
923
+ `restore of ${hash} into ${projectDir} was blocked by what is on disk (${code}: ${(err as Error).message}). ` +
924
+ `Declared outputs are wiped before a restore, so this is a path the output globs do not cover — remove it and re-run.`,
925
+ )
926
+ }
927
+ throw new CorruptArtifactError(hash, 'artifact is not a readable archive', err)
928
+ } finally {
929
+ endExtract()
1297
930
  }
1298
931
  }
1299
932
 
1300
933
  async save(args: {
1301
934
  hash: string
1302
- entry: Omit<CacheEntry, 'hash' | 'storedAt' | 'outputFiles'>
935
+ /** See `CacheLayer.save` — `exitCode` is not a caller's to supply. */
936
+ entry: Omit<CacheEntry, 'hash' | 'storedAt' | 'outputFiles' | 'exitCode'>
1303
937
  projectDir: string
1304
938
  outputFiles: string[]
939
+ /** See `CacheLayer.save`. */
940
+ skipLocalWrite?: boolean
1305
941
  workspaceOutputFiles?: string[]
1306
942
  workspaceRoot?: string
1307
943
  inputComponents?: readonly TaskInputRow[]
@@ -1324,23 +960,17 @@ export class Cache implements CacheLayer {
1324
960
  // still uploads to remote — it calls `packArtifact` itself to get
1325
961
  // the bytes, since there's no local artifact to read off disk.
1326
962
  if (!this.write) return
1327
- const compressed = await this.packArtifact(args)
1328
- await this.writeArtifactAndIndex(
1329
- args.hash,
1330
- compressed,
1331
- {
1332
- taskId: args.entry.taskId,
1333
- command: args.entry.command,
1334
- durationMs: args.entry.durationMs,
1335
- ...(args.inputComponents !== undefined ? { inputComponents: args.inputComponents } : {}),
1336
- },
1337
- // Save-path rows get millisecond mtimes from a fresh stat (tar
1338
- // headers carry only seconds) — see the row loop for the guard.
1339
- {
1340
- projectDir: args.projectDir,
1341
- ...(args.workspaceRoot !== undefined ? { workspaceRoot: args.workspaceRoot } : {}),
1342
- },
1343
- )
963
+ if (args.skipLocalWrite === true) return
964
+ await mkdir(this.cacheDir, { recursive: true })
965
+ const endPack = span('save: pack')
966
+ const compressed = await this.packArtifactToTemp(this.tempPath(args.hash), args)
967
+ endPack()
968
+ await this.writeArtifactAndIndex(args.hash, compressed, {
969
+ taskId: args.entry.taskId,
970
+ command: args.entry.command,
971
+ durationMs: args.entry.durationMs,
972
+ ...(args.inputComponents !== undefined ? { inputComponents: args.inputComponents } : {}),
973
+ })
1344
974
  }
1345
975
 
1346
976
  /**
@@ -1357,8 +987,8 @@ export class Cache implements CacheLayer {
1357
987
  * index. Used by the LayeredCache when local writes are disabled but
1358
988
  * a remote upload still needs the artifact bytes.
1359
989
  */
1360
- async packArtifactBytes(args: SaveArgs): Promise<Uint8Array> {
1361
- return await this.packArtifact(args)
990
+ packArtifactBytes(args: SaveArgs): Promise<Uint8Array> {
991
+ return this.packArtifact(args)
1362
992
  }
1363
993
 
1364
994
  async ingest(hash: string, compressed: Uint8Array, meta: IngestMeta): Promise<void> {
@@ -1366,179 +996,201 @@ export class Cache implements CacheLayer {
1366
996
  }
1367
997
 
1368
998
  /**
1369
- * Stage stdout + outputs, tar them, zstd-compress, return the bytes.
1370
- * No disk write to the final cache path — that's the index step's
1371
- * job. Pure transform, so `ingest()` can skip this and just hand its
1372
- * remote-supplied bytes straight to `writeArtifactAndIndex`.
999
+ * Collect stdout + outputs into artifact bytes, zstd-compress, return
1000
+ * them. No disk write to the final cache path — that's the index
1001
+ * step's job. Pure transform, so `ingest()` can skip this and hand
1002
+ * its remote-supplied bytes straight to `writeArtifactAndIndex`.
1003
+ *
1004
+ * Entries are named directly into the archive, so there is no staging
1005
+ * copy of every output byte and no `tar` subprocess (see
1006
+ * `archive.ts`).
1007
+ */
1008
+ /** Archive name → absolute source path for every declared output. */
1009
+ private outputsOf(args: {
1010
+ projectDir: string
1011
+ outputFiles: string[]
1012
+ workspaceOutputFiles?: string[]
1013
+ workspaceRoot?: string
1014
+ }): Map<string, string> {
1015
+ const outputs = new Map<string, string>()
1016
+ for (const f of args.outputFiles) {
1017
+ outputs.set(`outputs/${path.relative(args.projectDir, f)}`, f)
1018
+ }
1019
+ // Caller passes workspaceRoot whenever workspaceOutputFiles is
1020
+ // non-empty; the rels are root-anchored by construction.
1021
+ for (const f of args.workspaceOutputFiles ?? []) {
1022
+ outputs.set(`${WORKSPACE_OUTPUT_PREFIX}${path.relative(args.workspaceRoot!, f)}`, f)
1023
+ }
1024
+ return outputs
1025
+ }
1026
+
1027
+ /**
1028
+ * The compressed artifact, in memory: the remote upload when local
1029
+ * writes are off. A large artifact is packed and compressed as a
1030
+ * stream and collected — the seam takes bytes.
1373
1031
  */
1374
1032
  private async packArtifact(args: {
1375
- hash: string
1376
- entry: Omit<CacheEntry, 'hash' | 'storedAt' | 'outputFiles'>
1033
+ entry: Omit<CacheEntry, 'hash' | 'storedAt' | 'outputFiles' | 'exitCode'>
1377
1034
  projectDir: string
1378
1035
  outputFiles: string[]
1379
1036
  workspaceOutputFiles?: string[]
1380
1037
  workspaceRoot?: string
1381
1038
  }): Promise<Uint8Array> {
1382
- const wsOutputFiles = args.workspaceOutputFiles ?? []
1383
- const stage = await mkdtemp(path.join(os.tmpdir(), 'vx-save-'))
1039
+ // stdout is ALWAYS present in the artifact, even if empty, so the
1040
+ // layout is predictable: a successful read finds `stdout` and
1041
+ // zero-or-more `outputs/<rel>` / `workspace-outputs/<rel>` entries.
1042
+ const plan = await planArtifact({
1043
+ stdout: args.entry.stdout ?? '',
1044
+ outputs: this.outputsOf(args),
1045
+ exec: usageOfEntry(args.entry),
1046
+ })
1047
+ if (plan.size <= STREAM_DECODE_FROM)
1048
+ return await Bun.zstdCompress(await packArtifactBytes(plan))
1049
+ return bytesOf(packArtifactStream(plan).pipeThrough(zstdEncoder()))
1050
+ }
1051
+
1052
+ /**
1053
+ * Pack for the local save. A large artifact is read from disk as it is
1054
+ * written and compressed as a stream straight into the temp, so it
1055
+ * never sits in memory (measured 2026-09-03 on a 150 MiB output: +705
1056
+ * MiB before). The streamed compressor is ~2.4× the one-call cost per
1057
+ * byte and a stream costs ~35 µs to set up, so a small artifact is
1058
+ * packed in memory and compressed in one call — returned as bytes,
1059
+ * nothing written yet.
1060
+ */
1061
+ private async packArtifactToTemp(
1062
+ tmpPath: string,
1063
+ args: Parameters<Cache['packArtifact']>[0],
1064
+ ): Promise<Uint8Array | { tmpPath: string }> {
1065
+ const plan = await planArtifact({
1066
+ stdout: args.entry.stdout ?? '',
1067
+ outputs: this.outputsOf(args),
1068
+ exec: usageOfEntry(args.entry),
1069
+ })
1070
+ if (plan.size <= STREAM_DECODE_FROM)
1071
+ return await Bun.zstdCompress(await packArtifactBytes(plan))
1072
+ const sink = Bun.file(tmpPath).writer()
1384
1073
  try {
1385
- if (args.outputFiles.length > 0) {
1386
- const stageOutputs = path.join(stage, 'outputs')
1387
- await Promise.all(
1388
- args.outputFiles.map(async (f) => {
1389
- const rel = path.relative(args.projectDir, f)
1390
- const dest = path.join(stageOutputs, rel)
1391
- // Bun.write creates parent dirs as needed.
1392
- await Bun.write(dest, Bun.file(f))
1393
- }),
1394
- )
1074
+ for await (const chunk of packArtifactStream(plan).pipeThrough(zstdEncoder())) {
1075
+ await sink.write(chunk)
1395
1076
  }
1396
- if (wsOutputFiles.length > 0) {
1397
- // Caller passes workspaceRoot whenever workspaceOutputFiles is
1398
- // non-empty; the rels are root-anchored by construction.
1399
- const stageWs = path.join(stage, 'workspace-outputs')
1400
- await Promise.all(
1401
- wsOutputFiles.map(async (f) => {
1402
- const rel = path.relative(args.workspaceRoot!, f)
1403
- await Bun.write(path.join(stageWs, rel), Bun.file(f))
1404
- }),
1405
- )
1406
- }
1407
- // stdout is ALWAYS present in the artifact, even if empty, so the
1408
- // archive layout is predictable: a successful read finds `stdout`
1409
- // and zero-or-more `outputs/<rel>` / `workspace-outputs/<rel>`
1410
- // entries.
1411
- await Bun.write(path.join(stage, 'stdout'), args.entry.stdout ?? '')
1412
-
1413
- const topLevel: string[] = ['stdout']
1414
- if (wsOutputFiles.length > 0) topLevel.unshift('workspace-outputs')
1415
- if (args.outputFiles.length > 0) topLevel.unshift('outputs')
1416
-
1417
- // `--format=ustar` forces strict POSIX ustar — no PAX extended-
1418
- // header records. BSD tar (macOS default) emits PAX per entry
1419
- // by default for xattrs / mtime-nanos; those records would
1420
- // otherwise show up as junk `PaxHeaders/<name>` entries in our
1421
- // restored trees. GNU tar also accepts the flag (no-op on its
1422
- // side). Names > 100 chars still work via ustar's prefix+name
1423
- // (255 chars) or GNU longname fallback if the tar binary
1424
- // chooses to emit one.
1425
- const proc = Bun.spawn(['tar', '--format=ustar', '-cf', '-', '-C', stage, ...topLevel], {
1426
- stdout: 'pipe',
1427
- stderr: 'pipe',
1428
- // COPYFILE_DISABLE blocks Apple's copyfile() from attaching
1429
- // xattrs to staged files via inherited child copies; tar then
1430
- // has nothing to emit AppleDouble for.
1431
- env: { ...process.env, COPYFILE_DISABLE: '1' },
1432
- })
1433
- const [tarBytes, stderrText] = await Promise.all([
1434
- new Response(proc.stdout).bytes(),
1435
- new Response(proc.stderr).text(),
1436
- ])
1437
- await proc.exited
1438
- if (proc.exitCode !== 0) {
1439
- throw new Error(`save: tar exited ${proc.exitCode}: ${stderrText.trim()}`)
1440
- }
1441
- return await Bun.zstdCompress(tarBytes)
1442
- } finally {
1443
- await rm(stage, { recursive: true, force: true })
1077
+ } catch (err) {
1078
+ // An output that vanished or changed shape between the plan's stat
1079
+ // and its read: the partial temp must not outlive the failure.
1080
+ await sink.end()
1081
+ await unlink(tmpPath).catch(() => undefined)
1082
+ throw err
1444
1083
  }
1084
+ await sink.end()
1085
+ return { tmpPath }
1445
1086
  }
1446
1087
 
1447
1088
  /**
1448
- * Atomically write `compressed` to `<hash>.tar.zst` and (re)build
1449
- * the entries + output_files SQL rows from the tar headers. Shared
1450
- * by `save()` (we just packed the bytes) and `ingest()` (we got
1451
- * them from the remote layer).
1089
+ * Atomically write `compressed` to `<hash>.tar.zst` and (re)build the
1090
+ * entries + output_files SQL rows from the archive itself. Shared by
1091
+ * `save()` (we just packed the bytes) and `ingest()` (we got them from
1092
+ * the remote layer) — both index the identical values, because both
1093
+ * read them out of the artifact.
1452
1094
  */
1095
+ /** tmp suffix mixes pid + hrtime + a random hex chunk so two saves of
1096
+ * the same hash from the same process (or from two forked workers that
1097
+ * happen to share a wall-clock ms) don't pick the same tmp filename and
1098
+ * race on the rename. */
1099
+ private tempPath(hash: string): string {
1100
+ return `${this.tarPath(hash)}.tmp-${process.pid}-${process.hrtime.bigint()}-${Math.random().toString(36).slice(2, 10)}`
1101
+ }
1102
+
1453
1103
  private async writeArtifactAndIndex(
1454
1104
  hash: string,
1455
- compressed: Uint8Array,
1105
+ compressed: Uint8Array | { tmpPath: string },
1456
1106
  meta: IngestMeta,
1457
- statBase?: { projectDir: string; workspaceRoot?: string },
1458
1107
  ): Promise<void> {
1459
1108
  // Validate BEFORE anything touches the final path. `ingest()` feeds
1460
1109
  // us network bytes; a truncated/garbage body that went live first
1461
1110
  // would leave a corrupt `<hash>.tar.zst` behind (with no SQL row,
1462
1111
  // since the decompress throw aborted indexing) for every later
1463
1112
  // reader to trip over. Decompress + parse also produce the
1464
- // `output_files` rows: same headers extractOutputs will see on
1113
+ // `output_files` rows: same headers the restore will see on
1465
1114
  // restore, so the size/mode/mtime fingerprint we store matches
1466
1115
  // what isOutputsCurrent will compare against post-restore.
1467
- let tarBytes: Uint8Array
1116
+ const finalPath = this.tarPath(hash)
1117
+ let tmpPath: string
1118
+ if (compressed instanceof Uint8Array) {
1119
+ tmpPath = this.tempPath(hash)
1120
+ await mkdir(this.cacheDir, { recursive: true })
1121
+ // The temp is written BEFORE validation so a large artifact can be
1122
+ // scanned from the file as it decodes — a file stream reads in
1123
+ // bounded pieces; the bytes in memory would not (see `decodedTar`).
1124
+ // The final path is still untouched until the archive has passed.
1125
+ // node's writeFile, not Bun.write: the latter copies the buffer first
1126
+ // (measured on 150 MiB: +151 MiB and 33 ms against +0 and 21 ms).
1127
+ const endWrite = span('save: write temp')
1128
+ await writeFile(tmpPath, compressed)
1129
+ endWrite()
1130
+ } else {
1131
+ // `save` already streamed the artifact into its temp.
1132
+ tmpPath = compressed.tmpPath
1133
+ }
1134
+ let scanned: Awaited<ReturnType<typeof scanArtifact>>
1468
1135
  try {
1469
1136
  // ingest() is the UNTRUSTED boundary — `compressed` is bytes just
1470
1137
  // pulled from a remote. Refuse a bomb (declared or sizeless) before it
1471
1138
  // can expand into memory.
1472
- tarBytes = await zstdDecompressBounded(compressed, hash, false)
1139
+ const source =
1140
+ compressed instanceof Uint8Array && compressed.byteLength <= STREAM_DECODE_FROM
1141
+ ? compressed
1142
+ : Bun.file(tmpPath)
1143
+ const endScan = span('save: scan')
1144
+ scanned = await scanArtifact(await decodedTar(source, hash))
1145
+ endScan()
1146
+ // v17 invariant: every artifact carries a `stdout` entry. Its
1147
+ // absence means the bytes decompressed but aren't a vx artifact.
1148
+ if (scanned.stdout === null) {
1149
+ throw new CorruptArtifactError(hash, 'missing stdout entry')
1150
+ }
1473
1151
  } catch (err) {
1474
- if (err instanceof CorruptArtifactError) throw err
1475
- throw new CorruptArtifactError(hash, 'zstd decompression failed', err)
1152
+ await unlink(tmpPath).catch(() => undefined)
1153
+ if (err instanceof ArchiveSecurityError || err instanceof CorruptArtifactError) throw err
1154
+ throw new CorruptArtifactError(hash, 'artifact is not a readable archive', err)
1476
1155
  }
1477
- const headers = parseTarHeaders(tarBytes)
1478
- // v17 invariant: every artifact carries a `stdout` entry. Its
1479
- // absence means the bytes decompressed but aren't a vx artifact.
1480
- if (!headers.some((h) => h.name === 'stdout' && !h.isDir)) {
1481
- throw new CorruptArtifactError(hash, 'missing stdout entry')
1482
- }
1483
-
1484
- const finalPath = this.tarPath(hash)
1485
- // tmp suffix mixes pid + hrtime + a random hex chunk so two saves
1486
- // of the same hash from the same process (or from two forked
1487
- // workers that happen to share a wall-clock ms) don't pick the
1488
- // same tmp filename and race on the rename.
1489
- const tmpPath = `${finalPath}.tmp-${process.pid}-${process.hrtime.bigint()}-${Math.random().toString(36).slice(2, 10)}`
1490
- await mkdir(this.cacheDir, { recursive: true })
1491
- await Bun.write(tmpPath, compressed)
1156
+ const { entries } = scanned
1492
1157
  // POSIX rename atomically REPLACES the destination if it exists,
1493
1158
  // so we don't need a pre-rm. The pre-rm was actively harmful —
1494
1159
  // it opened a race window where writer B could delete writer A's
1495
1160
  // just-renamed file BEFORE A's subsequent stat, producing a
1496
1161
  // spurious ENOENT. The rename itself preserves the "either-or"
1497
1162
  // semantics for concurrent readers.
1163
+ const endRename = span('save: rename')
1498
1164
  await rename(tmpPath, finalPath)
1165
+ endRename()
1499
1166
 
1500
- const totalBytes = compressed.byteLength
1167
+ const totalBytes =
1168
+ compressed instanceof Uint8Array ? compressed.byteLength : Bun.file(finalPath).size
1501
1169
  const outputFileRows: Array<[string, number, number, number]> = []
1502
1170
  // Per-output-file fingerprint rows feed the skip-restore check.
1503
1171
  // Row paths: project entries store the bare rel (`outputs/`
1504
1172
  // stripped); workspace entries keep the full
1505
1173
  // `workspace-outputs/<rel>` name as the namespace discriminator.
1506
- for (const h of headers) {
1507
- if (h.isDir) continue
1174
+ //
1175
+ // Mode and MILLISECOND mtime come from the artifact's own sidecar
1176
+ // (`.vx-meta.json`), written from a stat taken while packing. Both
1177
+ // paths — save and remote ingest — therefore index the same values,
1178
+ // and `restoreOutputs` materialises exactly them, so
1179
+ // `isOutputsCurrent` compares equal at ms precision straight after a
1180
+ // restore. (Tar headers carry seconds, which is why the save path
1181
+ // used to need a second stat pass the ingest path could not have.)
1182
+ for (const e of entries) {
1508
1183
  let rowPath: string | null = null
1509
- if (h.name.startsWith('outputs/')) {
1510
- const rel = h.name.slice('outputs/'.length)
1184
+ if (e.name.startsWith('outputs/')) {
1185
+ const rel = e.name.slice('outputs/'.length)
1511
1186
  if (rel.length > 0) rowPath = rel
1512
- } else if (h.name.startsWith(WORKSPACE_OUTPUT_PREFIX)) {
1513
- if (h.name.length > WORKSPACE_OUTPUT_PREFIX.length) rowPath = h.name
1187
+ } else if (e.name.startsWith(WORKSPACE_OUTPUT_PREFIX)) {
1188
+ if (e.name.length > WORKSPACE_OUTPUT_PREFIX.length) rowPath = e.name
1514
1189
  }
1515
1190
  if (rowPath === null) continue
1516
- let mtimeMs = Math.floor(h.mtimeMs)
1517
- // Save path only (ingest has no filesystem to consult): tar
1518
- // headers truncate mtimes to SECONDS, which is what made the
1519
- // skip-restore check blind to same-second same-size edits. A
1520
- // fresh stat refines the row to millisecond precision — but only
1521
- // when the size still matches the packed header, so the row
1522
- // always describes the ARTIFACT's bytes; a file mutated between
1523
- // pack and stat keeps the coarse header time (degraded, not
1524
- // wrong). restoreOutputs re-syncs disk mtimes to these rows.
1525
- if (statBase !== undefined) {
1526
- try {
1527
- const abs = rowPath.startsWith(WORKSPACE_OUTPUT_PREFIX)
1528
- ? path.join(
1529
- statBase.workspaceRoot ?? statBase.projectDir,
1530
- rowPath.slice(WORKSPACE_OUTPUT_PREFIX.length),
1531
- )
1532
- : path.join(statBase.projectDir, rowPath)
1533
- const st = await stat(abs)
1534
- if (st.size === h.size) mtimeMs = Math.floor(st.mtimeMs)
1535
- } catch {
1536
- // stat failed — keep the header-seconds fallback.
1537
- }
1538
- }
1539
- outputFileRows.push([rowPath, h.size, h.mode & 0o777, mtimeMs])
1191
+ outputFileRows.push([rowPath, e.size, e.mode & 0o777, Math.floor(e.mtimeMs)])
1540
1192
  }
1541
- const stdoutText = readTarText(tarBytes, headers, 'stdout')
1193
+ const stdoutText = scanned.stdout
1542
1194
 
1543
1195
  const [project, task] = splitTaskId(meta.taskId)
1544
1196
  const now = Date.now()
@@ -1549,8 +1201,7 @@ export class Cache implements CacheLayer {
1549
1201
  // (not the per-run path) so a warm all-cache-hit run — which never
1550
1202
  // saves — writes none of them.
1551
1203
  const insertEntry = this.insertEntry
1552
- const insertOutputFile = this.insertOutputFile
1553
- const deleteOutputFiles = this.deleteOutputFiles
1204
+ const outputs = this.outputs
1554
1205
  const insertEntryInput = this.insertEntryInput
1555
1206
  const inputComponents = meta.inputComponents
1556
1207
  const tx = this.db.transaction(() => {
@@ -1559,19 +1210,23 @@ export class Cache implements CacheLayer {
1559
1210
  project,
1560
1211
  task,
1561
1212
  meta.command,
1562
- 0, // exitCode: we never cache failures
1213
+ // exitCode. Pinned, not supplied: vx caches only successes, and neither
1214
+ // `save` nor `ingest` accepts one (see their arg types). The column
1215
+ // stays because the READ side still defends against a non-zero value —
1216
+ // a row from a foreign or hand-edited cache.db classifies the hit
1217
+ // `failed` rather than laundering a broken build into a green run.
1218
+ 0,
1563
1219
  meta.durationMs,
1564
1220
  totalBytes,
1565
1221
  stdoutText,
1566
1222
  now,
1567
1223
  now,
1224
+ // From the artifact, on both paths: a save indexes what it just
1225
+ // packed, an ingest what the producing machine packed.
1226
+ scanned.exec?.cpuMs ?? null,
1227
+ scanned.exec?.peakRssBytes ?? null,
1568
1228
  )
1569
- // Replace the entry's existing output_files rows (an UPDATE on
1570
- // the same hash should refresh, not append).
1571
- deleteOutputFiles.run(hash)
1572
- for (const [rel, size, mode, mtime] of outputFileRows) {
1573
- insertOutputFile.run(hash, rel, size, mode, mtime)
1574
- }
1229
+ outputs.replaceFileRows(hash, outputFileRows)
1575
1230
  // INSERT OR IGNORE: identical inputs derive this same hash, so a
1576
1231
  // re-save's rows are identical — keep the first set, skip the rest.
1577
1232
  if (inputComponents !== undefined) {
@@ -1580,7 +1235,9 @@ export class Cache implements CacheLayer {
1580
1235
  }
1581
1236
  }
1582
1237
  })
1238
+ const endTx = span('save: index tx')
1583
1239
  tx()
1240
+ endTx()
1584
1241
  }
1585
1242
 
1586
1243
  /** Apply the deferred accessed_at bumps in one statement. */
@@ -1611,64 +1268,36 @@ export class Cache implements CacheLayer {
1611
1268
  return this.db
1612
1269
  }
1613
1270
 
1614
- /**
1615
- * Content-addressed storage view over the same artifacts directory.
1616
- * Returns an `FsCASBackend` pointing at `cacheDir`, so external
1617
- * subsystems (R2 mirror, REAPI CAS bridge, analytics scanners) can
1618
- * read raw bytes with a `Digest`-keyed API without coupling to
1619
- * Cache's internal save path. Read/write semantics match what
1620
- * Cache.save writes (`<cacheDir>/<hash>.tar.zst`).
1621
- */
1622
- contentBackend(): FsCASBackend {
1623
- return new FsCASBackend(this.cacheDir)
1624
- }
1625
-
1271
+ // --- run history: delegated to `RunHistory` (see run-history.ts) ---
1626
1272
  recordRun(run: RunRecord): void {
1627
- this.insertRun.run(...bindRun(run))
1273
+ this.history.recordRun(run)
1628
1274
  }
1629
-
1630
1275
  recordRuns(runs: readonly RunRecord[]): void {
1631
- if (runs.length === 0) return
1632
- if (runs.length === 1) {
1633
- this.insertRun.run(...bindRun(runs[0]!))
1634
- return
1635
- }
1636
- // `bun:sqlite`'s `transaction()` returns a callable that wraps the
1637
- // body in BEGIN/COMMIT, fsyncing once at the end. For a 200-task
1638
- // run that's one fsync instead of 200.
1639
- const insert = this.insertRun
1640
- const tx = this.db.transaction((batch: readonly RunRecord[]) => {
1641
- for (const r of batch) insert.run(...bindRun(r))
1642
- })
1643
- tx(runs)
1276
+ this.history.recordRuns(runs)
1644
1277
  }
1645
-
1646
1278
  recordRunBundle(bundle: { runs: readonly RunRecord[]; invocation: InvocationRecord }): void {
1647
- // Whole run records atomically — one transaction, one fsync: the
1648
- // per-task `runs` rows and the one `invocations` header. The
1649
- // input-fingerprint rows do NOT live here: they're written inside
1650
- // the entry-save transaction (`save`/`ingest`) so a warm
1651
- // all-cache-hit run — which writes no `runs`-vs-`entry_inputs`
1652
- // mismatch — pays nothing for the moat it isn't refreshing.
1653
- const insertRun = this.insertRun
1654
- const insertInvocation = this.insertInvocation
1655
- this.db.transaction(() => {
1656
- for (const r of bundle.runs) insertRun.run(...bindRun(r))
1657
- insertInvocation.run(...bindInvocation(bundle.invocation))
1658
- })()
1279
+ this.history.recordRunBundle(bundle)
1659
1280
  }
1660
-
1661
- stats(): CacheStats {
1281
+ stats(opts: CacheStatsOptions = {}): CacheStats {
1662
1282
  this.flushAccessed()
1283
+ const project = opts.project
1284
+ const scoped = project !== undefined
1285
+ const scopeParams: string[] = project === undefined ? [] : [project]
1663
1286
  const aggregate = this.db
1664
- .prepare('SELECT COUNT(*) AS n, COALESCE(SUM(size_bytes), 0) AS bytes FROM entries')
1665
- .get() as { n: number; bytes: number }
1287
+ .prepare(
1288
+ `SELECT COUNT(*) AS n, COALESCE(SUM(size_bytes), 0) AS bytes FROM entries${
1289
+ scoped ? ' WHERE project = ?' : ''
1290
+ }`,
1291
+ )
1292
+ .get(...scopeParams) as { n: number; bytes: number }
1666
1293
  const since = Date.now() - 24 * 60 * 60 * 1000
1667
1294
  const runs = this.db
1668
1295
  .prepare(
1669
- "SELECT COUNT(*) AS total, COALESCE(SUM(CASE WHEN status IN ('cache-hit', 'cache-hit-remote') THEN 1 ELSE 0 END), 0) AS hits FROM runs WHERE started_at >= ?",
1296
+ `SELECT COUNT(*) AS total, COALESCE(SUM(CASE WHEN status IN ('cache-hit', 'cache-hit-remote') THEN 1 ELSE 0 END), 0) AS hits FROM runs WHERE started_at >= ? AND ${EXECUTED_RUNS_SQL}${
1297
+ scoped ? ' AND project = ?' : ''
1298
+ }`,
1670
1299
  )
1671
- .get(since) as { total: number; hits: number }
1300
+ .get(since, ...scopeParams) as { total: number; hits: number }
1672
1301
  return {
1673
1302
  entryCount: aggregate.n,
1674
1303
  totalBytes: aggregate.bytes,
@@ -1679,7 +1308,7 @@ export class Cache implements CacheLayer {
1679
1308
 
1680
1309
  async prune(options: PruneOptions): Promise<PruneResult> {
1681
1310
  this.flushAccessed()
1682
- const { olderThanMs, maxBytes } = options
1311
+ const { olderThanMs, maxBytes, dryRun = false } = options
1683
1312
  if (olderThanMs === undefined && maxBytes === undefined) {
1684
1313
  throw new Error('prune: pass at least one of `olderThanMs` or `maxBytes`')
1685
1314
  }
@@ -1726,6 +1355,15 @@ export class Cache implements CacheLayer {
1726
1355
  // Promise.all over the unlinks. The IN-list is chunked at 900 like
1727
1356
  // flushAccessed so a huge eviction stays under any build's
1728
1357
  // bound-parameter ceiling.
1358
+ if (dryRun) {
1359
+ const orphans = await this.orphanStats()
1360
+ return {
1361
+ evicted: victims.size,
1362
+ bytesFreed,
1363
+ orphans: orphans.orphans,
1364
+ orphanBytes: orphans.orphanBytes,
1365
+ }
1366
+ }
1729
1367
  if (victims.size > 0) {
1730
1368
  const hashes = [...victims]
1731
1369
  this.db.transaction(() => {
@@ -1739,7 +1377,83 @@ export class Cache implements CacheLayer {
1739
1377
  await Promise.all(hashes.map((h) => rm(this.tarPath(h), { force: true })))
1740
1378
  }
1741
1379
 
1742
- return { evicted: victims.size, bytesFreed }
1380
+ const orphans = await this.reapOrphans()
1381
+ return { evicted: victims.size, bytesFreed, ...orphans }
1382
+ }
1383
+
1384
+ /**
1385
+ * Unlink artifacts the index does not know: a `<hash>.tar.zst` with no
1386
+ * `entries` row (a `SCHEMA_VERSION` drop, a `cache.db` deleted by hand)
1387
+ * and a `<hash>.tar.zst.tmp-*` a crashed save never renamed. Nothing
1388
+ * else reaps them — a lookup starts at the row, and a save of the same
1389
+ * key renames over the file, so a key that never recurs leaks its bytes
1390
+ * forever. Files younger than the grace window are left alone: a save
1391
+ * renames the artifact into place BEFORE its row commits, and its temp
1392
+ * exists while the bytes are still being written, so a fresh file
1393
+ * without a row is a save in flight, not an orphan.
1394
+ */
1395
+ private async reapOrphans(): Promise<{ orphans: number; orphanBytes: number }> {
1396
+ let orphans = 0
1397
+ let orphanBytes = 0
1398
+ await Promise.all(
1399
+ (await this.scanOrphans()).map(async (o) => {
1400
+ try {
1401
+ // unlink, not `rm({ force })`: force swallows ENOENT, and a file a
1402
+ // concurrent prune took first must not be counted as ours.
1403
+ await unlink(o.file)
1404
+ orphans += 1
1405
+ orphanBytes += o.size
1406
+ } catch {
1407
+ // Gone under us (a concurrent prune, a save's own cleanup): not ours.
1408
+ }
1409
+ }),
1410
+ )
1411
+ return { orphans, orphanBytes }
1412
+ }
1413
+
1414
+ /** What `prune()` would reap right now, for `vx info` to say before anyone prunes. */
1415
+ async orphanStats(): Promise<{ orphans: number; orphanBytes: number }> {
1416
+ const found = await this.scanOrphans()
1417
+ let orphanBytes = 0
1418
+ for (const o of found) orphanBytes += o.size
1419
+ return { orphans: found.length, orphanBytes }
1420
+ }
1421
+
1422
+ /** Row-less artifacts and temps past the in-flight grace window: one readdir, one stat per candidate. */
1423
+ private async scanOrphans(): Promise<Array<{ file: string; size: number }>> {
1424
+ let names: string[]
1425
+ try {
1426
+ names = await readdir(this.cacheDir)
1427
+ } catch {
1428
+ return []
1429
+ }
1430
+ const indexed = new Set(
1431
+ (this.db.prepare('SELECT hash FROM entries').all() as Array<{ hash: string }>).map(
1432
+ (r) => r.hash,
1433
+ ),
1434
+ )
1435
+ const cutoff = Date.now() - ORPHAN_GRACE_MS
1436
+ const candidates: string[] = []
1437
+ for (const name of names) {
1438
+ if (name.indexOf('.tar.zst.tmp-') > 0) {
1439
+ candidates.push(name)
1440
+ } else if (name.endsWith('.tar.zst') && !indexed.has(name.slice(0, -'.tar.zst'.length))) {
1441
+ candidates.push(name)
1442
+ }
1443
+ }
1444
+ const found: Array<{ file: string; size: number }> = []
1445
+ await Promise.all(
1446
+ candidates.map(async (name) => {
1447
+ const file = path.join(this.cacheDir, name)
1448
+ try {
1449
+ const st = await stat(file)
1450
+ if (st.isFile() && st.mtimeMs <= cutoff) found.push({ file, size: st.size })
1451
+ } catch {
1452
+ // Gone between readdir and stat: not an orphan any more.
1453
+ }
1454
+ }),
1455
+ )
1456
+ return found
1743
1457
  }
1744
1458
 
1745
1459
  close(): void {
@@ -1747,15 +1461,30 @@ export class Cache implements CacheLayer {
1747
1461
  // executed task per invocation — a 2000-task repo accretes ~20k
1748
1462
  // rows in days, inflating insert and checkpoint cost forever.
1749
1463
  // 30 days comfortably covers `vx stats` (24 h windows) and any
1750
- // CI-side analytics consumers.
1464
+ // CI-side analytics consumers. The `invocations` header table (one
1465
+ // row per `vx run`) is pruned on the SAME window — otherwise a
1466
+ // header would outlive its `runs` rows, so `vx info`/`vx last` would
1467
+ // list an invocation whose task detail is already gone, and the
1468
+ // table would grow unbounded on a long-lived checkout.
1751
1469
  try {
1752
- this.db
1753
- .prepare('DELETE FROM runs WHERE ended_at < ?')
1754
- .run(Date.now() - 30 * 24 * 60 * 60 * 1000)
1470
+ const cutoff = Date.now() - 30 * 24 * 60 * 60 * 1000
1471
+ this.history.pruneOlderThan(cutoff)
1472
+ this.configEvals.pruneOlderThan(cutoff)
1755
1473
  } catch {
1756
1474
  // Retention is best-effort; never block closing the handle.
1757
1475
  }
1758
- this.flushAccessed()
1476
+ try {
1477
+ this.flushAccessed()
1478
+ } catch {
1479
+ // Same contract as the retention prune above, which was already
1480
+ // guarded while this sibling was not: `accessed_at` is LRU
1481
+ // bookkeeping, never correctness, and the run's results are
1482
+ // already recorded by the time we get here. Letting it throw also
1483
+ // SKIPPED `db.close()` below, leaking the handle. Reachable when
1484
+ // the cache dir is removed under a live handle (a concurrent
1485
+ // `rm -rf .vx/cache`): macOS answers SQLITE_IOERR_VNODE on a
1486
+ // write to an unlinked file where Linux happily writes on.
1487
+ }
1759
1488
  this.db.close()
1760
1489
  }
1761
1490
 
@@ -1764,67 +1493,6 @@ export class Cache implements CacheLayer {
1764
1493
  }
1765
1494
  }
1766
1495
 
1767
- /**
1768
- * Bind a RunRecord to the positional parameters expected by the
1769
- * `insertRun` prepared statement (17 columns). Shared between the
1770
- * single and batched record paths.
1771
- */
1772
- function bindRun(run: RunRecord): SQLQueryBindings[] {
1773
- return [
1774
- run.hash,
1775
- run.project,
1776
- run.task,
1777
- run.status,
1778
- run.exitCode,
1779
- run.durationMs,
1780
- run.forwardArgs ? JSON.stringify(run.forwardArgs) : null,
1781
- run.startedAt,
1782
- run.endedAt,
1783
- run.runId ?? null,
1784
- run.cpuMs ?? null,
1785
- run.peakRssBytes ?? null,
1786
- run.wallclockStartNs !== undefined ? run.wallclockStartNs : null,
1787
- run.wallclockEndNs !== undefined ? run.wallclockEndNs : null,
1788
- run.cacheHit === undefined ? null : run.cacheHit ? 1 : 0,
1789
- run.attempts ?? null,
1790
- ]
1791
- }
1792
-
1793
- /**
1794
- * Bind an InvocationRecord to the positional parameters of the
1795
- * `insertInvocation` prepared statement (25 columns). Booleans map to
1796
- * 0/1; null-or-bool columns (`dirty`) keep null distinct from 0.
1797
- */
1798
- function bindInvocation(inv: InvocationRecord): SQLQueryBindings[] {
1799
- return [
1800
- inv.runId,
1801
- inv.command,
1802
- inv.requestedTasks,
1803
- inv.cachePolicy,
1804
- inv.concurrency,
1805
- inv.flow,
1806
- inv.startedAt,
1807
- inv.endedAt,
1808
- inv.totalDurationMs,
1809
- inv.taskCount,
1810
- inv.failedCount,
1811
- inv.hitCount,
1812
- inv.hitLocalCount,
1813
- inv.hitRemoteCount,
1814
- inv.exitOk ? 1 : 0,
1815
- inv.commitSha,
1816
- inv.branch,
1817
- inv.dirty === null ? null : inv.dirty ? 1 : 0,
1818
- inv.ci ? 1 : 0,
1819
- inv.ciProvider,
1820
- inv.host,
1821
- inv.os,
1822
- inv.arch,
1823
- inv.vxVersion,
1824
- inv.tags,
1825
- ]
1826
- }
1827
-
1828
1496
  function splitTaskId(id: string): [string, string] {
1829
1497
  const i = id.indexOf('#')
1830
1498
  if (i < 0) return [id, '']