@vzn/vx 0.0.21 → 0.0.23

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 (123) hide show
  1. package/README.md +21 -18
  2. package/package.json +6 -6
  3. package/src/bin.ts +52 -3
  4. package/src/cache/archive.ts +170 -21
  5. package/src/cache/cache.ts +667 -288
  6. package/src/cache/chained-cache.ts +14 -2
  7. package/src/cache/config-evals.ts +29 -6
  8. package/src/cache/file-hashes.ts +63 -35
  9. package/src/cache/git-inputs.ts +398 -69
  10. package/src/cache/index.ts +8 -1
  11. package/src/cache/inputs.ts +403 -102
  12. package/src/cache/key-fold.ts +232 -0
  13. package/src/cache/layer.ts +81 -11
  14. package/src/cache/layered-cache.ts +199 -32
  15. package/src/cache/output-index.ts +109 -10
  16. package/src/cache/run-history.ts +8 -3
  17. package/src/cache/zstd.ts +53 -30
  18. package/src/cli/cache.ts +51 -21
  19. package/src/cli/format.ts +4 -15
  20. package/src/cli/help.ts +13 -3
  21. package/src/cli/index.ts +35 -9
  22. package/src/cli/info.ts +49 -7
  23. package/src/cli/init.ts +17 -5
  24. package/src/cli/last.ts +96 -21
  25. package/src/cli/lock.ts +34 -17
  26. package/src/cli/plan-format.ts +4 -4
  27. package/src/cli/plugin-commands.ts +20 -5
  28. package/src/cli/run.ts +55 -12
  29. package/src/cli/select.ts +23 -13
  30. package/src/cli/show.ts +9 -4
  31. package/src/cli/upgrade.ts +134 -14
  32. package/src/cli/watch.ts +622 -102
  33. package/src/cli/why.ts +8 -16
  34. package/src/cli/workspace-config.ts +21 -9
  35. package/src/config.ts +46 -9
  36. package/src/exec/executor.ts +51 -5
  37. package/src/exec/index.ts +10 -0
  38. package/src/exec/kill-tree.ts +281 -0
  39. package/src/exec/local-executor.ts +1 -1
  40. package/src/exec/runner.ts +327 -82
  41. package/src/exec/sandbox-binds.ts +34 -5
  42. package/src/exec/sandbox-paths.ts +22 -2
  43. package/src/exec/sandbox-runtime.ts +627 -102
  44. package/src/exec/sandbox-violations.ts +36 -23
  45. package/src/graph/dependency-spec.ts +14 -14
  46. package/src/graph/index.ts +12 -2
  47. package/src/graph/priorities.ts +130 -0
  48. package/src/graph/scheduler.ts +182 -134
  49. package/src/graph/task-graph.ts +552 -188
  50. package/src/index.ts +16 -2
  51. package/src/orchestrator/admission.ts +41 -17
  52. package/src/orchestrator/colors.ts +8 -3
  53. package/src/orchestrator/deferred-outputs.ts +22 -7
  54. package/src/orchestrator/doctor.ts +122 -14
  55. package/src/orchestrator/events.ts +71 -2
  56. package/src/orchestrator/excluded-keys.ts +155 -0
  57. package/src/orchestrator/execute-task.ts +331 -37
  58. package/src/orchestrator/failure-recap.ts +121 -0
  59. package/src/orchestrator/fingerprint-watch.ts +89 -0
  60. package/src/orchestrator/framed-output.ts +81 -14
  61. package/src/orchestrator/history.ts +6 -7
  62. package/src/orchestrator/hit-restore.ts +82 -18
  63. package/src/orchestrator/index.ts +7 -1
  64. package/src/orchestrator/keyed-projects.ts +89 -0
  65. package/src/orchestrator/local-shortcircuit.ts +83 -23
  66. package/src/orchestrator/lockfile-claim.ts +152 -32
  67. package/src/orchestrator/logger.ts +116 -20
  68. package/src/orchestrator/metrics.ts +156 -60
  69. package/src/orchestrator/miss-save.ts +106 -43
  70. package/src/orchestrator/options.ts +30 -4
  71. package/src/orchestrator/persistent.ts +41 -21
  72. package/src/orchestrator/placement.ts +55 -25
  73. package/src/orchestrator/plan.ts +24 -11
  74. package/src/orchestrator/plugin-host.ts +17 -23
  75. package/src/orchestrator/plugin.ts +31 -8
  76. package/src/orchestrator/prepare.ts +99 -13
  77. package/src/orchestrator/projects.ts +19 -8
  78. package/src/orchestrator/remote-prefetch.ts +21 -1
  79. package/src/orchestrator/run-artifacts.ts +9 -0
  80. package/src/orchestrator/run-context.ts +19 -4
  81. package/src/orchestrator/run-lock.ts +349 -0
  82. package/src/orchestrator/run-records.ts +6 -23
  83. package/src/orchestrator/run-report.ts +4 -2
  84. package/src/orchestrator/run.ts +264 -57
  85. package/src/orchestrator/sandbox-request.ts +388 -62
  86. package/src/orchestrator/save-lane.ts +4 -7
  87. package/src/orchestrator/shell-verdict.ts +117 -0
  88. package/src/orchestrator/signals.ts +86 -30
  89. package/src/orchestrator/stable-keys.ts +202 -10
  90. package/src/orchestrator/status-line.ts +33 -1
  91. package/src/orchestrator/summary.ts +49 -2
  92. package/src/orchestrator/task-hash.ts +87 -7
  93. package/src/orchestrator/task-log-buffer.ts +7 -7
  94. package/src/orchestrator/telemetry-host.ts +16 -7
  95. package/src/orchestrator/telemetry.ts +69 -28
  96. package/src/orchestrator/upstream.ts +91 -45
  97. package/src/util/bun-version.ts +43 -0
  98. package/src/util/errors.ts +86 -0
  99. package/src/util/hash.ts +10 -1
  100. package/src/util/index.ts +32 -3
  101. package/src/util/paths.ts +147 -5
  102. package/src/util/procfs.ts +20 -0
  103. package/src/util/settle.ts +4 -2
  104. package/src/util/size.ts +33 -0
  105. package/src/util/task-id.ts +20 -0
  106. package/src/util/timing.ts +11 -1
  107. package/src/util/which.ts +48 -0
  108. package/src/workspace/affected.ts +272 -64
  109. package/src/workspace/config-cache.ts +262 -32
  110. package/src/workspace/config-eval.ts +49 -12
  111. package/src/workspace/config-imports.ts +126 -5
  112. package/src/workspace/config-schema.ts +237 -41
  113. package/src/workspace/filter.ts +33 -18
  114. package/src/workspace/fingerprint.ts +29 -11
  115. package/src/workspace/index.ts +7 -0
  116. package/src/workspace/json-data.ts +90 -0
  117. package/src/workspace/load-reads.ts +32 -0
  118. package/src/workspace/lockfile.ts +33 -11
  119. package/src/workspace/migrate-scripts.ts +60 -7
  120. package/src/workspace/migration.ts +40 -1
  121. package/src/workspace/package-graph.ts +154 -16
  122. package/src/workspace/project-loader.ts +227 -77
  123. package/src/workspace/workspace.ts +173 -23
