@vzn/vx 0.0.18 → 0.0.22

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 (149) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +107 -70
  3. package/package.json +10 -20
  4. package/src/bin.ts +64 -5
  5. package/src/cache/archive.ts +279 -28
  6. package/src/cache/cache.ts +952 -1584
  7. package/src/cache/chained-cache.ts +14 -10
  8. package/src/cache/config-evals.ts +112 -0
  9. package/src/cache/file-hashes.ts +290 -0
  10. package/src/cache/git-inputs.ts +966 -0
  11. package/src/cache/index.ts +23 -7
  12. package/src/cache/inputs.ts +553 -739
  13. package/src/cache/key-fold.ts +232 -0
  14. package/src/cache/layer.ts +674 -0
  15. package/src/cache/layered-cache.ts +211 -49
  16. package/src/cache/output-index.ts +323 -0
  17. package/src/cache/policy.ts +84 -0
  18. package/src/cache/run-history.ts +152 -0
  19. package/src/cache/zstd.ts +183 -0
  20. package/src/cli/cache.ts +88 -30
  21. package/src/cli/completions.ts +104 -0
  22. package/src/cli/core-alias.ts +25 -0
  23. package/src/cli/format.ts +4 -15
  24. package/src/cli/help.ts +76 -38
  25. package/src/cli/index.ts +82 -17
  26. package/src/cli/info.ts +162 -91
  27. package/src/cli/init.ts +87 -0
  28. package/src/cli/last.ts +130 -23
  29. package/src/cli/lock.ts +36 -18
  30. package/src/cli/plan-format.ts +18 -7
  31. package/src/cli/plugin-commands.ts +34 -12
  32. package/src/cli/run.ts +138 -313
  33. package/src/cli/select.ts +342 -0
  34. package/src/cli/show.ts +151 -71
  35. package/src/cli/upgrade.ts +136 -15
  36. package/src/cli/watch.ts +1175 -116
  37. package/src/cli/why.ts +51 -29
  38. package/src/cli/workspace-config.ts +138 -0
  39. package/src/config.ts +183 -126
  40. package/src/exec/env.ts +11 -0
  41. package/src/exec/executor.ts +62 -31
  42. package/src/exec/index.ts +16 -2
  43. package/src/exec/kill-tree.ts +281 -0
  44. package/src/exec/local-executor.ts +41 -0
  45. package/src/exec/runner.ts +361 -77
  46. package/src/exec/sandbox-binds.ts +211 -0
  47. package/src/exec/sandbox-paths.ts +70 -0
  48. package/src/exec/sandbox-runtime.ts +1152 -415
  49. package/src/exec/sandbox-violations.ts +293 -0
  50. package/src/graph/dependency-spec.ts +14 -14
  51. package/src/graph/index.ts +6 -4
  52. package/src/graph/priorities.ts +130 -0
  53. package/src/graph/scheduler.ts +332 -329
  54. package/src/graph/task-graph.ts +552 -188
  55. package/src/index.ts +111 -102
  56. package/src/orchestrator/admission.ts +161 -0
  57. package/src/orchestrator/colors.ts +8 -3
  58. package/src/orchestrator/deferred-outputs.ts +25 -7
  59. package/src/orchestrator/doctor.ts +363 -0
  60. package/src/orchestrator/events.ts +111 -8
  61. package/src/orchestrator/excluded-keys.ts +155 -0
  62. package/src/orchestrator/execute-task.ts +450 -565
  63. package/src/orchestrator/failure-mode.ts +169 -28
  64. package/src/orchestrator/failure-recap.ts +121 -0
  65. package/src/orchestrator/fingerprint-watch.ts +89 -0
  66. package/src/orchestrator/framed-output.ts +121 -49
  67. package/src/orchestrator/history.ts +123 -104
  68. package/src/orchestrator/hit-restore.ts +285 -0
  69. package/src/orchestrator/index.ts +42 -5
  70. package/src/orchestrator/keyed-projects.ts +89 -0
  71. package/src/orchestrator/local-shortcircuit.ts +83 -23
  72. package/src/orchestrator/lockfile-claim.ts +401 -0
  73. package/src/orchestrator/logger.ts +128 -41
  74. package/src/orchestrator/metrics.ts +201 -72
  75. package/src/orchestrator/miss-save.ts +232 -0
  76. package/src/orchestrator/options.ts +63 -34
  77. package/src/orchestrator/persistent.ts +99 -0
  78. package/src/orchestrator/placement.ts +212 -0
  79. package/src/orchestrator/plan.ts +24 -11
  80. package/src/orchestrator/plugin-host.ts +200 -49
  81. package/src/orchestrator/plugin.ts +190 -14
  82. package/src/orchestrator/prepare.ts +168 -137
  83. package/src/orchestrator/projects.ts +270 -0
  84. package/src/orchestrator/remote-prefetch.ts +22 -2
  85. package/src/orchestrator/run-artifacts.ts +34 -3
  86. package/src/orchestrator/run-context.ts +20 -5
  87. package/src/orchestrator/run-lock.ts +349 -0
  88. package/src/orchestrator/run-records.ts +132 -0
  89. package/src/orchestrator/run-report.ts +6 -4
  90. package/src/orchestrator/run.ts +467 -611
  91. package/src/orchestrator/sandbox-request.ts +569 -0
  92. package/src/orchestrator/save-lane.ts +44 -0
  93. package/src/orchestrator/shell-verdict.ts +117 -0
  94. package/src/orchestrator/signals.ts +167 -0
  95. package/src/orchestrator/stable-keys.ts +259 -10
  96. package/src/orchestrator/status-line.ts +35 -2
  97. package/src/orchestrator/summary.ts +115 -25
  98. package/src/orchestrator/task-hash.ts +103 -21
  99. package/src/orchestrator/task-log-buffer.ts +15 -18
  100. package/src/orchestrator/telemetry-host.ts +27 -8
  101. package/src/orchestrator/telemetry.ts +71 -44
  102. package/src/orchestrator/upstream.ts +91 -45
  103. package/src/util/bun-version.ts +43 -0
  104. package/src/util/cgroup.ts +160 -0
  105. package/src/util/edit-distance.ts +49 -0
  106. package/src/util/errors.ts +86 -0
  107. package/src/util/hash.ts +10 -1
  108. package/src/util/index.ts +42 -5
  109. package/src/util/paths.ts +168 -5
  110. package/src/util/procfs.ts +20 -0
  111. package/src/util/settle.ts +23 -2
  112. package/src/util/size.ts +33 -0
  113. package/src/util/task-id.ts +20 -0
  114. package/src/util/timing.ts +11 -1
  115. package/src/util/verbs.ts +39 -0
  116. package/src/util/which.ts +48 -0
  117. package/src/workspace/affected.ts +368 -60
  118. package/src/workspace/config-cache.ts +271 -32
  119. package/src/workspace/config-eval.ts +60 -16
  120. package/src/workspace/config-imports.ts +131 -9
  121. package/src/workspace/config-schema.ts +1033 -0
  122. package/src/workspace/filter.ts +69 -17
  123. package/src/workspace/fingerprint.ts +59 -7
  124. package/src/workspace/index.ts +37 -3
  125. package/src/workspace/json-data.ts +90 -0
  126. package/src/workspace/load-reads.ts +32 -0
  127. package/src/workspace/lockfile.ts +41 -14
  128. package/src/workspace/migrate-scripts.ts +187 -0
  129. package/src/workspace/migration.ts +353 -0
  130. package/src/workspace/package-graph.ts +239 -31
  131. package/src/workspace/project-loader.ts +295 -815
  132. package/src/workspace/workspace.ts +266 -31
  133. package/plugins/local-cache/index.ts +0 -2
  134. package/plugins/local-executor/index.ts +0 -2
  135. package/plugins/schedule-history/index.ts +0 -2
  136. package/src/cache/cas-backend.ts +0 -115
  137. package/src/cache/digest.ts +0 -32
  138. package/src/cli/migrate-emit.ts +0 -21
  139. package/src/cli/migrate-nx.ts +0 -473
  140. package/src/cli/migrate-scripts.ts +0 -127
  141. package/src/cli/migrate-turbo.ts +0 -401
  142. package/src/cli/migrate.ts +0 -305
  143. package/src/cli/prune.ts +0 -291
  144. package/src/orchestrator/missing-plugin.ts +0 -9
  145. package/src/orchestrator/resources.ts +0 -45
  146. package/src/orchestrator/verify.ts +0 -174
  147. package/src/plugins/local-cache/index.ts +0 -11
  148. package/src/plugins/local-executor/index.ts +0 -45
  149. package/src/plugins/schedule-history/index.ts +0 -109
