@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,323 @@
1
+ // The output fingerprint index: per-entry `output_files` rows (size, mode,
2
+ // millisecond mtime) and `output_dirs` rows (directory mtimes), and the two
3
+ // proofs a cache hit runs against the tree before deciding not to restore.
4
+ // Owns its statements over the store's handle; `Cache` delegates, and
5
+ // writes the file rows through `replaceFileRows` inside its own save
6
+ // transaction.
7
+
8
+ /** `mtime_ms` of a recorded output prefix that did not exist when the snapshot was taken. */
9
+ const ABSENT_DIR_MTIME = -1
10
+
11
+ import type { Database, SQLQueryBindings } from 'bun:sqlite'
12
+ import { lstatSync, statSync } from 'node:fs'
13
+ import { lstat, readdir } from 'node:fs/promises'
14
+ import path from 'node:path'
15
+ import {
16
+ OUTPUT_DIRS_CAP,
17
+ OUTPUT_DIRS_RACY_MS,
18
+ type OutputDirRow,
19
+ type OutputFileRow,
20
+ WORKSPACE_OUTPUT_PREFIX,
21
+ } from './layer.js'
22
+
23
+ export class OutputIndex {
24
+ private readonly insertOutputFile: ReturnType<Database['prepare']>
25
+ private readonly deleteOutputFiles: ReturnType<Database['prepare']>
26
+ private readonly insertOutputDir: ReturnType<Database['prepare']>
27
+ private readonly deleteOutputDirs: ReturnType<Database['prepare']>
28
+ private readonly entryExists: ReturnType<Database['prepare']>
29
+ private readonly stampOutputFile: ReturnType<Database['prepare']>
30
+ /**
31
+ * Snapshots taken and not yet written, the last per hash. A snapshot is
32
+ * read by the NEXT run's hit check, never by the task that took it, so
33
+ * the rows land in one transaction at the first read, at prune, at
34
+ * stats or at close — the way `accessed_at` bumps do — instead of one
35
+ * commit per task: 1,000 of them were the whole `output dir snapshots`
36
+ * stage, 47–72 ms at run end (item 622).
37
+ */
38
+ private readonly pendingDirs = new Map<string, Array<[string, number]> | null>()
39
+ /** Stamps taken and not yet written, flushed with the directory snapshots. */
40
+ private readonly pendingStamps = new Map<string, Array<[string, number, number]>>()
41
+
42
+ constructor(private readonly db: Database) {
43
+ this.insertOutputFile = this.db.prepare(`
44
+ INSERT INTO output_files(entry_hash, path, size_bytes, mode, mtime_ms)
45
+ VALUES (?, ?, ?, ?, ?)
46
+ ON CONFLICT(entry_hash, path) DO UPDATE SET
47
+ size_bytes = excluded.size_bytes,
48
+ mode = excluded.mode,
49
+ mtime_ms = excluded.mtime_ms
50
+ `)
51
+ this.deleteOutputFiles = this.db.prepare('DELETE FROM output_files WHERE entry_hash = ?')
52
+ this.insertOutputDir = this.db.prepare(
53
+ 'INSERT INTO output_dirs(entry_hash, path, mtime_ms) VALUES (?, ?, ?)',
54
+ )
55
+ this.deleteOutputDirs = this.db.prepare('DELETE FROM output_dirs WHERE entry_hash = ?')
56
+ this.entryExists = this.db.prepare('SELECT 1 FROM entries WHERE hash = ?')
57
+ this.stampOutputFile = this.db.prepare(
58
+ 'UPDATE output_files SET ino = ?, ctime_ms = ? WHERE entry_hash = ? AND path = ?',
59
+ )
60
+ }
61
+
62
+ /**
63
+ * Replace an entry's file rows. Called INSIDE the store's save transaction,
64
+ * so an entry and its fingerprints land together (an UPDATE on the same
65
+ * hash refreshes rather than appends).
66
+ */
67
+ replaceFileRows(hash: string, rows: ReadonlyArray<[string, number, number, number]>): void {
68
+ this.deleteOutputFiles.run(hash)
69
+ for (const [rel, size, mode, mtime] of rows) {
70
+ this.insertOutputFile.run(hash, rel, size, mode, mtime)
71
+ }
72
+ }
73
+
74
+ loadOutputFilesBatch(hashes: readonly string[]): Map<string, OutputFileRow[]> {
75
+ const out = new Map<string, OutputFileRow[]>()
76
+ if (hashes.length === 0) return out
77
+ // Inline placeholders for an IN-list — bun:sqlite doesn't ship
78
+ // rarray, but `IN (?, ?, …)` with N≤~999 is fast and avoids per-
79
+ // hash select.get() overhead. `db.query` (not `db.prepare`) caches the
80
+ // compiled statement keyed by the SQL text — so the dominant single-hash
81
+ // warm-hit path (called up to 3× per hit) reuses one statement instead of
82
+ // recompiling on every call.
83
+ // A reader in the same process (`vx watch`'s next cycle, a test) sees
84
+ // the stamps taken, not only the ones flushed.
85
+ this.flushOutputDirs()
86
+ const placeholders = hashes.map(() => '?').join(',')
87
+ const stmt = this.db.query(
88
+ `SELECT entry_hash, path, size_bytes, mode, mtime_ms, ino, ctime_ms FROM output_files WHERE entry_hash IN (${placeholders})`,
89
+ )
90
+ const rows = stmt.all(...(hashes as readonly SQLQueryBindings[])) as Array<{
91
+ entry_hash: string
92
+ path: string
93
+ size_bytes: number
94
+ mode: number
95
+ mtime_ms: number
96
+ ino: number | null
97
+ ctime_ms: number | null
98
+ }>
99
+ for (const r of rows) {
100
+ let list = out.get(r.entry_hash)
101
+ if (!list) {
102
+ list = []
103
+ out.set(r.entry_hash, list)
104
+ }
105
+ const row: OutputFileRow = {
106
+ path: r.path,
107
+ size: r.size_bytes,
108
+ mode: r.mode,
109
+ mtimeMs: r.mtime_ms,
110
+ }
111
+ if (r.ino !== null && r.ctime_ms !== null) {
112
+ row.ino = r.ino
113
+ row.ctimeMs = r.ctime_ms
114
+ }
115
+ list.push(row)
116
+ }
117
+ return out
118
+ }
119
+
120
+ async isOutputsCurrent(projectDir: string, expected: readonly OutputFileRow[]): Promise<boolean> {
121
+ // Empty manifest case (a task produced no outputs) → trivially
122
+ // current; the on-disk tree under projectDir is whatever it was,
123
+ // and nothing was supposed to land there.
124
+ if (expected.length === 0) return true
125
+ // statSync, deliberately. The async form measured faster on 2026-09-02,
126
+ // when a hit walked its whole output tree; since the directory
127
+ // short-circuit (`outputDirsCurrent`) a warm hit stats a handful of
128
+ // paths, and each async stat costs a thread-pool round trip the
129
+ // scheduler cannot overlap away (its concurrency is the worker count).
130
+ // Re-measured 2026-09-09, 1,000 warm hits, interleaved A/B: the run
131
+ // graph stage 100 → 54 ms, the whole process 422 → 368 ms.
132
+ const results = expected.map((e) => {
133
+ try {
134
+ const s = statSync(path.join(projectDir, e.path))
135
+ return (
136
+ s.size === e.size &&
137
+ (s.mode & 0o777) === (e.mode & 0o777) &&
138
+ // MILLISECOND comparison (sub-ms tolerance for the float
139
+ // round-trip through utimes). Save rows carry stat-ms and
140
+ // restoreOutputs re-syncs restored files to the row value,
141
+ // so equality holds exactly in steady state; legacy
142
+ // second-precision rows converge on their first restore.
143
+ Math.abs(s.mtimeMs - e.mtimeMs) < 1 &&
144
+ // And the file is the one THIS machine last saw under this
145
+ // entry: two entries whose outputs carry one fixed mtime (a
146
+ // `tar -x`, `cp -p`, SOURCE_DATE_EPOCH) and one size matched
147
+ // each other's rows, and a hit left the other entry's bytes in
148
+ // place under a green run (item 886). No task sets a ctime; the
149
+ // inode is weaker than it looks, since ext4 gives a restore's new
150
+ // file the inode the clean freed (item 941). A row without a stamp
151
+ // (an ingest, a changed file at stamping) is never current. The
152
+ // residual: a same-size rewrite, in place or as a new file on the
153
+ // same inode, inside the coarse clock tick of the stamp, with the
154
+ // mtime forged back.
155
+ e.ino !== undefined &&
156
+ s.ino === e.ino &&
157
+ Math.floor(s.ctimeMs) === e.ctimeMs
158
+ )
159
+ } catch {
160
+ return false
161
+ }
162
+ })
163
+ return results.every(Boolean)
164
+ }
165
+
166
+ /**
167
+ * Snapshot every directory under each of `prefixes` for `hash`. Called
168
+ * after a save and after a restore, when the tree is known to equal the
169
+ * entry's set. Symlinked directories are not descended (the output walk
170
+ * refuses them too). Over `OUTPUT_DIRS_CAP` directories, or on any
171
+ * error, the rows are cleared and the next hit keeps the walk.
172
+ */
173
+ async recordOutputDirs(
174
+ hash: string,
175
+ projectDir: string,
176
+ prefixes: readonly string[],
177
+ ): Promise<void> {
178
+ const rows: Array<[string, number]> = []
179
+ const walk = async (rel: string, isPrefix = false): Promise<boolean> => {
180
+ const abs = path.join(projectDir, rel)
181
+ let st
182
+ try {
183
+ st = await lstat(abs)
184
+ } catch {
185
+ // A declared prefix the task never produced (`build/**` beside
186
+ // `dist/**`, medusa's `.medusa/**`) is recorded ABSENT: the check
187
+ // then asks that it still not exist. Refusing the snapshot instead
188
+ // made every such task re-glob its outputs on every warm hit — all
189
+ // 83 of medusa's, 210 ms of a 2.5 s no-op (2026-09-11).
190
+ if (isPrefix) {
191
+ rows.push([rel, ABSENT_DIR_MTIME])
192
+ return true
193
+ }
194
+ return false
195
+ }
196
+ if (!st.isDirectory()) return false
197
+ rows.push([rel, st.mtimeMs])
198
+ if (rows.length > OUTPUT_DIRS_CAP) return false
199
+ let entries
200
+ try {
201
+ entries = await readdir(abs, { withFileTypes: true })
202
+ } catch {
203
+ return false
204
+ }
205
+ for (const e of entries) {
206
+ if (e.isDirectory() && !e.isSymbolicLink()) {
207
+ if (!(await walk(`${rel}/${e.name}`))) return false
208
+ }
209
+ }
210
+ return true
211
+ }
212
+ let ok = true
213
+ for (const prefix of prefixes) {
214
+ if (!(await walk(prefix, true))) {
215
+ ok = false
216
+ break
217
+ }
218
+ }
219
+ // All or nothing: a racy directory dropped alone would leave its
220
+ // parent trusted while an addition inside it bumps only the dropped one.
221
+ const youngest = Date.now() - OUTPUT_DIRS_RACY_MS
222
+ if (rows.some(([, mtime]) => mtime > youngest)) ok = false
223
+ this.pendingDirs.set(hash, ok ? rows : null)
224
+ }
225
+
226
+ /**
227
+ * Stamp `hash`'s rows with each file's inode and ctime, once a save or
228
+ * a restore has left the tree equal to the entry. A file whose size,
229
+ * mode or mtime no longer matches its row is left unstamped, so a
230
+ * write that landed since is never vouched for; the next hit restores.
231
+ * Written with the directory snapshots, in one transaction.
232
+ */
233
+ recordOutputStamps(hash: string, projectDir: string, workspaceRoot: string): void {
234
+ const rows = this.loadOutputFilesBatch([hash]).get(hash) ?? []
235
+ const stamps: Array<[string, number, number]> = []
236
+ for (const e of rows) {
237
+ const ws = e.path.startsWith(WORKSPACE_OUTPUT_PREFIX)
238
+ const abs = ws
239
+ ? path.join(workspaceRoot, e.path.slice(WORKSPACE_OUTPUT_PREFIX.length))
240
+ : path.join(projectDir, e.path)
241
+ try {
242
+ const s = statSync(abs)
243
+ if (
244
+ s.size === e.size &&
245
+ (s.mode & 0o777) === (e.mode & 0o777) &&
246
+ Math.abs(s.mtimeMs - e.mtimeMs) < 1
247
+ ) {
248
+ stamps.push([e.path, s.ino, Math.floor(s.ctimeMs)])
249
+ }
250
+ } catch {
251
+ // gone: left unstamped
252
+ }
253
+ }
254
+ if (stamps.length > 0) this.pendingStamps.set(hash, stamps)
255
+ }
256
+
257
+ /** Land every pending stamp and snapshot in one transaction each; a null snapshot clears its rows. */
258
+ flushOutputDirs(): void {
259
+ if (this.pendingStamps.size > 0) {
260
+ const stamps = [...this.pendingStamps]
261
+ this.pendingStamps.clear()
262
+ this.db.transaction(() => {
263
+ for (const [hash, rows] of stamps) {
264
+ for (const [rel, ino, ctime] of rows) this.stampOutputFile.run(ino, ctime, hash, rel)
265
+ }
266
+ })()
267
+ }
268
+ if (this.pendingDirs.size === 0) return
269
+ const pending = [...this.pendingDirs]
270
+ this.pendingDirs.clear()
271
+ this.db.transaction(() => {
272
+ for (const [hash, rows] of pending) {
273
+ this.deleteOutputDirs.run(hash)
274
+ // The rows reference the entry: one pruned by another process
275
+ // between the snapshot and this flush has nothing to describe,
276
+ // and its insert would fail the whole transaction on the FK.
277
+ if (rows === null || this.entryExists.get(hash) === null) continue
278
+ for (const [rel, mtime] of rows) this.insertOutputDir.run(hash, rel, mtime)
279
+ }
280
+ })()
281
+ }
282
+
283
+ loadOutputDirsBatch(hashes: readonly string[]): Map<string, OutputDirRow[]> {
284
+ const out = new Map<string, OutputDirRow[]>()
285
+ if (hashes.length === 0) return out
286
+ // A reader in the same process (`vx watch`'s next cycle, a test) sees
287
+ // what was snapshotted, not what was flushed.
288
+ this.flushOutputDirs()
289
+ const placeholders = hashes.map(() => '?').join(',')
290
+ const rows = this.db
291
+ .query(
292
+ `SELECT entry_hash, path, mtime_ms FROM output_dirs WHERE entry_hash IN (${placeholders})`,
293
+ )
294
+ .all(...(hashes as readonly SQLQueryBindings[])) as Array<{
295
+ entry_hash: string
296
+ path: string
297
+ mtime_ms: number
298
+ }>
299
+ for (const r of rows) {
300
+ const list = out.get(r.entry_hash)
301
+ const row = { path: r.path, mtimeMs: r.mtime_ms }
302
+ if (list) list.push(row)
303
+ else out.set(r.entry_hash, [row])
304
+ }
305
+ return out
306
+ }
307
+
308
+ /** True iff every recorded directory exists with its recorded mtime (ms). Same forged-mtime trade as the file check. */
309
+ async outputDirsCurrent(projectDir: string, rows: readonly OutputDirRow[]): Promise<boolean> {
310
+ if (rows.length === 0) return false
311
+ const results = rows.map((r) => {
312
+ let st
313
+ try {
314
+ st = lstatSync(path.join(projectDir, r.path))
315
+ } catch {
316
+ return r.mtimeMs === ABSENT_DIR_MTIME
317
+ }
318
+ if (r.mtimeMs === ABSENT_DIR_MTIME) return false
319
+ return st.isDirectory() && Math.abs(st.mtimeMs - r.mtimeMs) < 1
320
+ })
321
+ return results.every(Boolean)
322
+ }
323
+ }
@@ -0,0 +1,84 @@
1
+ // The run-level cache policy: four axes (local/remote × read/write) and
2
+ // the `--cache=<spec>` grammar that sets them. Pure data + parsing; no I/O.
3
+
4
+ import { UserError } from '../util/index.js'
5
+
6
+ /**
7
+ * Independent read/write control over the two cache layers (local +
8
+ * remote). Replaces the old single `noCache` boolean: each axis can be
9
+ * toggled on its own so `--force` (re-execute but still refresh the
10
+ * cache) is distinct from `--no-cache` (disable everything).
11
+ *
12
+ * Only the task-artifact get/save path is gated. `recordRun`, `stats`,
13
+ * `prune`, key derivation, and prefetch-ingest are never affected — they
14
+ * are bookkeeping/analytics that a run policy has no business disabling.
15
+ */
16
+ export interface CachePolicy {
17
+ localRead: boolean
18
+ localWrite: boolean
19
+ remoteRead: boolean
20
+ remoteWrite: boolean
21
+ }
22
+
23
+ /** All four axes on — the default when no cache flag is passed. */
24
+ export const FULL_CACHE_POLICY: CachePolicy = {
25
+ localRead: true,
26
+ localWrite: true,
27
+ remoteRead: true,
28
+ remoteWrite: true,
29
+ }
30
+
31
+ /**
32
+ * Parse a `--cache=<spec>` value into a `CachePolicy`, starting from a
33
+ * base (defaults to {@link FULL_CACHE_POLICY}). The spec is a
34
+ * comma-separated list of `layer:flags` segments where `layer` is
35
+ * `local` or `remote` and `flags` is any subset of `r` (read) and `w`
36
+ * (write), order-independent and possibly empty.
37
+ *
38
+ * A mentioned layer is set EXACTLY to its flags (read = includes `r`,
39
+ * write = includes `w`); an unmentioned layer keeps its base value.
40
+ * So `local:rw,remote:r` = remote read-only, `remote:` = remote fully
41
+ * off, `local:r` = local read-only with remote untouched.
42
+ *
43
+ * Throws a {@link UserError} on an unknown layer, an unknown flag, a
44
+ * duplicated flag, a missing colon, or a repeated layer.
45
+ */
46
+ export function parseCachePolicy(spec: string, base: CachePolicy = FULL_CACHE_POLICY): CachePolicy {
47
+ const out: CachePolicy = { ...base }
48
+ const seen = new Set<string>()
49
+ for (const rawSeg of spec.split(',')) {
50
+ const seg = rawSeg.trim()
51
+ if (seg.length === 0) continue
52
+ const colon = seg.indexOf(':')
53
+ if (colon < 0) {
54
+ throw new UserError(`invalid --cache segment '${seg}': expected '<layer>:<flags>'`)
55
+ }
56
+ const layer = seg.slice(0, colon)
57
+ const flags = seg.slice(colon + 1)
58
+ if (layer !== 'local' && layer !== 'remote') {
59
+ throw new UserError(`invalid --cache layer '${layer}': expected 'local' or 'remote'`)
60
+ }
61
+ if (seen.has(layer)) {
62
+ throw new UserError(`--cache layer '${layer}' specified twice`)
63
+ }
64
+ seen.add(layer)
65
+ const flagSet = new Set<string>()
66
+ for (const ch of flags) {
67
+ if (ch !== 'r' && ch !== 'w') {
68
+ throw new UserError(`invalid --cache flag '${ch}' for '${layer}': expected 'r' and/or 'w'`)
69
+ }
70
+ if (flagSet.has(ch)) {
71
+ throw new UserError(`--cache flag '${ch}' repeated for '${layer}'`)
72
+ }
73
+ flagSet.add(ch)
74
+ }
75
+ if (layer === 'local') {
76
+ out.localRead = flagSet.has('r')
77
+ out.localWrite = flagSet.has('w')
78
+ } else {
79
+ out.remoteRead = flagSet.has('r')
80
+ out.remoteWrite = flagSet.has('w')
81
+ }
82
+ }
83
+ return out
84
+ }
@@ -0,0 +1,152 @@
1
+ // The run history: one `runs` row per task outcome and one `invocations`
2
+ // header per `vx run`, written together, plus their 30-day retention.
3
+ // What `vx why`, `vx last`, `stats` and the history reader read. Owns
4
+ // its statements over the store's handle; `Cache` delegates.
5
+
6
+ import type { Database, SQLQueryBindings } from 'bun:sqlite'
7
+ import type { InvocationRecord, RunRecord } from './layer.js'
8
+
9
+ export class RunHistory {
10
+ private readonly insertRun: ReturnType<Database['prepare']>
11
+ private readonly insertInvocation: ReturnType<Database['prepare']>
12
+
13
+ constructor(private readonly db: Database) {
14
+ this.insertRun = this.db.prepare(`
15
+ INSERT INTO runs(
16
+ hash, project, task, status, exit_code, duration_ms, forward_args,
17
+ started_at, ended_at,
18
+ run_id, cpu_ms, peak_rss_bytes, wallclock_start_ns, wallclock_end_ns,
19
+ cache_hit, attempts, cached,
20
+ blocked_by, timed_out, sandbox_violations, not_ready
21
+ )
22
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
23
+ `)
24
+ this.insertInvocation = this.db.prepare(`
25
+ INSERT INTO invocations(
26
+ run_id, command, requested_tasks, cache_policy, concurrency, flow,
27
+ started_at, ended_at, total_duration_ms,
28
+ task_count, failed_count, hit_count, hit_local_count, hit_remote_count,
29
+ exit_ok,
30
+ commit_sha, branch, dirty, ci, ci_provider,
31
+ host, os, arch, vx_version, tags
32
+ )
33
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
34
+ ON CONFLICT(run_id) DO NOTHING
35
+ `)
36
+ }
37
+
38
+ recordRun(run: RunRecord): void {
39
+ this.insertRun.run(...bindRun(run))
40
+ }
41
+
42
+ recordRuns(runs: readonly RunRecord[]): void {
43
+ if (runs.length === 0) return
44
+ if (runs.length === 1) {
45
+ this.insertRun.run(...bindRun(runs[0]!))
46
+ return
47
+ }
48
+ // `bun:sqlite`'s `transaction()` returns a callable that wraps the
49
+ // body in BEGIN/COMMIT, fsyncing once at the end. For a 200-task
50
+ // run that's one fsync instead of 200.
51
+ const insert = this.insertRun
52
+ const tx = this.db.transaction((batch: readonly RunRecord[]) => {
53
+ for (const r of batch) insert.run(...bindRun(r))
54
+ })
55
+ tx(runs)
56
+ }
57
+
58
+ recordRunBundle(bundle: { runs: readonly RunRecord[]; invocation: InvocationRecord }): void {
59
+ // Whole run records atomically — one transaction, one fsync: the
60
+ // per-task `runs` rows and the one `invocations` header. The
61
+ // input-fingerprint rows do NOT live here: they're written inside
62
+ // the entry-save transaction (`save`/`ingest`) so a warm
63
+ // all-cache-hit run — which writes no `runs`-vs-`entry_inputs`
64
+ // mismatch — pays nothing for the moat it isn't refreshing.
65
+ const insertRun = this.insertRun
66
+ const insertInvocation = this.insertInvocation
67
+ this.db.transaction(() => {
68
+ for (const r of bundle.runs) insertRun.run(...bindRun(r))
69
+ insertInvocation.run(...bindInvocation(bundle.invocation))
70
+ })()
71
+ }
72
+
73
+ /**
74
+ * Retention: the runs table grows by one row per executed task per
75
+ * invocation — a 2000-task repo accretes ~20k rows in days. 30 days
76
+ * covers `vx stats` (24 h windows) and CI-side consumers. The
77
+ * `invocations` header is pruned on the SAME window, or a header would
78
+ * outlive its rows and `vx last` would list a run whose detail is gone.
79
+ */
80
+ pruneOlderThan(cutoff: number): void {
81
+ this.db.prepare('DELETE FROM runs WHERE started_at < ?').run(cutoff)
82
+ this.db.prepare('DELETE FROM invocations WHERE started_at < ?').run(cutoff)
83
+ }
84
+ }
85
+
86
+ /**
87
+ * Bind a RunRecord to the positional parameters expected by the
88
+ * `insertRun` prepared statement (21 columns). Shared between the
89
+ * single and batched record paths.
90
+ */
91
+ function bindRun(run: RunRecord): SQLQueryBindings[] {
92
+ return [
93
+ // The ONE place the no-key sentinel is applied, so the column's
94
+ // NOT NULL invariant can't be violated from a call site.
95
+ run.hash ?? '',
96
+ run.project,
97
+ run.task,
98
+ run.status,
99
+ run.exitCode,
100
+ run.durationMs,
101
+ run.forwardArgs ? JSON.stringify(run.forwardArgs) : null,
102
+ run.startedAt,
103
+ run.endedAt,
104
+ run.runId ?? null,
105
+ run.cpuMs ?? null,
106
+ run.peakRssBytes ?? null,
107
+ run.wallclockStartNs !== undefined ? run.wallclockStartNs : null,
108
+ run.wallclockEndNs !== undefined ? run.wallclockEndNs : null,
109
+ run.cacheHit === undefined ? null : run.cacheHit ? 1 : 0,
110
+ run.attempts ?? null,
111
+ run.cached === undefined ? null : run.cached ? 1 : 0,
112
+ run.blockedBy ?? null,
113
+ run.timedOut === true ? 1 : null,
114
+ run.sandboxViolations ?? null,
115
+ run.notReady ?? null,
116
+ ]
117
+ }
118
+
119
+ /**
120
+ * Bind an InvocationRecord to the positional parameters of the
121
+ * `insertInvocation` prepared statement (25 columns). Booleans map to
122
+ * 0/1; null-or-bool columns (`dirty`) keep null distinct from 0.
123
+ */
124
+ function bindInvocation(inv: InvocationRecord): SQLQueryBindings[] {
125
+ return [
126
+ inv.runId,
127
+ inv.command,
128
+ inv.requestedTasks,
129
+ inv.cachePolicy,
130
+ inv.concurrency,
131
+ inv.flow,
132
+ inv.startedAt,
133
+ inv.endedAt,
134
+ inv.totalDurationMs,
135
+ inv.taskCount,
136
+ inv.failedCount,
137
+ inv.hitCount,
138
+ inv.hitLocalCount,
139
+ inv.hitRemoteCount,
140
+ inv.exitOk ? 1 : 0,
141
+ inv.commitSha,
142
+ inv.branch,
143
+ inv.dirty === null ? null : inv.dirty ? 1 : 0,
144
+ inv.ci ? 1 : 0,
145
+ inv.ciProvider,
146
+ inv.host,
147
+ inv.os,
148
+ inv.arch,
149
+ inv.vxVersion,
150
+ inv.tags,
151
+ ]
152
+ }