@@ -43,6 +43,15 @@ export class ChainedCache implements CacheLayer {
43
43
  }
44
44
 
45
45
  async get(hash: string, ctx?: CacheGetContext): Promise<CacheEntry | null> {
46
+ // The layer that pulled this hash in a prefetch answers first. Layers
47
+ // may share one local store, and an earlier layer found the pulled copy
48
+ // there and called it a local hit: a second remote's hit was counted as
49
+ // saved locally, in the outcome, the summary and telemetry (item 889).
50
+ const owner = this.hitLayer.get(hash)
51
+ if (owner !== undefined) {
52
+ const entry = await owner.get(hash, ctx)
53
+ if (entry !== null) return entry
54
+ }
46
55
  for (const layer of this.layers) {
47
56
  const entry = await layer.get(hash, ctx)
48
57
  if (entry !== null) {
@@ -127,6 +136,9 @@ export class ChainedCache implements CacheLayer {
127
136
  recordOutputDirs(hash: string, projectDir: string, prefixes: readonly string[]): Promise<void> {
128
137
  return this.layers[0]!.recordOutputDirs?.(hash, projectDir, prefixes) ?? Promise.resolve()
129
138
  }
139
+ recordOutputStamps(hash: string, projectDir: string, workspaceRoot: string): void {
140
+ this.layers[0]!.recordOutputStamps?.(hash, projectDir, workspaceRoot)
141
+ }
130
142
 
131
143
  loadOutputDirsBatch(hashes: readonly string[]): Map<string, OutputDirRow[]> {
132
144
  return this.layers[0]!.loadOutputDirsBatch?.(hashes) ?? new Map()
@@ -155,8 +167,8 @@ export class ChainedCache implements CacheLayer {
155
167
  }
156
168
  }
157
169
 
158
- ingest(hash: string, compressed: Uint8Array, meta: IngestMeta): Promise<void> {
159
- return this.layers[0]!.ingest(hash, compressed, meta)
170
+ ingest(hash: string, body: Blob | Response, meta: IngestMeta): Promise<void> {
171
+ return this.layers[0]!.ingest(hash, body, meta)
160
172
  }
161
173
 
162
174
  recordRunBundle(bundle: { runs: readonly RunRecord[]; invocation: InvocationRecord }): void {
@@ -8,6 +8,7 @@ import type { Database, SQLQueryBindings } from 'bun:sqlite'
8
8
  export class ConfigEvalTable {
9
9
  private readonly selectConfigEval: ReturnType<Database['prepare']>
10
10
  private readonly insertConfigEval: ReturnType<Database['prepare']>
11
+ private readonly upsertConfigClosure: ReturnType<Database['prepare']>
11
12
  private readonly read: boolean
12
13
  private readonly write: boolean
13
14
 
@@ -21,6 +22,9 @@ export class ConfigEvalTable {
21
22
  this.insertConfigEval = this.db.prepare(
22
23
  'INSERT OR REPLACE INTO config_evals(key, json, created_at) VALUES (?, ?, ?)',
23
24
  )
25
+ this.upsertConfigClosure = this.db.prepare(
26
+ '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',
27
+ )
24
28
  }
25
29
 
26
30
  /** `ConfigEvalStore`: a cached config evaluation, honouring the local READ axis. */
@@ -52,11 +56,17 @@ export class ConfigEvalTable {
52
56
  /** `ConfigEvalStore`: remember a config's ordered closure, honouring the local WRITE axis. */
53
57
  putConfigClosure(configPath: string, files: readonly string[]): void {
54
58
  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())
59
+ this.upsertConfigClosure.run(configPath, JSON.stringify(files), Date.now())
60
+ }
61
+
62
+ /** `ConfigEvalStore`: a round's closures in ONE transaction (each upsert was its own, item 615). */
63
+ putConfigClosures(entries: ReadonlyArray<readonly [string, readonly string[]]>): void {
64
+ if (!this.write || entries.length === 0) return
65
+ const now = Date.now()
66
+ this.db.transaction(() => {
67
+ for (const [configPath, files] of entries)
68
+ this.upsertConfigClosure.run(configPath, JSON.stringify(files), now)
69
+ })()
60
70
  }
61
71
 
62
72
  /** `ConfigEvalStore`: the batched read — one `IN` query per 900 keys, honouring the local READ axis. */
@@ -81,7 +91,20 @@ export class ConfigEvalTable {
81
91
  this.insertConfigEval.run(key, json, Date.now())
82
92
  }
83
93
 
84
- /** Retention: a config not loaded since `cutoff` was edited or its project left. */
94
+ /** `ConfigEvalStore`: a round's evaluations in ONE transaction. */
95
+ putConfigEvals(entries: ReadonlyArray<readonly [string, string]>): void {
96
+ if (!this.write || entries.length === 0) return
97
+ const now = Date.now()
98
+ this.db.transaction(() => {
99
+ for (const [key, json] of entries) this.insertConfigEval.run(key, json, now)
100
+ })()
101
+ }
102
+
103
+ /**
104
+ * Retention: a row not WRITTEN since `cutoff`. A hit does not refresh it
105
+ * (that would be a write per config on every warm run), so a config that
106
+ * hit for thirty days is evaluated once more and stored again.
107
+ */
85
108
  pruneOlderThan(cutoff: number): void {
86
109
  this.db.prepare('DELETE FROM config_evals WHERE created_at < ?').run(cutoff)
87
110
  this.db.prepare('DELETE FROM config_closures WHERE created_at < ?').run(cutoff)
@@ -7,8 +7,9 @@
7
7
  // the store's handle; `Cache` delegates.
8
8
 
9
9
  import type { Database } from 'bun:sqlite'
10
- import { lstatSync, readlinkSync, statSync } from 'node:fs'
10
+ import { lstatSync, readlinkSync } from 'node:fs'
11
11
  import path from 'node:path'
12
+ import { fileIdentity, repoFacts } from './git-inputs.js'
12
13
  import { FILE_HASH_RACY_MS } from './layer.js'
13
14
 
14
15
  export class FileHashStore {
@@ -18,8 +19,17 @@ export class FileHashStore {
18
19
 
19
20
  constructor(
20
21
  private readonly db: Database,
21
- /** Where `git rev-parse --show-object-format` is asked, once, when a file misses the memo. */
22
+ /** Where the object format is asked when no `repoDir` is given and no file is named. */
22
23
  private readonly cacheDir: string,
24
+ /** The local WRITE axis: off, and a miss is hashed but not remembered (a read-only cache). */
25
+ private readonly write: boolean = true,
26
+ /**
27
+ * The directory whose repository answers the object format — the
28
+ * workspace root, in a run: the enumeration asks git there too, and
29
+ * `repoFacts` answers both from one spawn. Absent, the directory of the
30
+ * first file hashed answers.
31
+ */
32
+ private readonly repoDir?: string,
23
33
  ) {
24
34
  this.selectFileHash = this.db.prepare(
25
35
  'SELECT mtime_ms, size_bytes, ctime_ms, ino, content_hash FROM file_hashes WHERE path = ?',
@@ -50,7 +60,7 @@ export class FileHashStore {
50
60
  * mode-120000 blob of the link text — so this fallback and the
51
61
  * `CacheKeyInput.fileHashes` index-OID fast path agree on any file
52
62
  * git stores verbatim. They do NOT agree when a clean filter
53
- * (`text`/`eol`/`ident`) is active: the index blob is the filtered
63
+ * (`text`/`eol`/`ident`/`filter`/`working-tree-encoding`) is active: the index blob is the filtered
54
64
  * form while this hashes the worktree bytes, so `inputs.ts` drops
55
65
  * the index OID for those paths and routes them here.
56
66
  */
@@ -74,11 +84,19 @@ export class FileHashStore {
74
84
  // is its mode-120000 index OID. Not the bytes behind it — a link to a
75
85
  // directory has none, a dangling one has none, and a link to a file
76
86
  // 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
87
+ // cannot see. A link to a file inside the project tracks that file's
88
+ // content only when the file matches the task's globs too. No
79
89
  // memo: readlink is one syscall, and the row would be keyed on the
80
90
  // link's own stat, not its target's.
81
- if (st.isSymbolicLink()) return this.hashBlob(new TextEncoder().encode(readlinkSync(filePath)))
91
+ if (st.isSymbolicLink()) {
92
+ return fileIdentity(
93
+ '120000',
94
+ this.hashBytes(new TextEncoder().encode(readlinkSync(filePath))),
95
+ )
96
+ }
97
+ // git's mode for a regular file: 100755 when the owner may execute it.
98
+ // The memo below holds the content alone; the mode rides this stat.
99
+ const mode = (st.mode & 0o100) !== 0 ? '100755' : '100644'
82
100
  const mtimeMs = Math.floor(st.mtimeMs)
83
101
  const size = st.size
84
102
  // ctime + ino are what make this memo SAFE, not merely fast. mtime is
@@ -107,7 +125,7 @@ export class FileHashStore {
107
125
  row.ctime_ms === ctimeMs &&
108
126
  row.ino === ino
109
127
  ) {
110
- return row.content_hash
128
+ return fileIdentity(mode, row.content_hash)
111
129
  }
112
130
  const ch = await this.hashFileFromDisk(filePath)
113
131
  // git's racy-clean rule, applied to the memo: a stat taken in the same
@@ -118,10 +136,10 @@ export class FileHashStore {
118
136
  // changed within the window is hashed again on its next call rather
119
137
  // than memoised; the warm path never meets it (keys are derived long
120
138
  // after the files were written).
121
- if (Date.now() - ctimeMs >= FILE_HASH_RACY_MS) {
139
+ if (this.write && Date.now() - ctimeMs >= FILE_HASH_RACY_MS) {
122
140
  this.upsertFileHash.run(filePath, mtimeMs, size, ctimeMs, ino, ch, Date.now())
123
141
  }
124
- return ch
142
+ return fileIdentity(mode, ch)
125
143
  }
126
144
 
127
145
  /**
@@ -138,6 +156,8 @@ export class FileHashStore {
138
156
  const out = new Map<string, string>()
139
157
  if (paths.length === 0) return out
140
158
  interface Stat {
159
+ /** git's mode for it, as `hashFile` spells the identity. */
160
+ mode: string
141
161
  mtimeMs: number
142
162
  size: number
143
163
  ctimeMs: number
@@ -145,10 +165,26 @@ export class FileHashStore {
145
165
  }
146
166
  const stats = new Map<string, Stat>()
147
167
  for (const p of paths) {
148
- if (stats.has(p)) continue
168
+ if (stats.has(p) || out.has(p)) continue
149
169
  try {
150
- const st = statSync(p)
170
+ // lstat, not stat: `hashFile` folds a symlink as git folds it —
171
+ // the blob of its TARGET STRING — and this form promises the same
172
+ // digest. Following the link here instead hashed the target's
173
+ // BYTES, so the same path identified differently depending on
174
+ // which entry point the caller used, and the batch folded bytes
175
+ // `git diff` and `--affected` cannot see.
176
+ const st = lstatSync(p)
177
+ if (st.isSymbolicLink()) {
178
+ // No memo, for `hashFile`'s reason: the row would be keyed on
179
+ // the link's own stat, not its target's.
180
+ out.set(
181
+ p,
182
+ fileIdentity('120000', this.hashBytes(new TextEncoder().encode(readlinkSync(p)))),
183
+ )
184
+ continue
185
+ }
151
186
  stats.set(p, {
187
+ mode: (st.mode & 0o100) !== 0 ? '100755' : '100644',
152
188
  mtimeMs: Math.floor(st.mtimeMs),
153
189
  size: st.size,
154
190
  ctimeMs: Math.floor(st.ctimeMs),
@@ -187,7 +223,7 @@ export class FileHashStore {
187
223
  row.ctime_ms === st.ctimeMs &&
188
224
  row.ino === st.ino
189
225
  ) {
190
- out.set(p, row.content_hash)
226
+ out.set(p, fileIdentity(st.mode, row.content_hash))
191
227
  } else misses.push(p)
192
228
  }
193
229
  }
@@ -202,10 +238,10 @@ export class FileHashStore {
202
238
  if (digest === undefined) continue
203
239
  const p = misses[i]!
204
240
  const st = stats.get(p)!
205
- out.set(p, digest)
241
+ out.set(p, fileIdentity(st.mode, digest))
206
242
  // The same racy-clean rule as `hashFile`: a stat taken within the
207
243
  // window of the file's last change is not memoised.
208
- if (now - st.ctimeMs >= FILE_HASH_RACY_MS) {
244
+ if (this.write && now - st.ctimeMs >= FILE_HASH_RACY_MS) {
209
245
  this.upsertFileHash.run(p, st.mtimeMs, st.size, st.ctimeMs, st.ino, digest, now)
210
246
  }
211
247
  }
@@ -221,11 +257,16 @@ export class FileHashStore {
221
257
  * spawn per file.
222
258
  */
223
259
  private async hashFileFromDisk(filePath: string): Promise<string> {
224
- return this.hashBlob(await Bun.file(filePath).bytes(), filePath)
260
+ return this.hashBytes(await Bun.file(filePath).bytes(), filePath)
225
261
  }
226
262
 
227
- /** `git hash-object` of `bytes`: the blob OID in the repo's object format. */
228
- private hashBlob(bytes: Uint8Array, nearPath?: string): string {
263
+ /**
264
+ * `git hash-object` of `bytes`: the blob OID in the repo's object format —
265
+ * what `hashFile` returns for a file holding them, with no stat and no
266
+ * memo row. `nearPath` says which repo's format applies (the cache dir's
267
+ * when absent).
268
+ */
269
+ hashBytes(bytes: Uint8Array, nearPath?: string): string {
229
270
  const hasher = new Bun.CryptoHasher(
230
271
  this.objectFormat ?? this.detectObjectFormat(nearPath ?? this.cacheDir),
231
272
  )
@@ -236,26 +277,13 @@ export class FileHashStore {
236
277
 
237
278
  /**
238
279
  * 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.
280
+ * `--object-format=sha256`. Asked once per store, and only when at least
281
+ * one file misses the mtime+size memo; `repoFacts` shares the spawn with
282
+ * the input enumeration. Outside a repo (unit fixtures), or with no git,
283
+ * sha1, which is still a deterministic blob-OID domain.
243
284
  */
244
285
  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
- }
286
+ const detected = repoFacts(this.repoDir ?? path.dirname(nearPath))?.objectFormat ?? 'sha1'
259
287
  this.objectFormat = detected
260
288
  return detected
261
289
  }