@@ -0,0 +1,674 @@
1
+ // The cache layer CONTRACT and its records — what every cache implementation
2
+ // honours (`Cache`, `LayeredCache`, `ChainedCache`, a plugin's remote layer) and
3
+ // the shapes that cross it: the key input, entries, run-history rows, output
4
+ // fingerprints, stats, prune options. No implementation lives here; the one
5
+ // reference to `Cache` below is a type (the local handle a layer may wrap).
6
+
7
+ import type { Cache } from './cache.js'
8
+
9
+ export interface CacheKeyInput {
10
+ taskId: string
11
+ /**
12
+ * Hash of the resolved task config (post-evaluation). Folds in everything
13
+ * the user wrote — command, env declarations (passThrough names + define
14
+ * key/value pairs), dependsOn, cache.inputs declarations, outputs — including
15
+ * values that arrived via `import` at config-load time.
16
+ */
17
+ taskConfigHash: string
18
+ /**
19
+ * Runtime values of declared cache-input env names (from parent at hash
20
+ * time), `undefined` for a name that is unset. Independent of `exec.env`;
21
+ * lives here for cache identity.
22
+ */
23
+ envValues: Array<[name: string, value: string | undefined]>
24
+ /**
25
+ * Resolved `cache.inputs.runtime` commands as [command, output] pairs
26
+ * (output = trimmed stdout+stderr, resolved live at hash time). Folded
27
+ * into the key in a namespace distinct from workspaceRuntimeValues.
28
+ */
29
+ runtimeValues?: Array<[command: string, output: string]>
30
+ /** Resolved `cache.inputs.workspaceRuntime` pairs (root-cwd commands). */
31
+ workspaceRuntimeValues?: Array<[command: string, output: string]>
32
+ /** Absolute paths to input files. */
33
+ inputFiles: string[]
34
+ workspaceRoot: string
35
+ /** Cache keys of upstream tasks this one depends on, sorted. */
36
+ upstreamHashes: string[]
37
+ /**
38
+ * Upstream hash → upstream task id, for `captureInto` row NAMING only.
39
+ * Never folded into the digest — the key already folds the sorted
40
+ * hashes. Lets the persisted `entry_inputs` row name which upstream
41
+ * task a hash came from (the diff reads better than a bare hash). When
42
+ * absent, the captured `upstream` row falls back to `name = hash`.
43
+ */
44
+ upstreamIds?: ReadonlyMap<string, string>
45
+ /**
46
+ * The task's DEPENDENCY closure with GROUP tasks expanded into the real
47
+ * tasks they stand for — what an input-shipping executor must place in the
48
+ * input root. A different question from what the key folds, and derived
49
+ * differently in both directions:
50
+ *
51
+ * - It expands groups, which contribute a synthetic roll-up hash and no
52
+ * outputs of their own, so a dependent of one would otherwise describe
53
+ * an empty closure.
54
+ * - It ignores `cache.inputs.tasks`. That filter says which upstream KEYS
55
+ * this task's key folds; what the task may READ is `dependsOn`, and
56
+ * locally every dependency's outputs are on disk before the command runs
57
+ * however the filter is written.
58
+ *
59
+ * NEVER folded into the digest — the key cascades through a group's own
60
+ * roll-up hash already, and folding either difference in would move every
61
+ * existing dependent's key without telling it anything new.
62
+ */
63
+ upstreamGraft?: ReadonlyArray<{
64
+ readonly taskId: string
65
+ readonly hash: string
66
+ readonly projectDir: string
67
+ }>
68
+ /**
69
+ * Workspace-level fingerprint — typically a hash of `pnpm-lock.yaml` +
70
+ * `pnpm-workspace.yaml`. Folds resolved dep versions and workspace shape
71
+ * into every task's key, so a lockfile bump invalidates everything.
72
+ */
73
+ workspaceFingerprint: string
74
+ /**
75
+ * CLI args forwarded to the task (after `--`). Folded into the key so that
76
+ * the same command with different forwarded args is treated as a distinct
77
+ * run, never a spurious cache hit.
78
+ */
79
+ forwardArgs?: readonly string[]
80
+ /**
81
+ * Hash of the project's `package.json` bytes. Folded into the key
82
+ * implicitly (Turbo / Nx parity) so dep changes invalidate every
83
+ * task in that project, even when `cache.inputs.files` doesn't
84
+ * cover package.json. Empty string when the project has no
85
+ * package.json (impossible in practice — workspace discovery
86
+ * requires one — but we don't fail-loud here).
87
+ */
88
+ projectPackageJsonHash: string
89
+ /**
90
+ * Precomputed content hashes (git blob OIDs) keyed by absolute
91
+ * path — typically the trusted-index OID map harvested by the
92
+ * run's bulk `git ls-files -s`. Paths present here skip `hashFile`
93
+ * entirely (no stat, no SQLite, no read); missing paths fall back
94
+ * to `hashFile`, which computes the byte-identical blob OID from
95
+ * disk. Pure fast path: the derived key never depends on whether a
96
+ * hash arrived via the map or the fallback.
97
+ */
98
+ fileHashes?: ReadonlyMap<string, string>
99
+ /**
100
+ * Material a plugin's `key` stage contributed, as sorted `[name, value]`
101
+ * pairs. Folded only when non-empty, so a workspace without a key plugin
102
+ * derives exactly the key it always did.
103
+ */
104
+ pluginParts?: ReadonlyArray<readonly [name: string, value: string]>
105
+ /**
106
+ * When set, `key()` pushes each component (kind, name, hash) it folds
107
+ * — at the same fold sites, in fold order. Pure SIDE-CHANNEL: it does
108
+ * not change the returned digest in any way. On a cache MISS the
109
+ * orchestrator allocates an array, passes it here, and persists the
110
+ * rows to `entry_inputs` (inside the entry-save transaction) so a
111
+ * later run can diff its inputs against this one (the Tier-3 "why did
112
+ * this re-run?" moat). Capturing at the fold sites keeps the
113
+ * recorded set in lockstep with the key by construction: a future
114
+ * component that forgets to capture is a one-line miss, not silent
115
+ * drift. The per-file OIDs are already awaited here, so file rows cost
116
+ * zero extra I/O — just array pushes.
117
+ */
118
+ captureInto?: Array<{ kind: string; name: string; hash: string }>
119
+ }
120
+
121
+ export interface CacheEntry {
122
+ hash: string
123
+ taskId: string
124
+ command: string
125
+ exitCode: number
126
+ durationMs: number
127
+ /**
128
+ * What the PRODUCING execution used — CPU time and peak RSS — carried in
129
+ * the artifact's sidecar, so an entry ingested from a remote knows them
130
+ * too. Absent when the runner reported nothing. A hit surfaces them as
131
+ * `storedCpuMs` / `storedPeakRssBytes` on its outcome: what the hit
132
+ * skipped, never what it spent.
133
+ */
134
+ cpuMs?: number
135
+ peakRssBytes?: number
136
+ outputFiles: string[]
137
+ /**
138
+ * The `output_files` rows behind `outputFiles` (size / mode / mtime), when
139
+ * the layer that produced this entry had them in hand. `restoreHit` reads
140
+ * them for its tree-is-current check instead of re-querying — one SQL
141
+ * round trip per cache hit, saved.
142
+ */
143
+ outputRows?: OutputFileRow[]
144
+ /**
145
+ * The `output_dirs` rows behind the directory short-circuit, when the
146
+ * batched `getMany` loaded them — one query for the run instead of one
147
+ * per hit (0.12 ms each across a warm 1000-project run before this).
148
+ */
149
+ outputDirRows?: OutputDirRow[]
150
+ /** Captured stdout, always present (may be empty). stderr is not cached. */
151
+ stdout: string
152
+ storedAt: string
153
+ /**
154
+ * Where this hit was resolved from. `'local'` for a SQLite-backed
155
+ * Cache; `'remote'` when LayeredCache pulled the artifact from the
156
+ * remote layer this lookup (even though it's been materialized into
157
+ * local for next time). Lets the orchestrator surface
158
+ * `cache-hit-remote` so users see when remote caching actually saved
159
+ * them work vs. a stale-local replay.
160
+ */
161
+ source?: 'local' | 'remote'
162
+ }
163
+
164
+ export interface RunRecord {
165
+ /**
166
+ * The task's cache key. ABSENT when the outcome never derived one — a
167
+ * `skipped` task (its upstream failed, so it never probed) or a
168
+ * `persistent` one (a dev server is never cached). Such a row is still a
169
+ * task of the run and must be recorded, so the column keeps a `''`
170
+ * sentinel rather than going nullable: `''` is impossible for a real key
171
+ * (`Cache.key` returns 16 hex chars), the reads that must not treat it as
172
+ * a key already guard it, and it matches what the cloud `task_runs.hash`
173
+ * column has stored for the same concept since its first migration.
174
+ */
175
+ hash?: string
176
+ project: string
177
+ task: string
178
+ status: 'success' | 'failed' | 'cache-hit' | 'cache-hit-remote' | 'skipped'
179
+ exitCode: number
180
+ durationMs: number
181
+ forwardArgs?: readonly string[]
182
+ startedAt: number // ms-epoch wall clock
183
+ endedAt: number // ms-epoch wall clock
184
+ /**
185
+ * Optional analytics columns. Populated by the orchestrator/runner;
186
+ * stored as NULL on rows from older runs. Surfaced via `vx stats`
187
+ * and consumable from CI by reading cache.db directly.
188
+ */
189
+ runId?: string // ULID shared across every task in one `vx run` invocation
190
+ cpuMs?: number // sum of user + system CPU time for the child process
191
+ peakRssBytes?: number // peak resident set size of the child process
192
+ wallclockStartNs?: bigint // hrtime span relative to run t=0
193
+ wallclockEndNs?: bigint
194
+ cacheHit?: boolean // convenience for flamegraph color; derivable from status
195
+ attempts?: number // >1 when the task retried; the direct within-run flaky signal
196
+ /**
197
+ * Whether the task declared a `cache` block. An uncached task executes
198
+ * on every run and its key exists only for dependents to fold, so a
199
+ * reader (`vx why`, `vx last`) must not present its re-run as a cache
200
+ * decision. Absent on rows written before the column existed.
201
+ */
202
+ cached?: boolean
203
+ /**
204
+ * Why the task failed or was skipped, as the run's own footer said it
205
+ * (v27 columns; NULL on older rows and where the reason does not apply):
206
+ * a skip's root blocker, vx's own timeout, the sandbox's violation count,
207
+ * and why a persistent task never became ready.
208
+ */
209
+ blockedBy?: string
210
+ timedOut?: true
211
+ sandboxViolations?: number
212
+ notReady?: 'timeout' | 'exited' | 'spawn'
213
+ }
214
+
215
+ /**
216
+ * One header row per `vx run` invocation (the `invocations` table). All
217
+ * fields mirror the columns; nullable VCS/host columns are `null` when
218
+ * the probe failed (not a git repo, hostname unavailable). Recorded
219
+ * once per run inside the same transaction as the per-task `runs` rows.
220
+ */
221
+ export interface InvocationRecord {
222
+ runId: string
223
+ command: string
224
+ /** JSON-serialized `string[]` of the requested task names. */
225
+ requestedTasks: string
226
+ /** Compact policy flags, e.g. `'lR,lW,rR,rW'` (an axis omitted = off). */
227
+ cachePolicy: string
228
+ concurrency: number
229
+ flow: 'focused' | 'broad' | null
230
+ startedAt: number
231
+ endedAt: number
232
+ totalDurationMs: number
233
+ taskCount: number
234
+ failedCount: number
235
+ hitCount: number
236
+ hitLocalCount: number
237
+ hitRemoteCount: number
238
+ exitOk: boolean
239
+ commitSha: string | null
240
+ branch: string | null
241
+ dirty: boolean | null
242
+ ci: boolean
243
+ ciProvider: string | null
244
+ host: string | null
245
+ os: string | null
246
+ arch: string | null
247
+ vxVersion: string
248
+ /** JSON object `{k:v}` of `--tag` pairs. */
249
+ tags: string
250
+ }
251
+
252
+ /**
253
+ * One cache-key component row for the `entry_inputs` table — keyed by
254
+ * the cache-entry HASH it belongs to, not a run. Written inside the
255
+ * entry-save transaction (`writeArtifactAndIndex`) on a miss/save; a
256
+ * cache HIT does not save, so it persists nothing (warm runs are free).
257
+ * The diff (Phase B/B1) reads these by the run's task hash
258
+ * (`runs.hash → entry_inputs[entry_hash]`).
259
+ */
260
+ export interface TaskInputRow {
261
+ entryHash: string
262
+ kind: string
263
+ name: string
264
+ hash: string
265
+ }
266
+
267
+ export interface CacheStats {
268
+ entryCount: number
269
+ totalBytes: number
270
+ runCountLast24h: number
271
+ hitCountLast24h: number
272
+ }
273
+
274
+ export interface CacheStatsOptions {
275
+ /** Narrow every aggregate to one project. Absent = the whole workspace. */
276
+ project?: string
277
+ }
278
+
279
+ export interface PruneOptions {
280
+ /** Drop entries last accessed before this ms-epoch threshold. */
281
+ olderThanMs?: number
282
+ /**
283
+ * After applying olderThanMs, if the cache still exceeds this size in
284
+ * bytes, evict LRU (smallest `accessed_at` first) until under it.
285
+ */
286
+ maxBytes?: number
287
+ /**
288
+ * Pick the victims and count the orphans, delete nothing: the result
289
+ * says what a real prune with the same policy would reap right now.
290
+ */
291
+ dryRun?: boolean
292
+ }
293
+
294
+ export interface PruneResult {
295
+ /** Index entries evicted by the age / size policy (each with its artifact). */
296
+ evicted: number
297
+ /** Bytes the evicted entries occupied, per the index. */
298
+ bytesFreed: number
299
+ /**
300
+ * Artifacts and temp files with no index row, unlinked by the orphan
301
+ * sweep that runs after eviction (a `SCHEMA_VERSION` drop, a deleted
302
+ * `cache.db`, a save that crashed before its rename). Files younger
303
+ * than one hour are never counted: they may be a save in flight.
304
+ */
305
+ orphans: number
306
+ /** Bytes the reaped orphans occupied on disk. */
307
+ orphanBytes: number
308
+ }
309
+
310
+ /**
311
+ * Artifact + `output_files` namespace prefix for workspace-root-
312
+ * anchored outputs (`cache.outputs.workspaceFiles`). Project outputs
313
+ * keep their bare project-relative `path` rows; workspace rows store
314
+ * the full `workspace-outputs/<rel-to-root>` tar entry name as the
315
+ * discriminator — least-invasive row format, no schema change. A
316
+ * project output dir literally named `workspace-outputs/` would
317
+ * collide with the namespace; the name is reserved.
318
+ */
319
+ export const WORKSPACE_OUTPUT_PREFIX = 'workspace-outputs/'
320
+
321
+ /**
322
+ * Per-output-file fingerprint, scoped by the cache entry that
323
+ * produced it. Batch-loaded once at the top of a run via
324
+ * `loadOutputFilesBatch(hashes)` so the orchestrator's "is this
325
+ * tree already current?" probe becomes an in-memory Map lookup
326
+ * plus N parallel stat calls.
327
+ *
328
+ * `path` is project-relative (e.g. `dist/index.js`), matching how
329
+ * outputs are addressed under `<projectDir>/` — except workspace
330
+ * outputs, which carry the full `workspace-outputs/<rel-to-root>`
331
+ * tar entry name (see `WORKSPACE_OUTPUT_PREFIX`); callers split on
332
+ * the prefix and anchor those at the workspace root.
333
+ */
334
+ export interface OutputFileRow {
335
+ path: string
336
+ size: number
337
+ mode: number
338
+ mtimeMs: number
339
+ /**
340
+ * The inode and ctime THIS machine saw the file carry after the save or
341
+ * restore that left it equal to the entry (`recordOutputStamps`).
342
+ * Absent until then, and from any ingest: a row without them is never
343
+ * current.
344
+ */
345
+ ino?: number
346
+ ctimeMs?: number
347
+ }
348
+
349
+ /** One directory under a whole-subtree output glob, as it stood after the last save or restore on THIS machine. */
350
+ export interface OutputDirRow {
351
+ path: string
352
+ mtimeMs: number
353
+ }
354
+
355
+ /**
356
+ * More directories than this under a task's output prefixes: record
357
+ * nothing, keep the walk. 256 refused payload's `@payloadcms/ui#build`
358
+ * (535 directories, 4,069 files), which then paid the refused snapshot's
359
+ * walk AND the output glob on every warm no-op — 65 ms of a 320 ms run
360
+ * (2026-09-11). Rows are 60 bytes each and the check is one stat per
361
+ * directory, so the cap is a bound on pathological trees, not a budget.
362
+ */
363
+ export const OUTPUT_DIRS_CAP = 8192
364
+
365
+ /**
366
+ * A directory whose mtime lies within this many ms of the snapshot is RACY
367
+ * and the whole snapshot is dropped. File timestamps are coarse on Linux
368
+ * (a kernel tick, up to 10 ms), so a write landing in the same tick as the
369
+ * one that made the directory's recorded mtime leaves the mtime unchanged
370
+ * and the change invisible — git's index distrusts stats this young for
371
+ * the same reason (a stray survived a hit on the ubuntu job, 2026-09-03).
372
+ * The next hit walks, and once the tree is older than the window it is
373
+ * recorded for good.
374
+ */
375
+ export const OUTPUT_DIRS_RACY_MS = 50
376
+
377
+ /** The stat memo's twin of `OUTPUT_DIRS_RACY_MS`: a file changed within this window of the stat is not memoised. */
378
+ export const FILE_HASH_RACY_MS = 50
379
+
380
+ /**
381
+ * Context passed to `get()`. Optional, but required when the lookup
382
+ * may resolve through the remote layer — the local SQL row inserted on
383
+ * remote-hit needs `taskId` + `command` to be queryable later (the
384
+ * artifact itself doesn't carry them). `Cache` (local) ignores this
385
+ * field; `LayeredCache` forwards it to `Cache.ingest`.
386
+ */
387
+ export interface CacheGetContext {
388
+ taskId: string
389
+ command: string
390
+ /**
391
+ * The task's declared `cache.outputs`. A remote artifact is ingested
392
+ * only when every file it carries is one of them: bytes off the network
393
+ * named `outputs/src/in.txt` or `workspace-outputs/.git/hooks/…` wrote
394
+ * an input and a git hook on a green `cache-hit-remote` (item 942).
395
+ * Absent, the names are not checked.
396
+ */
397
+ outputs?: DeclaredOutputs
398
+ }
399
+
400
+ /** A task's declared outputs, as its config spells them. */
401
+ export interface DeclaredOutputs {
402
+ files: readonly string[]
403
+ workspaceFiles: readonly string[]
404
+ }
405
+
406
+ /** Metadata supplied at ingest time — values the artifact does not carry. */
407
+ export interface IngestMeta {
408
+ taskId: string
409
+ command: string
410
+ /** Wall-clock time of the original task execution. */
411
+ durationMs: number
412
+ /**
413
+ * Cache-key components (Tier-3 input fingerprint) to persist into
414
+ * `entry_inputs` in the same transaction as the entry row. Omitted on
415
+ * the remote-hit ingest path (the artifact doesn't carry them — the
416
+ * fingerprint is the saving machine's local moat). Only computed on a
417
+ * miss/save, so a cache hit never reaches here.
418
+ */
419
+ inputComponents?: readonly TaskInputRow[]
420
+ /** See `CacheGetContext.outputs`: the names the artifact may carry. */
421
+ outputs?: DeclaredOutputs
422
+ }
423
+
424
+ /**
425
+ * The supplied artifact bytes don't decompress/parse as a vx artifact.
426
+ * Thrown by `save`/`ingest` BEFORE anything reaches the final cache
427
+ * path — a rejected artifact leaves no `<hash>.tar.zst` and no SQL row.
428
+ * The LayeredCache treats this as a remote fault on the remote-hit
429
+ * path (degrades to a cache miss).
430
+ */
431
+ export class CorruptArtifactError extends Error {
432
+ constructor(
433
+ public readonly hash: string,
434
+ reason: string,
435
+ public override readonly cause?: unknown,
436
+ ) {
437
+ super(`cache: corrupt artifact for ${hash}: ${reason}`)
438
+ this.name = 'CorruptArtifactError'
439
+ }
440
+ }
441
+
442
+ /**
443
+ * `restoreOutputs` found no artifact where the probe found one: it was
444
+ * removed in between — a `vx cache prune` in another shell, or the
445
+ * `cacheRetention` of another workspace sharing the cache directory. The
446
+ * bytes are not wrong, they are gone, so the entry is a MISS and the task
447
+ * runs (execute-task.ts); nothing about the cache needs throwing away.
448
+ */
449
+ export class ArtifactVanishedError extends Error {
450
+ constructor(public readonly hash: string) {
451
+ super(
452
+ `its cache artifact ${hash} vanished before the restore (a prune or another run's retention)`,
453
+ )
454
+ this.name = 'ArtifactVanishedError'
455
+ }
456
+ }
457
+
458
+ /**
459
+ * The shape every cache implementation honors. `Cache` (the local v10
460
+ * implementation) and `LayeredCache` both `implements` this so the
461
+ * orchestrator's `executeTask` can take either without a discriminated
462
+ * union and we get a compile-time guarantee the surfaces stay congruent.
463
+ */
464
+ export interface CacheLayer {
465
+ /**
466
+ * The local handle this layer wraps, when it wraps one (`LayeredCache`).
467
+ * `resolveCache` uses it to drop a bare local layer another declared layer
468
+ * already contains, so a plugin that layers over the local handle does
469
+ * not write the
470
+ * local store twice.
471
+ */
472
+ readonly local?: Cache | undefined
473
+ /**
474
+ * `true` iff a REMOTE cache sits behind this layer. THE answer to "can
475
+ * the remote axes of the cache policy do anything on this run?" — the
476
+ * orchestrator clamps `remoteRead`/`remoteWrite` off when it is absent,
477
+ * skips the up-front local classify when it is present (that classify's
478
+ * `cache.get` would be a remote read-through awaited before scheduling),
479
+ * and drives the prefetch pass off it.
480
+ *
481
+ * A layer must answer TRUTHFULLY: identity against the local cache
482
+ * ("something other than the handle I passed in") is NOT the same
483
+ * question — an ordinary pass-through decorator with no remote at all
484
+ * answers yes to it. `LayeredCache` sets it, a bare `Cache` denies it,
485
+ * and a third-party layer opts in when, and only when, it really has a
486
+ * remote. Optional so a layer written before this contract keeps
487
+ * type-checking; absent reads as "no remote", the safe answer.
488
+ */
489
+ readonly hasRemote?: boolean
490
+ /**
491
+ * Batch existence probe over the remote — the subset of `hashes` stored
492
+ * remotely, in ONE round-trip, or `null` for "no batch info; use
493
+ * per-hash". Only meaningful with `hasRemote`; a remote layer that can't
494
+ * batch omits it and the prefetch pass falls back to per-hash GETs.
495
+ */
496
+ remoteHasMany?(hashes: readonly string[]): Promise<Set<string> | null>
497
+ /**
498
+ * Record that the remote has NO artifact for each of `hashes` (from a
499
+ * batch probe), so a later `get`/`prefetch` short-circuits to a miss with
500
+ * no round-trip. Only meaningful with `hasRemote`.
501
+ */
502
+ markRemoteAbsent?(hashes: Iterable<string>): void
503
+ /**
504
+ * Resolve once every background write-through upload settles. The
505
+ * orchestrator awaits it before `close()` — an upload reading layer state
506
+ * after close would race. Only meaningful with `hasRemote`; a layer that
507
+ * uploads synchronously (or not at all) omits it.
508
+ */
509
+ drainUploads?(): Promise<void>
510
+ key(input: CacheKeyInput): Promise<string>
511
+ get(hash: string, ctx?: CacheGetContext): Promise<CacheEntry | null>
512
+ /**
513
+ * Optional batched `get` (same answers, fewer round trips). The
514
+ * short-circuit probe uses it when a layer offers one; a layer without it
515
+ * is probed hash by hash.
516
+ */
517
+ getMany?(hashes: readonly string[]): Promise<Map<string, CacheEntry>>
518
+ /**
519
+ * Lightweight existence probe. `'local'` / `'remote'` names the layer
520
+ * that holds the artifact; `null` is a miss. NEVER moves bytes: no
521
+ * artifact read, no remote download, no local ingest, no accessed_at
522
+ * bump (the LayeredCache's remote side is an HTTP HEAD). Planning
523
+ * (`--dry` / `--graph`) predicts hits with this instead of `get` so a
524
+ * dry run can't pull N artifacts over the network. Remote errors
525
+ * degrade to `null` — an existence probe never fails anything.
526
+ */
527
+ has(hash: string): Promise<'local' | 'remote' | null>
528
+ /**
529
+ * Best-effort warm of `hash` from a slower layer (the remote cache)
530
+ * into this one, so a later `get(hash)` resolves locally without a
531
+ * round-trip on the task's critical path. Returns `true` if the
532
+ * artifact is now present locally, `false` on a miss / error
533
+ * (degrades, never throws). The local `Cache` is a no-op (nothing
534
+ * slower to warm from); `LayeredCache` owns the real implementation
535
+ * plus the in-flight de-dup so prefetch + get share ONE remote GET.
536
+ */
537
+ prefetch(hash: string, ctx?: CacheGetContext): Promise<boolean>
538
+ /**
539
+ * Batched lookup of per-output-file fingerprints for many cache
540
+ * entries in one SQL round-trip. Returns a Map keyed by entry hash.
541
+ *
542
+ * Orchestrator pattern: call this once at `prepareRun` for every
543
+ * task whose hash is known up-front, then per-task `executeCachedTask`
544
+ * does a Map.get (O(1)) + parallel stat checks to decide whether the
545
+ * on-disk tree is already current.
546
+ *
547
+ * Hashes with no rows (cache misses) are absent from the result.
548
+ */
549
+ loadOutputFilesBatch(hashes: readonly string[]): Map<string, OutputFileRow[]>
550
+ /**
551
+ * Stat each `expected` row's target under `projectDir` and return
552
+ * `true` iff every (size, mode, mtime) matches. Missing files,
553
+ * stat errors, or any mismatch → `false`.
554
+ *
555
+ * Pure FS check — no DB access. Caller batches the expected rows
556
+ * via `loadOutputFilesBatch`. Lets the orchestrator skip
557
+ * `cleanOutputs + restoreOutputs` entirely when the cached
558
+ * snapshot is already in place. Integrity-preserving: detects
559
+ * out-of-band file edits or deletions and falls through to a real
560
+ * restore.
561
+ */
562
+ isOutputsCurrent(projectDir: string, expected: readonly OutputFileRow[]): Promise<boolean>
563
+ /**
564
+ * The directory-mtime short-circuit behind a warm hit (optional — a layer
565
+ * without it keeps the output walk). `recordOutputDirs` snapshots every
566
+ * directory under each of `prefixes` (project-relative, whole-subtree
567
+ * globs only — see `wholeSubtreePrefixes`) after a save or restore;
568
+ * `loadOutputDirsBatch` reads them back; `outputDirsCurrent` is true iff
569
+ * every recorded directory still carries its recorded mtime, which proves
570
+ * no file was added or removed anywhere the glob could see. Machine-local
571
+ * state, like the output rows: a remote ingest records none.
572
+ */
573
+ recordOutputDirs?(hash: string, projectDir: string, prefixes: readonly string[]): Promise<void>
574
+ /**
575
+ * Stamp `hash`'s output rows with each file's inode and ctime once a
576
+ * save or restore has left the tree equal to the entry (optional — a
577
+ * layer without it has rows `isOutputsCurrent` never trusts, so every
578
+ * hit restores). Machine-local; skips a file that no longer matches its
579
+ * row.
580
+ */
581
+ recordOutputStamps?(hash: string, projectDir: string, workspaceRoot: string): void
582
+ loadOutputDirsBatch?(hashes: readonly string[]): Map<string, OutputDirRow[]>
583
+ outputDirsCurrent?(projectDir: string, rows: readonly OutputDirRow[]): Promise<boolean>
584
+ /**
585
+ * Extract the artifact's `outputs/` entries into `projectDir` and —
586
+ * when `workspaceRoot` is given — its `workspace-outputs/` entries
587
+ * into the workspace root. Callers restoring entries that may carry
588
+ * workspace outputs must pass `workspaceRoot`.
589
+ */
590
+ restoreOutputs(hash: string, projectDir: string, workspaceRoot?: string): Promise<void>
591
+ save(args: {
592
+ hash: string
593
+ /**
594
+ * `exitCode` is deliberately NOT accepted: vx caches only successes, so
595
+ * the stored value is pinned to 0 and there is nothing for a caller to
596
+ * decide. Accepting a number and discarding it invited the one shape that
597
+ * launders a failure into a success — cache a failing task's outputs, read
598
+ * the entry back as exit 0, and the hit classifies `cache-hit` while the
599
+ * broken build's files are restored over a good tree. Unrepresentable
600
+ * beats guarded.
601
+ */
602
+ entry: Omit<CacheEntry, 'hash' | 'storedAt' | 'outputFiles' | 'exitCode'>
603
+ projectDir: string
604
+ outputFiles: string[]
605
+ /**
606
+ * Set only by ChainedCache when an EARLIER layer in the chain already
607
+ * wrote this artifact to the same local handle: the local pack + write
608
+ * is skipped and only the layer's remote side acts. A layer with no
609
+ * remote side treats it as a full no-op.
610
+ */
611
+ skipLocalWrite?: boolean
612
+ /**
613
+ * Resolved `outputs.workspaceFiles` (absolute paths) + the root
614
+ * they're relative to. Packed under `workspace-outputs/<rel>`.
615
+ * Omitted → artifact bytes identical to the pre-workspaceFiles
616
+ * format.
617
+ */
618
+ workspaceOutputFiles?: string[]
619
+ workspaceRoot?: string
620
+ /**
621
+ * Cache-key components for this entry (the Tier-3 input
622
+ * fingerprint). Persisted to `entry_inputs` inside the same
623
+ * transaction as the entry row, via `INSERT OR IGNORE` (a re-save
624
+ * of the same hash is a no-op). Omitted/empty → nothing written.
625
+ * Only computed on the miss/save path — never on a hit.
626
+ */
627
+ inputComponents?: readonly TaskInputRow[]
628
+ }): Promise<void>
629
+ /**
630
+ * Adopt an artifact produced elsewhere — the remote-hit path. Streams
631
+ * the compressed body to `<cacheDir>/<hash>.tar.zst`, parses the
632
+ * tar headers to populate the `output_files` rows, and inserts the
633
+ * `entries` row using the caller-supplied `meta`. After this returns,
634
+ * the next `get(hash)` resolves locally.
635
+ */
636
+ ingest(hash: string, body: Blob | Response, meta: IngestMeta): Promise<void>
637
+ /**
638
+ * The ONE run-history write: a whole `vx run` atomically, the per-task
639
+ * `runs` rows and the one `invocations` header row — in a SINGLE
640
+ * transaction (one fsync). Input-fingerprint rows are NOT written here — they
641
+ * ride the entry-save transaction (`save`/`ingest`) so a warm
642
+ * all-cache-hit run writes nothing.
643
+ */
644
+ recordRunBundle(bundle: { runs: readonly RunRecord[]; invocation: InvocationRecord }): void
645
+ stats(opts?: CacheStatsOptions): CacheStats
646
+ /**
647
+ * Content-hash a file with an mtime+size fast path. If the
648
+ * `(mtime_ms, size_bytes)` of `filePath` match a previously seen
649
+ * row, return the stored digest (a git blob OID since CACHE_VERSION
650
+ * v20) instead of re-reading the bytes. Otherwise read + hash + upsert.
651
+ * The hash is byte-for-byte identical to what a fresh content-hash
652
+ * would produce — pure optimization, no cache-key change.
653
+ */
654
+ hashFile(filePath: string): Promise<string>
655
+ /**
656
+ * Absolute path to the on-disk outputs artifact for a hash —
657
+ * `<cacheDir>/<hash>.tar.zst`. Returns the path whether or
658
+ * not the artifact exists. Exposed for telemetry / dashboards;
659
+ * `restoreOutputs` is the canonical way to materialize the bytes.
660
+ */
661
+ outputsPath(hash: string): string
662
+ prune(options: PruneOptions): Promise<PruneResult>
663
+ close(): void
664
+ }
665
+
666
+ /**
667
+ * Convenience alias for the `save()` args. Used by `LayeredCache` to
668
+ * forward call args without redeclaring the structural shape — NOT
669
+ * part of the conceptual cache contract; consumers should call
670
+ * `CacheLayer.save({ ... })` directly.
671
+ *
672
+ * @internal
673
+ */
674
+ export type SaveArgs = Parameters<CacheLayer['save']>[0]