@vzn/vx 0.0.16 → 0.0.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +1 -1
- package/README.md +135 -124
- package/index.ts +10 -0
- package/launcher.mjs +10 -11
- package/package.json +11 -7
- package/src/bin.ts +14 -4
- package/src/cache/archive.ts +659 -0
- package/src/cache/cache.ts +769 -1101
- package/src/cache/chained-cache.ts +194 -0
- package/src/cache/config-evals.ts +89 -0
- package/src/cache/file-hashes.ts +262 -0
- package/src/cache/git-inputs.ts +637 -0
- package/src/cache/index.ts +24 -5
- package/src/cache/inputs.ts +373 -456
- package/src/cache/layer.ts +604 -0
- package/src/cache/layered-cache.ts +96 -43
- package/src/cache/output-index.ts +224 -0
- package/src/cache/policy.ts +84 -0
- package/src/cache/run-history.ts +147 -0
- package/src/cache/tar-stream.ts +358 -0
- package/src/cache/zstd.ts +160 -0
- package/src/cli/cache.ts +75 -21
- package/src/cli/completions.ts +104 -0
- package/src/cli/core-alias.ts +25 -0
- package/src/cli/help.ts +195 -128
- package/src/cli/index.ts +116 -46
- package/src/cli/info.ts +122 -63
- package/src/cli/init.ts +75 -0
- package/src/cli/last.ts +172 -0
- package/src/cli/lock.ts +2 -1
- package/src/cli/plan-format.ts +72 -6
- package/src/cli/plugin-commands.ts +103 -0
- package/src/cli/run.ts +245 -271
- package/src/cli/select.ts +332 -0
- package/src/cli/show.ts +144 -69
- package/src/cli/upgrade.ts +9 -7
- package/src/cli/watch.ts +858 -110
- package/src/cli/why.ts +223 -0
- package/src/cli/workspace-config.ts +126 -0
- package/src/config.ts +187 -150
- package/src/exec/env.ts +18 -1
- package/src/exec/executor.ts +224 -0
- package/src/exec/index.ts +24 -2
- package/src/exec/local-executor.ts +41 -0
- package/src/exec/runner.ts +166 -40
- package/src/exec/sandbox-binds.ts +182 -0
- package/src/exec/sandbox-paths.ts +50 -0
- package/src/exec/sandbox-runtime.ts +642 -260
- package/src/exec/sandbox-violations.ts +280 -0
- package/src/graph/index.ts +4 -10
- package/src/graph/scheduler.ts +232 -203
- package/src/graph/task-graph.ts +222 -8
- package/src/index.ts +146 -153
- package/src/orchestrator/admission.ts +137 -0
- package/src/orchestrator/deferred-outputs.ts +147 -0
- package/src/orchestrator/doctor.ts +255 -0
- package/src/orchestrator/download-policy.ts +171 -0
- package/src/orchestrator/events.ts +72 -8
- package/src/orchestrator/execute-task.ts +374 -549
- package/src/orchestrator/failure-mode.ts +202 -0
- package/src/orchestrator/framed-output.ts +126 -69
- package/src/orchestrator/history.ts +126 -59
- package/src/orchestrator/hit-restore.ts +221 -0
- package/src/orchestrator/index.ts +77 -93
- package/src/orchestrator/local-shortcircuit.ts +22 -0
- package/src/orchestrator/lockfile-claim.ts +281 -0
- package/src/orchestrator/logger.ts +208 -35
- package/src/orchestrator/metrics.ts +102 -1388
- package/src/orchestrator/miss-save.ts +169 -0
- package/src/orchestrator/options.ts +55 -34
- package/src/orchestrator/persistent.ts +79 -0
- package/src/orchestrator/placement.ts +182 -0
- package/src/orchestrator/plan.ts +144 -1
- package/src/orchestrator/plugin-host.ts +357 -120
- package/src/orchestrator/plugin.ts +298 -58
- package/src/orchestrator/prepare.ts +202 -106
- package/src/orchestrator/projects.ts +259 -0
- package/src/orchestrator/remote-prefetch.ts +12 -8
- package/src/orchestrator/run-artifacts.ts +63 -16
- package/src/orchestrator/run-context.ts +167 -29
- package/src/orchestrator/run-records.ts +149 -0
- package/src/orchestrator/run-report.ts +74 -47
- package/src/orchestrator/run.ts +534 -417
- package/src/orchestrator/sandbox-request.ts +243 -0
- package/src/orchestrator/save-lane.ts +47 -0
- package/src/orchestrator/signals.ts +111 -0
- package/src/orchestrator/stable-keys.ts +129 -31
- package/src/orchestrator/status-line.ts +49 -9
- package/src/orchestrator/summary.ts +90 -26
- package/src/orchestrator/tally.ts +78 -35
- package/src/orchestrator/task-hash.ts +111 -15
- package/src/orchestrator/task-log-buffer.ts +288 -0
- package/src/orchestrator/telemetry-host.ts +34 -6
- package/src/orchestrator/telemetry.ts +182 -36
- package/src/orchestrator/upstream.ts +72 -31
- package/src/util/cgroup.ts +160 -0
- package/src/util/edit-distance.ts +70 -0
- package/src/util/errors.ts +15 -1
- package/src/util/index.ts +15 -2
- package/src/util/num.ts +37 -0
- package/src/util/paths.ts +64 -0
- package/src/util/settle.ts +67 -0
- package/src/util/size.ts +5 -1
- package/src/util/tail.ts +66 -0
- package/src/util/timing.ts +63 -0
- package/src/util/verbs.ts +39 -0
- package/src/workspace/affected.ts +250 -23
- package/src/workspace/config-cache.ts +313 -0
- package/src/workspace/config-eval.ts +217 -0
- package/src/workspace/config-imports.ts +162 -0
- package/src/workspace/config-schema.ts +837 -0
- package/src/workspace/filter.ts +65 -10
- package/src/workspace/fingerprint.ts +53 -4
- package/src/workspace/index.ts +42 -3
- package/src/workspace/lockfile.ts +25 -9
- package/src/workspace/migrate-scripts.ts +134 -0
- package/src/workspace/migration.ts +314 -0
- package/src/workspace/nested-dirs.ts +21 -7
- package/src/workspace/package-graph.ts +114 -41
- package/src/workspace/project-loader.ts +224 -450
- package/src/workspace/workspace.ts +298 -72
- package/src/cache/cas-backend.ts +0 -97
- package/src/cache/digest.ts +0 -47
- package/src/cache/tar.ts +0 -343
- package/src/cli/backend.ts +0 -23
- package/src/cli/mcp-rpc.ts +0 -292
- package/src/cli/mcp.ts +0 -92
- package/src/cli/migrate-nx.ts +0 -450
- package/src/cli/migrate-turbo.ts +0 -364
- package/src/cli/migrate.ts +0 -219
- package/src/orchestrator/devframe-surface.ts +0 -74
- package/src/orchestrator/predict.ts +0 -106
- package/src/orchestrator/protocol.ts +0 -156
- package/src/orchestrator/resources.ts +0 -59
- package/src/orchestrator/run-state.ts +0 -145
- package/src/orchestrator/verify.ts +0 -164
- package/src/orchestrator/wire-render.ts +0 -92
- package/src/orchestrator/wire.ts +0 -212
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
// Several declared cache layers, consulted in declaration order. Lookup
|
|
2
|
+
// walks the layers until one answers; a save reaches every layer; the FIRST
|
|
3
|
+
// layer owns the run index (history, stats, prune) so a run is recorded
|
|
4
|
+
// once. Restore goes to the layer that produced the hit — remembered per
|
|
5
|
+
// hash — because an entry's artifact lives wherever it was found.
|
|
6
|
+
|
|
7
|
+
import type {
|
|
8
|
+
Cache,
|
|
9
|
+
CacheEntry,
|
|
10
|
+
CacheGetContext,
|
|
11
|
+
CacheKeyInput,
|
|
12
|
+
CacheLayer,
|
|
13
|
+
CacheStats,
|
|
14
|
+
CacheStatsOptions,
|
|
15
|
+
IngestMeta,
|
|
16
|
+
InvocationRecord,
|
|
17
|
+
OutputDirRow,
|
|
18
|
+
OutputFileRow,
|
|
19
|
+
PruneOptions,
|
|
20
|
+
PruneResult,
|
|
21
|
+
RunRecord,
|
|
22
|
+
} from './cache.js'
|
|
23
|
+
|
|
24
|
+
export class ChainedCache implements CacheLayer {
|
|
25
|
+
readonly hasRemote: boolean
|
|
26
|
+
private readonly hitLayer = new Map<string, CacheLayer>()
|
|
27
|
+
|
|
28
|
+
constructor(readonly layers: readonly CacheLayer[]) {
|
|
29
|
+
if (layers.length < 2) throw new Error('ChainedCache needs at least two layers')
|
|
30
|
+
this.hasRemote = layers.some((l) => l.hasRemote === true)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
get local(): Cache | undefined {
|
|
34
|
+
return this.layers[0]!.local
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
private owner(hash: string): CacheLayer {
|
|
38
|
+
return this.hitLayer.get(hash) ?? this.layers[0]!
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
key(input: CacheKeyInput): Promise<string> {
|
|
42
|
+
return this.layers[0]!.key(input)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async get(hash: string, ctx?: CacheGetContext): Promise<CacheEntry | null> {
|
|
46
|
+
for (const layer of this.layers) {
|
|
47
|
+
const entry = await layer.get(hash, ctx)
|
|
48
|
+
if (entry !== null) {
|
|
49
|
+
this.hitLayer.set(hash, layer)
|
|
50
|
+
return entry
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return null
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async has(hash: string): Promise<'local' | 'remote' | null> {
|
|
57
|
+
for (const layer of this.layers) {
|
|
58
|
+
const where = await layer.has(hash)
|
|
59
|
+
if (where !== null) {
|
|
60
|
+
this.hitLayer.set(hash, layer)
|
|
61
|
+
return where
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return null
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async prefetch(hash: string, ctx?: CacheGetContext): Promise<boolean> {
|
|
68
|
+
for (const layer of this.layers) {
|
|
69
|
+
if (await layer.prefetch(hash, ctx)) {
|
|
70
|
+
this.hitLayer.set(hash, layer)
|
|
71
|
+
return true
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return false
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async remoteHasMany(hashes: readonly string[]): Promise<Set<string> | null> {
|
|
78
|
+
// The caller (remote-prefetch) treats a non-null answer as authoritative
|
|
79
|
+
// for the WHOLE chain: complement = absent, broadcast. That is sound only
|
|
80
|
+
// if EVERY remote layer answered — a partial union would poison a layer
|
|
81
|
+
// that cannot batch with another layer's negatives, and its later lazy
|
|
82
|
+
// get() would skip a real remote hit. So: each answering layer gets its
|
|
83
|
+
// OWN complement marked here (its own truth — this also spares it the
|
|
84
|
+
// per-hash GETs for hashes only a sibling holds), and the merged answer
|
|
85
|
+
// is returned only when no remote layer was left unanswered.
|
|
86
|
+
let out: Set<string> | null = null
|
|
87
|
+
let complete = true
|
|
88
|
+
for (const layer of this.layers) {
|
|
89
|
+
if (layer.hasRemote !== true) continue
|
|
90
|
+
const found = (await layer.remoteHasMany?.(hashes)) ?? null
|
|
91
|
+
if (found === null) {
|
|
92
|
+
complete = false
|
|
93
|
+
continue
|
|
94
|
+
}
|
|
95
|
+
layer.markRemoteAbsent?.(hashes.filter((h) => !found.has(h)))
|
|
96
|
+
out ??= new Set()
|
|
97
|
+
for (const h of found) out.add(h)
|
|
98
|
+
}
|
|
99
|
+
return complete ? out : null
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
markRemoteAbsent(hashes: Iterable<string>): void {
|
|
103
|
+
const list = [...hashes]
|
|
104
|
+
for (const layer of this.layers) layer.markRemoteAbsent?.(list)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async drainUploads(): Promise<void> {
|
|
108
|
+
await Promise.all(this.layers.map((l) => l.drainUploads?.()))
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
loadOutputFilesBatch(hashes: readonly string[]): Map<string, OutputFileRow[]> {
|
|
112
|
+
const out = new Map<string, OutputFileRow[]>()
|
|
113
|
+
for (const layer of this.layers) {
|
|
114
|
+
for (const [h, rows] of layer.loadOutputFilesBatch(hashes)) {
|
|
115
|
+
if (!out.has(h)) out.set(h, rows)
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return out
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
isOutputsCurrent(projectDir: string, expected: readonly OutputFileRow[]): Promise<boolean> {
|
|
122
|
+
return this.layers[0]!.isOutputsCurrent(projectDir, expected)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// The directory short-circuit lives with the layer that owns the local
|
|
126
|
+
// rows — the first, like the file check.
|
|
127
|
+
recordOutputDirs(hash: string, projectDir: string, prefixes: readonly string[]): Promise<void> {
|
|
128
|
+
return this.layers[0]!.recordOutputDirs?.(hash, projectDir, prefixes) ?? Promise.resolve()
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
loadOutputDirsBatch(hashes: readonly string[]): Map<string, OutputDirRow[]> {
|
|
132
|
+
return this.layers[0]!.loadOutputDirsBatch?.(hashes) ?? new Map()
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
outputDirsCurrent(projectDir: string, rows: readonly OutputDirRow[]): Promise<boolean> {
|
|
136
|
+
return this.layers[0]!.outputDirsCurrent?.(projectDir, rows) ?? Promise.resolve(false)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
restoreOutputs(hash: string, projectDir: string, workspaceRoot?: string): Promise<void> {
|
|
140
|
+
return this.owner(hash).restoreOutputs(hash, projectDir, workspaceRoot)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async save(args: Parameters<CacheLayer['save']>[0]): Promise<void> {
|
|
144
|
+
// Layers wrapping the SAME local handle (two remote plugins over
|
|
145
|
+
// ctx.localCache) would each pack + write the identical artifact; the
|
|
146
|
+
// first write is the only one that matters, so later layers get
|
|
147
|
+
// `skipLocalWrite` and go straight to their remote upload (which reads
|
|
148
|
+
// the artifact the first layer just wrote — same handle, same path).
|
|
149
|
+
const seenLocals = new Set<NonNullable<CacheLayer['local']>>()
|
|
150
|
+
for (const layer of this.layers) {
|
|
151
|
+
const local = layer.local
|
|
152
|
+
const skip = local !== undefined && seenLocals.has(local)
|
|
153
|
+
await layer.save(skip ? { ...args, skipLocalWrite: true } : args)
|
|
154
|
+
if (local !== undefined) seenLocals.add(local)
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
ingest(hash: string, compressed: Uint8Array, meta: IngestMeta): Promise<void> {
|
|
159
|
+
return this.layers[0]!.ingest(hash, compressed, meta)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
recordRunBundle(bundle: { runs: readonly RunRecord[]; invocation: InvocationRecord }): void {
|
|
163
|
+
this.layers[0]!.recordRunBundle(bundle)
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
stats(opts?: CacheStatsOptions): CacheStats {
|
|
167
|
+
return this.layers[0]!.stats(opts)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
hashFile(filePath: string): Promise<string> {
|
|
171
|
+
return this.layers[0]!.hashFile(filePath)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
outputsPath(hash: string): string {
|
|
175
|
+
return this.owner(hash).outputsPath(hash)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
prune(options: PruneOptions): Promise<PruneResult> {
|
|
179
|
+
return this.layers[0]!.prune(options)
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
close(): void {
|
|
183
|
+
// Every layer closes even if an earlier one throws; the first error wins.
|
|
184
|
+
let failure: unknown
|
|
185
|
+
for (const layer of this.layers) {
|
|
186
|
+
try {
|
|
187
|
+
layer.close()
|
|
188
|
+
} catch (err) {
|
|
189
|
+
failure ??= err
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
if (failure !== undefined) throw failure
|
|
193
|
+
}
|
|
194
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// The config-evaluation cache tables (`config_evals`, `config_closures`):
|
|
2
|
+
// what `workspace/config-cache.ts` reads and writes through the
|
|
3
|
+
// `ConfigEvalStore` contract, honouring the run's local read/write axes.
|
|
4
|
+
// Owns its statements over the store's handle; `Cache` delegates.
|
|
5
|
+
|
|
6
|
+
import type { Database, SQLQueryBindings } from 'bun:sqlite'
|
|
7
|
+
|
|
8
|
+
export class ConfigEvalTable {
|
|
9
|
+
private readonly selectConfigEval: ReturnType<Database['prepare']>
|
|
10
|
+
private readonly insertConfigEval: ReturnType<Database['prepare']>
|
|
11
|
+
private readonly read: boolean
|
|
12
|
+
private readonly write: boolean
|
|
13
|
+
|
|
14
|
+
constructor(
|
|
15
|
+
private readonly db: Database,
|
|
16
|
+
policy: { read: boolean; write: boolean },
|
|
17
|
+
) {
|
|
18
|
+
this.read = policy.read
|
|
19
|
+
this.write = policy.write
|
|
20
|
+
this.selectConfigEval = this.db.prepare('SELECT json FROM config_evals WHERE key = ?')
|
|
21
|
+
this.insertConfigEval = this.db.prepare(
|
|
22
|
+
'INSERT OR REPLACE INTO config_evals(key, json, created_at) VALUES (?, ?, ?)',
|
|
23
|
+
)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** `ConfigEvalStore`: a cached config evaluation, honouring the local READ axis. */
|
|
27
|
+
getConfigEval(key: string): string | null {
|
|
28
|
+
if (!this.read) return null
|
|
29
|
+
const row = this.selectConfigEval.get(key) as { json: string } | null
|
|
30
|
+
return row?.json ?? null
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** `ConfigEvalStore`: each config's ordered closure, one `IN` query per 900 paths. */
|
|
34
|
+
getConfigClosures(configPaths: readonly string[]): Map<string, string[]> {
|
|
35
|
+
const out = new Map<string, string[]>()
|
|
36
|
+
if (!this.read || configPaths.length === 0) return out
|
|
37
|
+
for (let i = 0; i < configPaths.length; i += 900) {
|
|
38
|
+
const chunk = configPaths.slice(i, i + 900)
|
|
39
|
+
const rows = this.db
|
|
40
|
+
.query(
|
|
41
|
+
`SELECT config_path, files_json FROM config_closures WHERE config_path IN (${chunk.map(() => '?').join(',')})`,
|
|
42
|
+
)
|
|
43
|
+
.all(...(chunk as readonly SQLQueryBindings[])) as Array<{
|
|
44
|
+
config_path: string
|
|
45
|
+
files_json: string
|
|
46
|
+
}>
|
|
47
|
+
for (const r of rows) out.set(r.config_path, JSON.parse(r.files_json) as string[])
|
|
48
|
+
}
|
|
49
|
+
return out
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** `ConfigEvalStore`: remember a config's ordered closure, honouring the local WRITE axis. */
|
|
53
|
+
putConfigClosure(configPath: string, files: readonly string[]): void {
|
|
54
|
+
if (!this.write) return
|
|
55
|
+
this.db
|
|
56
|
+
.prepare(
|
|
57
|
+
'INSERT INTO config_closures(config_path, files_json, created_at) VALUES (?, ?, ?) ON CONFLICT(config_path) DO UPDATE SET files_json = excluded.files_json, created_at = excluded.created_at',
|
|
58
|
+
)
|
|
59
|
+
.run(configPath, JSON.stringify(files), Date.now())
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** `ConfigEvalStore`: the batched read — one `IN` query per 900 keys, honouring the local READ axis. */
|
|
63
|
+
getConfigEvals(keys: readonly string[]): Map<string, string> {
|
|
64
|
+
const out = new Map<string, string>()
|
|
65
|
+
if (!this.read || keys.length === 0) return out
|
|
66
|
+
for (let i = 0; i < keys.length; i += 900) {
|
|
67
|
+
const chunk = keys.slice(i, i + 900)
|
|
68
|
+
const rows = this.db
|
|
69
|
+
.query(
|
|
70
|
+
`SELECT key, json FROM config_evals WHERE key IN (${chunk.map(() => '?').join(',')})`,
|
|
71
|
+
)
|
|
72
|
+
.all(...(chunk as readonly SQLQueryBindings[])) as Array<{ key: string; json: string }>
|
|
73
|
+
for (const r of rows) out.set(r.key, r.json)
|
|
74
|
+
}
|
|
75
|
+
return out
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** `ConfigEvalStore`: remember a validated evaluation, honouring the local WRITE axis. */
|
|
79
|
+
putConfigEval(key: string, json: string): void {
|
|
80
|
+
if (!this.write) return
|
|
81
|
+
this.insertConfigEval.run(key, json, Date.now())
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Retention: a config not loaded since `cutoff` was edited or its project left. */
|
|
85
|
+
pruneOlderThan(cutoff: number): void {
|
|
86
|
+
this.db.prepare('DELETE FROM config_evals WHERE created_at < ?').run(cutoff)
|
|
87
|
+
this.db.prepare('DELETE FROM config_closures WHERE created_at < ?').run(cutoff)
|
|
88
|
+
}
|
|
89
|
+
}
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
// The per-file content-hash memo. A file's cache-key contribution is its
|
|
2
|
+
// git blob OID; a clean tracked file takes it from the index (inputs.ts),
|
|
3
|
+
// everything else is hashed here from the worktree bytes — memoized on
|
|
4
|
+
// (mtime, size, ctime, ino) in `file_hashes`, the same four fields git's
|
|
5
|
+
// own index keys on — and a symlink hashes as the blob of its target
|
|
6
|
+
// string, exactly as the index stores it. Owns its two statements over
|
|
7
|
+
// the store's handle; `Cache` delegates.
|
|
8
|
+
|
|
9
|
+
import type { Database } from 'bun:sqlite'
|
|
10
|
+
import { lstatSync, readlinkSync, statSync } from 'node:fs'
|
|
11
|
+
import path from 'node:path'
|
|
12
|
+
import { FILE_HASH_RACY_MS } from './layer.js'
|
|
13
|
+
|
|
14
|
+
export class FileHashStore {
|
|
15
|
+
private readonly selectFileHash: ReturnType<Database['prepare']>
|
|
16
|
+
private readonly upsertFileHash: ReturnType<Database['prepare']>
|
|
17
|
+
private objectFormat: 'sha1' | 'sha256' | null = null
|
|
18
|
+
|
|
19
|
+
constructor(
|
|
20
|
+
private readonly db: Database,
|
|
21
|
+
/** Where `git rev-parse --show-object-format` is asked, once, when a file misses the memo. */
|
|
22
|
+
private readonly cacheDir: string,
|
|
23
|
+
) {
|
|
24
|
+
this.selectFileHash = this.db.prepare(
|
|
25
|
+
'SELECT mtime_ms, size_bytes, ctime_ms, ino, content_hash FROM file_hashes WHERE path = ?',
|
|
26
|
+
)
|
|
27
|
+
this.upsertFileHash = this.db.prepare(`
|
|
28
|
+
INSERT INTO file_hashes(path, mtime_ms, size_bytes, ctime_ms, ino, content_hash, seen_at)
|
|
29
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
30
|
+
ON CONFLICT(path) DO UPDATE SET
|
|
31
|
+
mtime_ms = excluded.mtime_ms,
|
|
32
|
+
size_bytes = excluded.size_bytes,
|
|
33
|
+
ctime_ms = excluded.ctime_ms,
|
|
34
|
+
ino = excluded.ino,
|
|
35
|
+
content_hash = excluded.content_hash,
|
|
36
|
+
seen_at = excluded.seen_at
|
|
37
|
+
`)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Content-hash a file (as a git blob OID, v20) with an mtime+size
|
|
42
|
+
* fast path. If the `file_hashes` table has a row for `path` whose
|
|
43
|
+
* `(mtime_ms, size_bytes, ctime_ms, ino)` all match the current
|
|
44
|
+
* stat, we reuse the stored content_hash (a memory + SQLite lookup,
|
|
45
|
+
* no disk read). Otherwise we read + hash + upsert. All four fields
|
|
46
|
+
* are load-bearing — see the comment at the comparison.
|
|
47
|
+
*
|
|
48
|
+
* The OID is byte-identical to what `git hash-object` (and the git
|
|
49
|
+
* index) computes for the same content — for a symlink, the index's
|
|
50
|
+
* mode-120000 blob of the link text — so this fallback and the
|
|
51
|
+
* `CacheKeyInput.fileHashes` index-OID fast path agree on any file
|
|
52
|
+
* git stores verbatim. They do NOT agree when a clean filter
|
|
53
|
+
* (`text`/`eol`/`ident`) is active: the index blob is the filtered
|
|
54
|
+
* form while this hashes the worktree bytes, so `inputs.ts` drops
|
|
55
|
+
* the index OID for those paths and routes them here.
|
|
56
|
+
*/
|
|
57
|
+
async hashFile(filePath: string): Promise<string> {
|
|
58
|
+
// lstatSync intentional: a single stat is ~1.6µs (Bun 1.3); the
|
|
59
|
+
// async-stat equivalent adds ~75µs of Promise machinery per call.
|
|
60
|
+
// Promise.all over the batched callers (key derivation) gives no
|
|
61
|
+
// I/O parallelism benefit because the stat is faster than the
|
|
62
|
+
// threadpool dispatch overhead. lstat, not stat, so a symlink is
|
|
63
|
+
// seen as one.
|
|
64
|
+
let st
|
|
65
|
+
try {
|
|
66
|
+
st = lstatSync(filePath)
|
|
67
|
+
} catch {
|
|
68
|
+
// Caller is responsible for skipping files that don't exist;
|
|
69
|
+
// fall through to the content-hash path which will throw with
|
|
70
|
+
// a more useful error.
|
|
71
|
+
return await this.hashFileFromDisk(filePath)
|
|
72
|
+
}
|
|
73
|
+
// A symlink folds as git folds it: the blob of its TARGET STRING, which
|
|
74
|
+
// is its mode-120000 index OID. Not the bytes behind it — a link to a
|
|
75
|
+
// directory has none, a dangling one has none, and a link to a file
|
|
76
|
+
// outside the project would fold bytes `git diff` and `--affected`
|
|
77
|
+
// cannot see. A link to a file inside the project still tracks that
|
|
78
|
+
// file's content, because the file is an input in its own right. No
|
|
79
|
+
// memo: readlink is one syscall, and the row would be keyed on the
|
|
80
|
+
// link's own stat, not its target's.
|
|
81
|
+
if (st.isSymbolicLink()) return this.hashBlob(new TextEncoder().encode(readlinkSync(filePath)))
|
|
82
|
+
const mtimeMs = Math.floor(st.mtimeMs)
|
|
83
|
+
const size = st.size
|
|
84
|
+
// ctime + ino are what make this memo SAFE, not merely fast. mtime is
|
|
85
|
+
// caller-settable, so (mtime, size) alone hands back the previous run's
|
|
86
|
+
// digest for genuinely different bytes whenever a producer preserves
|
|
87
|
+
// mtime — `tar -x`, `unzip`, `cp -p`, `rsync --times`, any
|
|
88
|
+
// SOURCE_DATE_EPOCH generator — which is a stale cache hit. `utimes`
|
|
89
|
+
// cannot suppress ctime without root, and an atomic write-then-rename
|
|
90
|
+
// changes the inode; git's own index keys on ctime+ino+dev for exactly
|
|
91
|
+
// this reason. Both fields come from the stat we already took.
|
|
92
|
+
const ctimeMs = Math.floor(st.ctimeMs)
|
|
93
|
+
const ino = Number(st.ino)
|
|
94
|
+
const row = this.selectFileHash.get(filePath) as
|
|
95
|
+
| {
|
|
96
|
+
mtime_ms: number
|
|
97
|
+
size_bytes: number
|
|
98
|
+
ctime_ms: number
|
|
99
|
+
ino: number
|
|
100
|
+
content_hash: string
|
|
101
|
+
}
|
|
102
|
+
| undefined
|
|
103
|
+
if (
|
|
104
|
+
row &&
|
|
105
|
+
row.mtime_ms === mtimeMs &&
|
|
106
|
+
row.size_bytes === size &&
|
|
107
|
+
row.ctime_ms === ctimeMs &&
|
|
108
|
+
row.ino === ino
|
|
109
|
+
) {
|
|
110
|
+
return row.content_hash
|
|
111
|
+
}
|
|
112
|
+
const ch = await this.hashFileFromDisk(filePath)
|
|
113
|
+
// git's racy-clean rule, applied to the memo: a stat taken in the same
|
|
114
|
+
// tick as the file's last change cannot be trusted next time, because a
|
|
115
|
+
// rewrite landing in that tick keeps ctime (and, with mtime restored,
|
|
116
|
+
// every other field) equal and would hand back THIS digest for other
|
|
117
|
+
// bytes — seen once on ubuntu CI, on a docs-only commit. So a file
|
|
118
|
+
// changed within the window is hashed again on its next call rather
|
|
119
|
+
// than memoised; the warm path never meets it (keys are derived long
|
|
120
|
+
// after the files were written).
|
|
121
|
+
if (Date.now() - ctimeMs >= FILE_HASH_RACY_MS) {
|
|
122
|
+
this.upsertFileHash.run(filePath, mtimeMs, size, ctimeMs, ino, ch, Date.now())
|
|
123
|
+
}
|
|
124
|
+
return ch
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* `hashFile` for many paths at once, with ONE memo query. The per-file
|
|
129
|
+
* form costs a SQLite point lookup each; a warm config load keys 1,000
|
|
130
|
+
* closures that way (7.7 ms of `get` in a 2026-09-09 profile), where one
|
|
131
|
+
* `IN` query over all of them costs well under a millisecond. Same stat
|
|
132
|
+
* fields, same racy-clean rule, same digest. A path that cannot be
|
|
133
|
+
* stat'ed is absent from the result (the caller decides what a missing
|
|
134
|
+
* identity means) rather than thrown, so one vanished preset does not
|
|
135
|
+
* fail the batch for every other config.
|
|
136
|
+
*/
|
|
137
|
+
async hashFiles(paths: readonly string[]): Promise<Map<string, string>> {
|
|
138
|
+
const out = new Map<string, string>()
|
|
139
|
+
if (paths.length === 0) return out
|
|
140
|
+
interface Stat {
|
|
141
|
+
mtimeMs: number
|
|
142
|
+
size: number
|
|
143
|
+
ctimeMs: number
|
|
144
|
+
ino: number
|
|
145
|
+
}
|
|
146
|
+
const stats = new Map<string, Stat>()
|
|
147
|
+
for (const p of paths) {
|
|
148
|
+
if (stats.has(p)) continue
|
|
149
|
+
try {
|
|
150
|
+
const st = statSync(p)
|
|
151
|
+
stats.set(p, {
|
|
152
|
+
mtimeMs: Math.floor(st.mtimeMs),
|
|
153
|
+
size: st.size,
|
|
154
|
+
ctimeMs: Math.floor(st.ctimeMs),
|
|
155
|
+
ino: Number(st.ino),
|
|
156
|
+
})
|
|
157
|
+
} catch {
|
|
158
|
+
// absent from the result
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
const wanted = [...stats.keys()]
|
|
162
|
+
const misses: string[] = []
|
|
163
|
+
// SQLite's bound-variable ceiling is 32,766 (999 on old builds); 500
|
|
164
|
+
// keeps a closure list of any size inside either.
|
|
165
|
+
for (let i = 0; i < wanted.length; i += 500) {
|
|
166
|
+
const chunk = wanted.slice(i, i + 500)
|
|
167
|
+
const rows = this.db
|
|
168
|
+
.query(
|
|
169
|
+
`SELECT path, mtime_ms, size_bytes, ctime_ms, ino, content_hash FROM file_hashes WHERE path IN (${chunk.map(() => '?').join(',')})`,
|
|
170
|
+
)
|
|
171
|
+
.all(...chunk) as Array<{
|
|
172
|
+
path: string
|
|
173
|
+
mtime_ms: number
|
|
174
|
+
size_bytes: number
|
|
175
|
+
ctime_ms: number
|
|
176
|
+
ino: number
|
|
177
|
+
content_hash: string
|
|
178
|
+
}>
|
|
179
|
+
const byPath = new Map(rows.map((r) => [r.path, r]))
|
|
180
|
+
for (const p of chunk) {
|
|
181
|
+
const st = stats.get(p)!
|
|
182
|
+
const row = byPath.get(p)
|
|
183
|
+
if (
|
|
184
|
+
row &&
|
|
185
|
+
row.mtime_ms === st.mtimeMs &&
|
|
186
|
+
row.size_bytes === st.size &&
|
|
187
|
+
row.ctime_ms === st.ctimeMs &&
|
|
188
|
+
row.ino === st.ino
|
|
189
|
+
) {
|
|
190
|
+
out.set(p, row.content_hash)
|
|
191
|
+
} else misses.push(p)
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
if (misses.length === 0) return out
|
|
195
|
+
const digests = await Promise.all(
|
|
196
|
+
misses.map((p) => this.hashFileFromDisk(p).catch(() => undefined)),
|
|
197
|
+
)
|
|
198
|
+
const now = Date.now()
|
|
199
|
+
this.db.transaction(() => {
|
|
200
|
+
for (let i = 0; i < misses.length; i++) {
|
|
201
|
+
const digest = digests[i]
|
|
202
|
+
if (digest === undefined) continue
|
|
203
|
+
const p = misses[i]!
|
|
204
|
+
const st = stats.get(p)!
|
|
205
|
+
out.set(p, digest)
|
|
206
|
+
// The same racy-clean rule as `hashFile`: a stat taken within the
|
|
207
|
+
// window of the file's last change is not memoised.
|
|
208
|
+
if (now - st.ctimeMs >= FILE_HASH_RACY_MS) {
|
|
209
|
+
this.upsertFileHash.run(p, st.mtimeMs, st.size, st.ctimeMs, st.ino, digest, now)
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
})()
|
|
213
|
+
return out
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Git blob OID of the file's bytes:
|
|
218
|
+
* `hex(HASH("blob " + byteLength + "\0" + content))`, where HASH is
|
|
219
|
+
* the repo's object format. Same value `git hash-object` prints and
|
|
220
|
+
* the same value the index stores. Computed in-process — no git
|
|
221
|
+
* spawn per file.
|
|
222
|
+
*/
|
|
223
|
+
private async hashFileFromDisk(filePath: string): Promise<string> {
|
|
224
|
+
return this.hashBlob(await Bun.file(filePath).bytes(), filePath)
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** `git hash-object` of `bytes`: the blob OID in the repo's object format. */
|
|
228
|
+
private hashBlob(bytes: Uint8Array, nearPath?: string): string {
|
|
229
|
+
const hasher = new Bun.CryptoHasher(
|
|
230
|
+
this.objectFormat ?? this.detectObjectFormat(nearPath ?? this.cacheDir),
|
|
231
|
+
)
|
|
232
|
+
hasher.update(`blob ${bytes.byteLength}\0`)
|
|
233
|
+
hasher.update(bytes)
|
|
234
|
+
return hasher.digest('hex')
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Repo object format — sha1 unless the repo was created with
|
|
239
|
+
* `--object-format=sha256`. One `git rev-parse` spawn per Cache
|
|
240
|
+
* lifetime, and only when at least one file misses the mtime+size
|
|
241
|
+
* memo. Outside a repo (unit fixtures) we default to sha1, which is
|
|
242
|
+
* still a deterministic blob-OID domain.
|
|
243
|
+
*/
|
|
244
|
+
private detectObjectFormat(nearPath: string): 'sha1' | 'sha256' {
|
|
245
|
+
let detected: 'sha1' | 'sha256' = 'sha1'
|
|
246
|
+
try {
|
|
247
|
+
const proc = Bun.spawnSync({
|
|
248
|
+
cmd: ['git', 'rev-parse', '--show-object-format'],
|
|
249
|
+
cwd: path.dirname(nearPath),
|
|
250
|
+
stdout: 'pipe',
|
|
251
|
+
stderr: 'pipe',
|
|
252
|
+
})
|
|
253
|
+
if (proc.exitCode === 0 && new TextDecoder().decode(proc.stdout).trim() === 'sha256') {
|
|
254
|
+
detected = 'sha256'
|
|
255
|
+
}
|
|
256
|
+
} catch {
|
|
257
|
+
// git unavailable → sha1 default keeps hashing deterministic.
|
|
258
|
+
}
|
|
259
|
+
this.objectFormat = detected
|
|
260
|
+
return detected
|
|
261
|
+
}
|
|
262
|
+
}
|