@vzn/vx 0.0.21 → 0.0.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (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 +611 -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
@@ -1,7 +1,7 @@
1
1
  // LayeredCache — composes the local cache with a remote HTTP cache.
2
2
  //
3
- // Read path: try local. On miss, try remote; on remote hit, ingest
4
- // the artifact bytes into local so the next read is a local hit.
3
+ // Read path: try local. On miss, try remote; on remote hit, stream
4
+ // the artifact body into local so the next read is a local hit.
5
5
  //
6
6
  // Write path: write to local synchronously. Upload the local artifact
7
7
  // to remote as a fire-and-forget background task; failures log a
@@ -45,8 +45,20 @@ import { FULL_CACHE_POLICY, type OutputDirRow } from './cache.js'
45
45
  * local `<hash>.tar.zst` verbatim. The wires live in plugin packages
46
46
  * (`@vzn/vx-migrate`'s `turboCache()` and `nxCache()`, `@vzn/vx-reapi`); see
47
47
  * docs/modules/layered-cache.md.
48
+ *
49
+ * Core awaits every call and bounds none: a `get` that never settles
50
+ * holds its task and a `put` the run's upload drain, so each request
51
+ * carries the layer's own deadline (every first-party layer has one; the
52
+ * plugin guide's example shows it).
48
53
  */
49
54
  export interface RemoteCacheLayer {
55
+ /**
56
+ * Where the layer's artifacts live, named in every degrade warning
57
+ * (`upload <hash> to <endpoint> failed: …`): a base URL or `host:port`.
58
+ * Never a credential: a URL's `user:pass@`, query and fragment are
59
+ * dropped before it is printed.
60
+ */
61
+ readonly endpoint?: string
50
62
  /** Existence probe (drives the plan path's `--dry` remote prediction). */
51
63
  has(hash: string): Promise<boolean>
52
64
  /**
@@ -58,11 +70,47 @@ export interface RemoteCacheLayer {
58
70
  * info; use per-hash".
59
71
  */
60
72
  hasMany?(hashes: readonly string[]): Promise<Set<string> | null>
61
- /** Fetch an artifact's bytes; `null` = miss. `durationMs` is the
62
- * producing task's duration when the wire carries it. */
63
- get(hash: string): Promise<{ body: ArrayBuffer; durationMs: number | undefined } | null>
64
- /** Store an artifact (fire-and-forget from LayeredCache's perspective). */
65
- put(hash: string, body: ArrayBuffer | Uint8Array, meta: { durationMs: number }): Promise<void>
73
+ /**
74
+ * Fetch an artifact; `null` = miss. `body` is read once, by core,
75
+ * straight into the local cache — an HTTP wire returns its `fetch`
76
+ * `Response`, a chunked wire `new Response(stream)`, bytes in hand
77
+ * `new Blob([bytes])`. `durationMs` is the producing task's duration
78
+ * when the wire carries it.
79
+ */
80
+ get(hash: string): Promise<{ body: Blob | Response; durationMs: number | undefined } | null>
81
+ /**
82
+ * Store an artifact (fire-and-forget from LayeredCache's perspective).
83
+ * `body` is file-backed (`Bun.file`) when the local store holds the
84
+ * artifact, so a plugin that streams it never holds it whole.
85
+ */
86
+ put(hash: string, body: Blob, meta: { durationMs: number }): Promise<void>
87
+ }
88
+
89
+ /**
90
+ * What a remote layer resolves is a plugin's, so its shape is a boundary
91
+ * (item 252): a `get` that resolved `{ body: 'abc' }` was reported as
92
+ * "corrupt artifact … not a readable archive" — the bytes blamed for the
93
+ * plugin's shape — and a `hasMany` that resolved an array reached the
94
+ * prefetch pass's `.has()`. Named here and degraded to a miss, like every
95
+ * other remote failure.
96
+ */
97
+ function invalidRemoteResult(what: string): Error {
98
+ return new Error(
99
+ `remote cache layer returned an invalid result: ${what} — a plugin bug, degraded to a miss`,
100
+ )
101
+ }
102
+
103
+ function isBody(value: unknown): value is Blob | Response {
104
+ return value instanceof Blob || value instanceof Response
105
+ }
106
+
107
+ function describeValue(value: unknown): string {
108
+ if (value === null) return 'null'
109
+ if (Array.isArray(value)) return 'an array'
110
+ // The pre-stream contract's shape, named so an unported layer reads its fix.
111
+ if (value instanceof ArrayBuffer || ArrayBuffer.isView(value))
112
+ return `a ${value.constructor.name}`
113
+ return typeof value === 'object' ? 'an object' : typeof value
66
114
  }
67
115
 
68
116
  /**
@@ -71,8 +119,51 @@ export interface RemoteCacheLayer {
71
119
  */
72
120
  const UPLOAD_CONCURRENCY = 4
73
121
 
122
+ /** The seam call a failure happened in, as the warning names it. */
123
+ type RemoteOp = 'probe' | 'download' | 'upload'
124
+
125
+ const PREPOSITION: Record<RemoteOp, string> = { probe: 'at', download: 'from', upload: 'to' }
126
+
127
+ /** The layer's `endpoint` as it may be printed: no userinfo, query or fragment. */
128
+ function printableEndpoint(endpoint: unknown): string | undefined {
129
+ if (typeof endpoint !== 'string' || endpoint === '') return undefined
130
+ if (!URL.canParse(endpoint)) return endpoint
131
+ const u = new URL(endpoint)
132
+ if (u.username === '' && u.password === '' && u.search === '' && u.hash === '') return endpoint
133
+ u.username = ''
134
+ u.password = ''
135
+ u.search = ''
136
+ u.hash = ''
137
+ return u.toString()
138
+ }
139
+
140
+ /**
141
+ * What makes two failures "the same way": the error's `code` when it
142
+ * carries one (a gRPC status, an errno, Bun's `ConnectionRefused`), because
143
+ * gRPC writes the elapsed time into its message and no two deadline messages
144
+ * match; else the message with the artifact's hash taken out, so a 500 on
145
+ * one hash and a 500 on the next are one class. The operation is not part of
146
+ * it: an unreachable server fails the probe, the download and the upload
147
+ * alike, and that is one fact.
148
+ */
149
+ function failureClass(err: Error, hash: string | undefined): string {
150
+ const code = (err as { code?: unknown }).code
151
+ if (typeof code === 'string' || typeof code === 'number') return `${err.name}\0${code}`
152
+ return hash === undefined ? err.message : err.message.replaceAll(hash, '\0')
153
+ }
154
+
155
+ /** A batch probe names no one hash, so its line counts them. */
156
+ function batchOf(hashes: readonly string[]): { batch: string } {
157
+ return { batch: `of ${hashes.length} artifact${hashes.length === 1 ? '' : 's'}` }
158
+ }
159
+
74
160
  export interface LayeredCacheOptions {
75
- /** Called for remote-related errors that the layer suppresses. */
161
+ /**
162
+ * Called for the remote failures the layer degrades to a miss, once per
163
+ * failure class per layer (`upload <hash> to <endpoint> failed: <cause>`,
164
+ * with the original error as `cause`); the repeats are counted and said
165
+ * once more at `close()` (`<n> more requests failed the same way: <cause>`).
166
+ */
76
167
  onRemoteError?: (err: Error) => void
77
168
  /**
78
169
  * The 4-axis read/write policy. The local slice (read/write) is
@@ -121,6 +212,15 @@ export class LayeredCache implements CacheLayer {
121
212
  private drainWaiters: Array<() => void> = []
122
213
 
123
214
  private readonly policy: CachePolicy
215
+ private readonly endpoint: string | undefined
216
+
217
+ /**
218
+ * One entry per failure class said: its first line went out, `repeats`
219
+ * counts the ones held back. A run's requests are concurrent, so an
220
+ * unreachable server otherwise printed its bare runtime message once per
221
+ * request (three lines for one task over the Turbo wire, item 749).
222
+ */
223
+ private readonly reported = new Map<string, { cause: string; repeats: number }>()
124
224
 
125
225
  constructor(
126
226
  readonly local: Cache,
@@ -128,6 +228,7 @@ export class LayeredCache implements CacheLayer {
128
228
  private readonly options: LayeredCacheOptions = {},
129
229
  ) {
130
230
  this.policy = options.policy ?? FULL_CACHE_POLICY
231
+ this.endpoint = printableEndpoint(remote.endpoint)
131
232
  }
132
233
 
133
234
  key(input: CacheKeyInput): Promise<string> {
@@ -157,9 +258,20 @@ export class LayeredCache implements CacheLayer {
157
258
  try {
158
259
  // `return await`, deliberately: the catch below is the never-fail
159
260
  // contract, and a returned promise's rejection would sail past it.
160
- return await this.remote.hasMany(hashes)
261
+ const found: unknown = await this.remote.hasMany(hashes)
262
+ if (found !== null && found !== undefined && !(found instanceof Set)) {
263
+ this.reportRemoteError(
264
+ 'probe',
265
+ batchOf(hashes),
266
+ invalidRemoteResult(
267
+ `hasMany() resolved ${describeValue(found)} (expected a Set of the hashes present, or null)`,
268
+ ),
269
+ )
270
+ return null
271
+ }
272
+ return found ?? null
161
273
  } catch (err) {
162
- this.reportRemoteError(err)
274
+ this.reportRemoteError('probe', batchOf(hashes), err)
163
275
  return null
164
276
  }
165
277
  }
@@ -227,7 +339,7 @@ export class LayeredCache implements CacheLayer {
227
339
  try {
228
340
  return (await this.remote.has(hash)) ? 'remote' : null
229
341
  } catch (err) {
230
- this.reportRemoteError(err)
342
+ this.reportRemoteError('probe', hash, err)
231
343
  return null
232
344
  }
233
345
  }
@@ -259,16 +371,31 @@ export class LayeredCache implements CacheLayer {
259
371
  // or remote if a concurrent prefetch set it).
260
372
  if ((await this.local.has(hash)) === 'local') return true
261
373
 
262
- let remoteResult
374
+ let remoteResult: unknown
263
375
  try {
264
376
  remoteResult = await this.remote.get(hash)
265
377
  } catch (err) {
266
- this.reportRemoteError(err)
378
+ this.reportRemoteError('download', hash, err)
267
379
  return false
268
380
  }
269
381
  if (!remoteResult) return false
382
+ if (typeof remoteResult !== 'object' || !isBody((remoteResult as { body?: unknown }).body)) {
383
+ const shape =
384
+ typeof remoteResult !== 'object'
385
+ ? describeValue(remoteResult)
386
+ : `body is ${describeValue((remoteResult as { body?: unknown }).body)}`
387
+ this.reportRemoteError(
388
+ 'download',
389
+ hash,
390
+ invalidRemoteResult(
391
+ `get() resolved ${shape} (expected { body: Blob | Response, durationMs } or null)`,
392
+ ),
393
+ )
394
+ return false
395
+ }
396
+ const remoteBody = remoteResult as { body: Blob | Response; durationMs?: number }
270
397
 
271
- // Ingest the remote bytes into local using the caller-supplied
398
+ // Ingest the remote body into local using the caller-supplied
272
399
  // taskId/command plus the remote-reported durationMs. The remote
273
400
  // layer carries durationMs as an HTTP header (x-artifact-duration);
274
401
  // taskId + command come from the orchestrator's TaskNode in scope.
@@ -278,16 +405,21 @@ export class LayeredCache implements CacheLayer {
278
405
  const meta: IngestMeta = {
279
406
  taskId: ctx?.taskId ?? `${hash}#unknown`,
280
407
  command: ctx?.command ?? '',
281
- durationMs: remoteResult.durationMs ?? 0,
408
+ // A layer's number is taken only as a finite non-negative one: a NaN
409
+ // failed the entry row's NOT NULL after the artifact was renamed into
410
+ // place, which threw a valid hit away and orphaned its bytes, and a
411
+ // negative or infinite one was stored and replayed (item 944).
412
+ durationMs: durationOf(remoteBody.durationMs),
413
+ ...(ctx?.outputs !== undefined ? { outputs: ctx.outputs } : {}),
282
414
  }
283
415
  try {
284
- await this.local.ingest(hash, new Uint8Array(remoteResult.body), meta)
416
+ await this.local.ingest(hash, remoteBody.body, meta)
285
417
  } catch (err) {
286
418
  // The bytes came off the network — a corrupt/truncated remote
287
419
  // artifact must degrade to a cache miss (task re-executes), not
288
420
  // crash the run. Local-layer reads outside this block still
289
421
  // propagate: local corruption is a real fault, not a network one.
290
- this.reportRemoteError(err)
422
+ this.reportRemoteError('download', hash, err)
291
423
  return false
292
424
  }
293
425
  this.remoteSourced.add(hash)
@@ -318,16 +450,13 @@ export class LayeredCache implements CacheLayer {
318
450
  // cache. Errors are logged, not propagated: the task already
319
451
  // succeeded; we don't fail it on cache-server issues.
320
452
  //
321
- // The artifact bytes are read INSIDE the job, not here.
322
- // UPLOAD_CONCURRENCY bounds sockets, not memory: a queued closure
323
- // holding its own artifact keeps the WHOLE backlog resident, so peak
324
- // RSS scaled with a run's total miss artifact bytes rather than with
325
- // the pool — any cold monorepo run whose remote uploads slower than the
326
- // build produces artifacts held every pending one at once. Reading in
327
- // the job caps resident artifact bytes at UPLOAD_CONCURRENCY. The
453
+ // The job hands the plugin a file-backed Blob over the local artifact,
454
+ // opened when the plugin reads it: a queued job holds a path, not a
455
+ // buffer, and a plugin that streams the Blob never holds it whole. The
328
456
  // artifact is content-addressed and immutable, so a deferred read sees
329
457
  // the same bytes; if a concurrent `vx cache prune` removed it first the
330
- // read throws and this upload is skipped — the never-fail contract.
458
+ // plugin's read throws and this upload is skipped — the never-fail
459
+ // contract.
331
460
  const hash = args.hash
332
461
  const durationMs = args.entry.durationMs
333
462
  // Local writes disabled (`--cache=local:,remote:rw`): there is no
@@ -341,16 +470,17 @@ export class LayeredCache implements CacheLayer {
341
470
  try {
342
471
  packed = await this.local.packArtifactBytes(args)
343
472
  } catch (err) {
344
- this.reportRemoteError(err)
473
+ this.reportRemoteError('upload', hash, err)
345
474
  return
346
475
  }
347
476
  }
348
477
  this.enqueueUpload(async () => {
349
478
  try {
350
- const bytes = packed ?? (await Bun.file(this.local.outputsPath(hash)).bytes())
351
- await this.remote.put(hash, bytes, { durationMs })
479
+ const body =
480
+ packed !== undefined ? new Blob([packed]) : Bun.file(this.local.outputsPath(hash))
481
+ await this.remote.put(hash, body, { durationMs })
352
482
  } catch (err) {
353
- this.reportRemoteError(err)
483
+ this.reportRemoteError('upload', hash, err)
354
484
  }
355
485
  })
356
486
  }
@@ -387,8 +517,8 @@ export class LayeredCache implements CacheLayer {
387
517
  }
388
518
  }
389
519
 
390
- async ingest(hash: string, compressed: Uint8Array, meta: IngestMeta): Promise<void> {
391
- await this.local.ingest(hash, compressed, meta)
520
+ async ingest(hash: string, body: Blob | Response, meta: IngestMeta): Promise<void> {
521
+ await this.local.ingest(hash, body, meta)
392
522
  }
393
523
 
394
524
  loadOutputFilesBatch(hashes: readonly string[]): Map<string, OutputFileRow[]> {
@@ -404,6 +534,9 @@ export class LayeredCache implements CacheLayer {
404
534
  recordOutputDirs(hash: string, projectDir: string, prefixes: readonly string[]): Promise<void> {
405
535
  return this.local.recordOutputDirs(hash, projectDir, prefixes)
406
536
  }
537
+ recordOutputStamps(hash: string, projectDir: string, workspaceRoot: string): void {
538
+ this.local.recordOutputStamps(hash, projectDir, workspaceRoot)
539
+ }
407
540
 
408
541
  loadOutputDirsBatch(hashes: readonly string[]): Map<string, OutputDirRow[]> {
409
542
  return this.local.loadOutputDirsBatch(hashes)
@@ -425,12 +558,42 @@ export class LayeredCache implements CacheLayer {
425
558
  return this.local.prune(options)
426
559
  }
427
560
 
561
+ /** `run()` drains the uploads and prefetches first, so the repeat counts are final here. */
428
562
  close(): void {
563
+ for (const { cause, repeats } of this.reported.values()) {
564
+ if (repeats === 0) continue
565
+ this.emit(
566
+ new Error(
567
+ `${repeats} more request${repeats === 1 ? '' : 's'} failed the same way: ${cause}`,
568
+ ),
569
+ )
570
+ }
571
+ this.reported.clear()
429
572
  this.local.close()
430
573
  }
431
574
 
432
- private reportRemoteError(err: unknown): void {
575
+ /**
576
+ * `subject` is the artifact's hash, or the batch for a `hasMany`. The
577
+ * wire's own message says what went wrong but not on what or where: an
578
+ * upload deadline read `The operation timed out.` with no upload, hash or
579
+ * server in it (item 749).
580
+ */
581
+ private reportRemoteError(op: RemoteOp, subject: string | { batch: string }, err: unknown): void {
433
582
  const e = err instanceof Error ? err : new Error(String(err))
583
+ const hash = typeof subject === 'string' ? subject : undefined
584
+ const cls = failureClass(e, hash)
585
+ const seen = this.reported.get(cls)
586
+ if (seen !== undefined) {
587
+ seen.repeats++
588
+ return
589
+ }
590
+ this.reported.set(cls, { cause: e.message, repeats: 0 })
591
+ const what = typeof subject === 'string' ? subject : subject.batch
592
+ const where = this.endpoint === undefined ? '' : ` ${PREPOSITION[op]} ${this.endpoint}`
593
+ this.emit(new Error(`${op} ${what}${where} failed: ${e.message}`, { cause: e }))
594
+ }
595
+
596
+ private emit(e: Error): void {
434
597
  // The remote cache is fully optional: NO remote failure — a 500, a
435
598
  // network drop, a corrupt artifact, even a throwing onRemoteError
436
599
  // callback — may ever fail the run. We report and degrade to a
@@ -447,3 +610,7 @@ export class LayeredCache implements CacheLayer {
447
610
  }
448
611
  }
449
612
  }
613
+
614
+ function durationOf(ms: unknown): number {
615
+ return typeof ms === 'number' && ms >= 0 && ms <= Number.MAX_SAFE_INTEGER ? Math.round(ms) : 0
616
+ }
@@ -17,6 +17,7 @@ import {
17
17
  OUTPUT_DIRS_RACY_MS,
18
18
  type OutputDirRow,
19
19
  type OutputFileRow,
20
+ WORKSPACE_OUTPUT_PREFIX,
20
21
  } from './layer.js'
21
22
 
22
23
  export class OutputIndex {
@@ -24,6 +25,19 @@ export class OutputIndex {
24
25
  private readonly deleteOutputFiles: ReturnType<Database['prepare']>
25
26
  private readonly insertOutputDir: ReturnType<Database['prepare']>
26
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]>>()
27
41
 
28
42
  constructor(private readonly db: Database) {
29
43
  this.insertOutputFile = this.db.prepare(`
@@ -39,6 +53,10 @@ export class OutputIndex {
39
53
  'INSERT INTO output_dirs(entry_hash, path, mtime_ms) VALUES (?, ?, ?)',
40
54
  )
41
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
+ )
42
60
  }
43
61
 
44
62
  /**
@@ -62,9 +80,12 @@ export class OutputIndex {
62
80
  // compiled statement keyed by the SQL text — so the dominant single-hash
63
81
  // warm-hit path (called up to 3× per hit) reuses one statement instead of
64
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()
65
86
  const placeholders = hashes.map(() => '?').join(',')
66
87
  const stmt = this.db.query(
67
- `SELECT entry_hash, path, size_bytes, mode, mtime_ms FROM output_files WHERE entry_hash IN (${placeholders})`,
88
+ `SELECT entry_hash, path, size_bytes, mode, mtime_ms, ino, ctime_ms FROM output_files WHERE entry_hash IN (${placeholders})`,
68
89
  )
69
90
  const rows = stmt.all(...(hashes as readonly SQLQueryBindings[])) as Array<{
70
91
  entry_hash: string
@@ -72,6 +93,8 @@ export class OutputIndex {
72
93
  size_bytes: number
73
94
  mode: number
74
95
  mtime_ms: number
96
+ ino: number | null
97
+ ctime_ms: number | null
75
98
  }>
76
99
  for (const r of rows) {
77
100
  let list = out.get(r.entry_hash)
@@ -79,7 +102,17 @@ export class OutputIndex {
79
102
  list = []
80
103
  out.set(r.entry_hash, list)
81
104
  }
82
- list.push({ path: r.path, size: r.size_bytes, mode: r.mode, mtimeMs: r.mtime_ms })
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)
83
116
  }
84
117
  return out
85
118
  }
@@ -107,11 +140,21 @@ export class OutputIndex {
107
140
  // restoreOutputs re-syncs restored files to the row value,
108
141
  // so equality holds exactly in steady state; legacy
109
142
  // second-precision rows converge on their first restore.
110
- // Residual blind spot: a same-size edit landing in the SAME
111
- // millisecond as the recorded write, or a deliberately
112
- // forged mtime (touch -r) — the trade every mtime-based
113
- // skip check accepts.
114
- Math.abs(s.mtimeMs - e.mtimeMs) < 1
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
115
158
  )
116
159
  } catch {
117
160
  return false
@@ -177,16 +220,72 @@ export class OutputIndex {
177
220
  // parent trusted while an addition inside it bumps only the dropped one.
178
221
  const youngest = Date.now() - OUTPUT_DIRS_RACY_MS
179
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()
180
271
  this.db.transaction(() => {
181
- this.deleteOutputDirs.run(hash)
182
- if (!ok) return
183
- for (const [rel, mtime] of rows) this.insertOutputDir.run(hash, rel, mtime)
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
+ }
184
280
  })()
185
281
  }
186
282
 
187
283
  loadOutputDirsBatch(hashes: readonly string[]): Map<string, OutputDirRow[]> {
188
284
  const out = new Map<string, OutputDirRow[]>()
189
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()
190
289
  const placeholders = hashes.map(() => '?').join(',')
191
290
  const rows = this.db
192
291
  .query(
@@ -16,9 +16,10 @@ export class RunHistory {
16
16
  hash, project, task, status, exit_code, duration_ms, forward_args,
17
17
  started_at, ended_at,
18
18
  run_id, cpu_ms, peak_rss_bytes, wallclock_start_ns, wallclock_end_ns,
19
- cache_hit, attempts, cached
19
+ cache_hit, attempts, cached,
20
+ blocked_by, timed_out, sandbox_violations, not_ready
20
21
  )
21
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
22
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
22
23
  `)
23
24
  this.insertInvocation = this.db.prepare(`
24
25
  INSERT INTO invocations(
@@ -84,7 +85,7 @@ export class RunHistory {
84
85
 
85
86
  /**
86
87
  * Bind a RunRecord to the positional parameters expected by the
87
- * `insertRun` prepared statement (17 columns). Shared between the
88
+ * `insertRun` prepared statement (21 columns). Shared between the
88
89
  * single and batched record paths.
89
90
  */
90
91
  function bindRun(run: RunRecord): SQLQueryBindings[] {
@@ -108,6 +109,10 @@ function bindRun(run: RunRecord): SQLQueryBindings[] {
108
109
  run.cacheHit === undefined ? null : run.cacheHit ? 1 : 0,
109
110
  run.attempts ?? null,
110
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,
111
116
  ]
112
117
  }
113
118