@vzn/vx 0.0.16 → 0.0.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (138) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +135 -124
  3. package/index.ts +10 -0
  4. package/launcher.mjs +10 -11
  5. package/package.json +11 -7
  6. package/src/bin.ts +14 -4
  7. package/src/cache/archive.ts +659 -0
  8. package/src/cache/cache.ts +769 -1101
  9. package/src/cache/chained-cache.ts +194 -0
  10. package/src/cache/config-evals.ts +89 -0
  11. package/src/cache/file-hashes.ts +262 -0
  12. package/src/cache/git-inputs.ts +637 -0
  13. package/src/cache/index.ts +24 -5
  14. package/src/cache/inputs.ts +373 -456
  15. package/src/cache/layer.ts +604 -0
  16. package/src/cache/layered-cache.ts +96 -43
  17. package/src/cache/output-index.ts +224 -0
  18. package/src/cache/policy.ts +84 -0
  19. package/src/cache/run-history.ts +147 -0
  20. package/src/cache/tar-stream.ts +358 -0
  21. package/src/cache/zstd.ts +160 -0
  22. package/src/cli/cache.ts +75 -21
  23. package/src/cli/completions.ts +104 -0
  24. package/src/cli/core-alias.ts +25 -0
  25. package/src/cli/help.ts +195 -128
  26. package/src/cli/index.ts +116 -46
  27. package/src/cli/info.ts +122 -63
  28. package/src/cli/init.ts +75 -0
  29. package/src/cli/last.ts +172 -0
  30. package/src/cli/lock.ts +2 -1
  31. package/src/cli/plan-format.ts +72 -6
  32. package/src/cli/plugin-commands.ts +103 -0
  33. package/src/cli/run.ts +245 -271
  34. package/src/cli/select.ts +332 -0
  35. package/src/cli/show.ts +144 -69
  36. package/src/cli/upgrade.ts +9 -7
  37. package/src/cli/watch.ts +858 -110
  38. package/src/cli/why.ts +223 -0
  39. package/src/cli/workspace-config.ts +126 -0
  40. package/src/config.ts +187 -150
  41. package/src/exec/env.ts +18 -1
  42. package/src/exec/executor.ts +224 -0
  43. package/src/exec/index.ts +24 -2
  44. package/src/exec/local-executor.ts +41 -0
  45. package/src/exec/runner.ts +166 -40
  46. package/src/exec/sandbox-binds.ts +182 -0
  47. package/src/exec/sandbox-paths.ts +50 -0
  48. package/src/exec/sandbox-runtime.ts +642 -260
  49. package/src/exec/sandbox-violations.ts +280 -0
  50. package/src/graph/index.ts +4 -10
  51. package/src/graph/scheduler.ts +232 -203
  52. package/src/graph/task-graph.ts +222 -8
  53. package/src/index.ts +146 -153
  54. package/src/orchestrator/admission.ts +137 -0
  55. package/src/orchestrator/deferred-outputs.ts +147 -0
  56. package/src/orchestrator/doctor.ts +255 -0
  57. package/src/orchestrator/download-policy.ts +171 -0
  58. package/src/orchestrator/events.ts +72 -8
  59. package/src/orchestrator/execute-task.ts +374 -549
  60. package/src/orchestrator/failure-mode.ts +202 -0
  61. package/src/orchestrator/framed-output.ts +126 -69
  62. package/src/orchestrator/history.ts +126 -59
  63. package/src/orchestrator/hit-restore.ts +221 -0
  64. package/src/orchestrator/index.ts +77 -93
  65. package/src/orchestrator/local-shortcircuit.ts +22 -0
  66. package/src/orchestrator/lockfile-claim.ts +281 -0
  67. package/src/orchestrator/logger.ts +208 -35
  68. package/src/orchestrator/metrics.ts +102 -1388
  69. package/src/orchestrator/miss-save.ts +169 -0
  70. package/src/orchestrator/options.ts +55 -34
  71. package/src/orchestrator/persistent.ts +79 -0
  72. package/src/orchestrator/placement.ts +182 -0
  73. package/src/orchestrator/plan.ts +144 -1
  74. package/src/orchestrator/plugin-host.ts +357 -120
  75. package/src/orchestrator/plugin.ts +298 -58
  76. package/src/orchestrator/prepare.ts +202 -106
  77. package/src/orchestrator/projects.ts +259 -0
  78. package/src/orchestrator/remote-prefetch.ts +12 -8
  79. package/src/orchestrator/run-artifacts.ts +63 -16
  80. package/src/orchestrator/run-context.ts +167 -29
  81. package/src/orchestrator/run-records.ts +149 -0
  82. package/src/orchestrator/run-report.ts +74 -47
  83. package/src/orchestrator/run.ts +534 -417
  84. package/src/orchestrator/sandbox-request.ts +243 -0
  85. package/src/orchestrator/save-lane.ts +47 -0
  86. package/src/orchestrator/signals.ts +111 -0
  87. package/src/orchestrator/stable-keys.ts +129 -31
  88. package/src/orchestrator/status-line.ts +49 -9
  89. package/src/orchestrator/summary.ts +90 -26
  90. package/src/orchestrator/tally.ts +78 -35
  91. package/src/orchestrator/task-hash.ts +111 -15
  92. package/src/orchestrator/task-log-buffer.ts +288 -0
  93. package/src/orchestrator/telemetry-host.ts +34 -6
  94. package/src/orchestrator/telemetry.ts +182 -36
  95. package/src/orchestrator/upstream.ts +72 -31
  96. package/src/util/cgroup.ts +160 -0
  97. package/src/util/edit-distance.ts +70 -0
  98. package/src/util/errors.ts +15 -1
  99. package/src/util/index.ts +15 -2
  100. package/src/util/num.ts +37 -0
  101. package/src/util/paths.ts +64 -0
  102. package/src/util/settle.ts +67 -0
  103. package/src/util/size.ts +5 -1
  104. package/src/util/tail.ts +66 -0
  105. package/src/util/timing.ts +63 -0
  106. package/src/util/verbs.ts +39 -0
  107. package/src/workspace/affected.ts +250 -23
  108. package/src/workspace/config-cache.ts +313 -0
  109. package/src/workspace/config-eval.ts +217 -0
  110. package/src/workspace/config-imports.ts +162 -0
  111. package/src/workspace/config-schema.ts +837 -0
  112. package/src/workspace/filter.ts +65 -10
  113. package/src/workspace/fingerprint.ts +53 -4
  114. package/src/workspace/index.ts +42 -3
  115. package/src/workspace/lockfile.ts +25 -9
  116. package/src/workspace/migrate-scripts.ts +134 -0
  117. package/src/workspace/migration.ts +314 -0
  118. package/src/workspace/nested-dirs.ts +21 -7
  119. package/src/workspace/package-graph.ts +114 -41
  120. package/src/workspace/project-loader.ts +224 -450
  121. package/src/workspace/workspace.ts +298 -72
  122. package/src/cache/cas-backend.ts +0 -97
  123. package/src/cache/digest.ts +0 -47
  124. package/src/cache/tar.ts +0 -343
  125. package/src/cli/backend.ts +0 -23
  126. package/src/cli/mcp-rpc.ts +0 -292
  127. package/src/cli/mcp.ts +0 -92
  128. package/src/cli/migrate-nx.ts +0 -450
  129. package/src/cli/migrate-turbo.ts +0 -364
  130. package/src/cli/migrate.ts +0 -219
  131. package/src/orchestrator/devframe-surface.ts +0 -74
  132. package/src/orchestrator/predict.ts +0 -106
  133. package/src/orchestrator/protocol.ts +0 -156
  134. package/src/orchestrator/resources.ts +0 -59
  135. package/src/orchestrator/run-state.ts +0 -145
  136. package/src/orchestrator/verify.ts +0 -164
  137. package/src/orchestrator/wire-render.ts +0 -92
  138. package/src/orchestrator/wire.ts +0 -212
@@ -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
+ }