@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.
- package/LICENSE +1 -1
- package/README.md +107 -70
- package/package.json +10 -20
- package/src/bin.ts +64 -5
- package/src/cache/archive.ts +279 -28
- package/src/cache/cache.ts +952 -1584
- package/src/cache/chained-cache.ts +14 -10
- package/src/cache/config-evals.ts +112 -0
- package/src/cache/file-hashes.ts +290 -0
- package/src/cache/git-inputs.ts +966 -0
- package/src/cache/index.ts +23 -7
- package/src/cache/inputs.ts +553 -739
- package/src/cache/key-fold.ts +232 -0
- package/src/cache/layer.ts +674 -0
- package/src/cache/layered-cache.ts +211 -49
- package/src/cache/output-index.ts +323 -0
- package/src/cache/policy.ts +84 -0
- package/src/cache/run-history.ts +152 -0
- package/src/cache/zstd.ts +183 -0
- package/src/cli/cache.ts +88 -30
- package/src/cli/completions.ts +104 -0
- package/src/cli/core-alias.ts +25 -0
- package/src/cli/format.ts +4 -15
- package/src/cli/help.ts +76 -38
- package/src/cli/index.ts +82 -17
- package/src/cli/info.ts +162 -91
- package/src/cli/init.ts +87 -0
- package/src/cli/last.ts +130 -23
- package/src/cli/lock.ts +36 -18
- package/src/cli/plan-format.ts +18 -7
- package/src/cli/plugin-commands.ts +34 -12
- package/src/cli/run.ts +138 -313
- package/src/cli/select.ts +342 -0
- package/src/cli/show.ts +151 -71
- package/src/cli/upgrade.ts +136 -15
- package/src/cli/watch.ts +1175 -116
- package/src/cli/why.ts +51 -29
- package/src/cli/workspace-config.ts +138 -0
- package/src/config.ts +183 -126
- package/src/exec/env.ts +11 -0
- package/src/exec/executor.ts +62 -31
- package/src/exec/index.ts +16 -2
- package/src/exec/kill-tree.ts +281 -0
- package/src/exec/local-executor.ts +41 -0
- package/src/exec/runner.ts +361 -77
- package/src/exec/sandbox-binds.ts +211 -0
- package/src/exec/sandbox-paths.ts +70 -0
- package/src/exec/sandbox-runtime.ts +1152 -415
- package/src/exec/sandbox-violations.ts +293 -0
- package/src/graph/dependency-spec.ts +14 -14
- package/src/graph/index.ts +6 -4
- package/src/graph/priorities.ts +130 -0
- package/src/graph/scheduler.ts +332 -329
- package/src/graph/task-graph.ts +552 -188
- package/src/index.ts +111 -102
- package/src/orchestrator/admission.ts +161 -0
- package/src/orchestrator/colors.ts +8 -3
- package/src/orchestrator/deferred-outputs.ts +25 -7
- package/src/orchestrator/doctor.ts +363 -0
- package/src/orchestrator/events.ts +111 -8
- package/src/orchestrator/excluded-keys.ts +155 -0
- package/src/orchestrator/execute-task.ts +450 -565
- package/src/orchestrator/failure-mode.ts +169 -28
- package/src/orchestrator/failure-recap.ts +121 -0
- package/src/orchestrator/fingerprint-watch.ts +89 -0
- package/src/orchestrator/framed-output.ts +121 -49
- package/src/orchestrator/history.ts +123 -104
- package/src/orchestrator/hit-restore.ts +285 -0
- package/src/orchestrator/index.ts +42 -5
- package/src/orchestrator/keyed-projects.ts +89 -0
- package/src/orchestrator/local-shortcircuit.ts +83 -23
- package/src/orchestrator/lockfile-claim.ts +401 -0
- package/src/orchestrator/logger.ts +128 -41
- package/src/orchestrator/metrics.ts +201 -72
- package/src/orchestrator/miss-save.ts +232 -0
- package/src/orchestrator/options.ts +63 -34
- package/src/orchestrator/persistent.ts +99 -0
- package/src/orchestrator/placement.ts +212 -0
- package/src/orchestrator/plan.ts +24 -11
- package/src/orchestrator/plugin-host.ts +200 -49
- package/src/orchestrator/plugin.ts +190 -14
- package/src/orchestrator/prepare.ts +168 -137
- package/src/orchestrator/projects.ts +270 -0
- package/src/orchestrator/remote-prefetch.ts +22 -2
- package/src/orchestrator/run-artifacts.ts +34 -3
- package/src/orchestrator/run-context.ts +20 -5
- package/src/orchestrator/run-lock.ts +349 -0
- package/src/orchestrator/run-records.ts +132 -0
- package/src/orchestrator/run-report.ts +6 -4
- package/src/orchestrator/run.ts +467 -611
- package/src/orchestrator/sandbox-request.ts +569 -0
- package/src/orchestrator/save-lane.ts +44 -0
- package/src/orchestrator/shell-verdict.ts +117 -0
- package/src/orchestrator/signals.ts +167 -0
- package/src/orchestrator/stable-keys.ts +259 -10
- package/src/orchestrator/status-line.ts +35 -2
- package/src/orchestrator/summary.ts +115 -25
- package/src/orchestrator/task-hash.ts +103 -21
- package/src/orchestrator/task-log-buffer.ts +15 -18
- package/src/orchestrator/telemetry-host.ts +27 -8
- package/src/orchestrator/telemetry.ts +71 -44
- package/src/orchestrator/upstream.ts +91 -45
- package/src/util/bun-version.ts +43 -0
- package/src/util/cgroup.ts +160 -0
- package/src/util/edit-distance.ts +49 -0
- package/src/util/errors.ts +86 -0
- package/src/util/hash.ts +10 -1
- package/src/util/index.ts +42 -5
- package/src/util/paths.ts +168 -5
- package/src/util/procfs.ts +20 -0
- package/src/util/settle.ts +23 -2
- package/src/util/size.ts +33 -0
- package/src/util/task-id.ts +20 -0
- package/src/util/timing.ts +11 -1
- package/src/util/verbs.ts +39 -0
- package/src/util/which.ts +48 -0
- package/src/workspace/affected.ts +368 -60
- package/src/workspace/config-cache.ts +271 -32
- package/src/workspace/config-eval.ts +60 -16
- package/src/workspace/config-imports.ts +131 -9
- package/src/workspace/config-schema.ts +1033 -0
- package/src/workspace/filter.ts +69 -17
- package/src/workspace/fingerprint.ts +59 -7
- package/src/workspace/index.ts +37 -3
- package/src/workspace/json-data.ts +90 -0
- package/src/workspace/load-reads.ts +32 -0
- package/src/workspace/lockfile.ts +41 -14
- package/src/workspace/migrate-scripts.ts +187 -0
- package/src/workspace/migration.ts +353 -0
- package/src/workspace/package-graph.ts +239 -31
- package/src/workspace/project-loader.ts +295 -815
- package/src/workspace/workspace.ts +266 -31
- package/plugins/local-cache/index.ts +0 -2
- package/plugins/local-executor/index.ts +0 -2
- package/plugins/schedule-history/index.ts +0 -2
- package/src/cache/cas-backend.ts +0 -115
- package/src/cache/digest.ts +0 -32
- package/src/cli/migrate-emit.ts +0 -21
- package/src/cli/migrate-nx.ts +0 -473
- package/src/cli/migrate-scripts.ts +0 -127
- package/src/cli/migrate-turbo.ts +0 -401
- package/src/cli/migrate.ts +0 -305
- package/src/cli/prune.ts +0 -291
- package/src/orchestrator/missing-plugin.ts +0 -9
- package/src/orchestrator/resources.ts +0 -45
- package/src/orchestrator/verify.ts +0 -174
- package/src/plugins/local-cache/index.ts +0 -11
- package/src/plugins/local-executor/index.ts +0 -45
- 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
|
+
}
|