@vzn/vx 0.0.18 → 0.0.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (149) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +107 -70
  3. package/package.json +10 -20
  4. package/src/bin.ts +64 -5
  5. package/src/cache/archive.ts +279 -28
  6. package/src/cache/cache.ts +952 -1584
  7. package/src/cache/chained-cache.ts +14 -10
  8. package/src/cache/config-evals.ts +112 -0
  9. package/src/cache/file-hashes.ts +290 -0
  10. package/src/cache/git-inputs.ts +966 -0
  11. package/src/cache/index.ts +23 -7
  12. package/src/cache/inputs.ts +553 -739
  13. package/src/cache/key-fold.ts +232 -0
  14. package/src/cache/layer.ts +674 -0
  15. package/src/cache/layered-cache.ts +211 -49
  16. package/src/cache/output-index.ts +323 -0
  17. package/src/cache/policy.ts +84 -0
  18. package/src/cache/run-history.ts +152 -0
  19. package/src/cache/zstd.ts +183 -0
  20. package/src/cli/cache.ts +88 -30
  21. package/src/cli/completions.ts +104 -0
  22. package/src/cli/core-alias.ts +25 -0
  23. package/src/cli/format.ts +4 -15
  24. package/src/cli/help.ts +76 -38
  25. package/src/cli/index.ts +82 -17
  26. package/src/cli/info.ts +162 -91
  27. package/src/cli/init.ts +87 -0
  28. package/src/cli/last.ts +130 -23
  29. package/src/cli/lock.ts +36 -18
  30. package/src/cli/plan-format.ts +18 -7
  31. package/src/cli/plugin-commands.ts +34 -12
  32. package/src/cli/run.ts +138 -313
  33. package/src/cli/select.ts +342 -0
  34. package/src/cli/show.ts +151 -71
  35. package/src/cli/upgrade.ts +136 -15
  36. package/src/cli/watch.ts +1175 -116
  37. package/src/cli/why.ts +51 -29
  38. package/src/cli/workspace-config.ts +138 -0
  39. package/src/config.ts +183 -126
  40. package/src/exec/env.ts +11 -0
  41. package/src/exec/executor.ts +62 -31
  42. package/src/exec/index.ts +16 -2
  43. package/src/exec/kill-tree.ts +281 -0
  44. package/src/exec/local-executor.ts +41 -0
  45. package/src/exec/runner.ts +361 -77
  46. package/src/exec/sandbox-binds.ts +211 -0
  47. package/src/exec/sandbox-paths.ts +70 -0
  48. package/src/exec/sandbox-runtime.ts +1152 -415
  49. package/src/exec/sandbox-violations.ts +293 -0
  50. package/src/graph/dependency-spec.ts +14 -14
  51. package/src/graph/index.ts +6 -4
  52. package/src/graph/priorities.ts +130 -0
  53. package/src/graph/scheduler.ts +332 -329
  54. package/src/graph/task-graph.ts +552 -188
  55. package/src/index.ts +111 -102
  56. package/src/orchestrator/admission.ts +161 -0
  57. package/src/orchestrator/colors.ts +8 -3
  58. package/src/orchestrator/deferred-outputs.ts +25 -7
  59. package/src/orchestrator/doctor.ts +363 -0
  60. package/src/orchestrator/events.ts +111 -8
  61. package/src/orchestrator/excluded-keys.ts +155 -0
  62. package/src/orchestrator/execute-task.ts +450 -565
  63. package/src/orchestrator/failure-mode.ts +169 -28
  64. package/src/orchestrator/failure-recap.ts +121 -0
  65. package/src/orchestrator/fingerprint-watch.ts +89 -0
  66. package/src/orchestrator/framed-output.ts +121 -49
  67. package/src/orchestrator/history.ts +123 -104
  68. package/src/orchestrator/hit-restore.ts +285 -0
  69. package/src/orchestrator/index.ts +42 -5
  70. package/src/orchestrator/keyed-projects.ts +89 -0
  71. package/src/orchestrator/local-shortcircuit.ts +83 -23
  72. package/src/orchestrator/lockfile-claim.ts +401 -0
  73. package/src/orchestrator/logger.ts +128 -41
  74. package/src/orchestrator/metrics.ts +201 -72
  75. package/src/orchestrator/miss-save.ts +232 -0
  76. package/src/orchestrator/options.ts +63 -34
  77. package/src/orchestrator/persistent.ts +99 -0
  78. package/src/orchestrator/placement.ts +212 -0
  79. package/src/orchestrator/plan.ts +24 -11
  80. package/src/orchestrator/plugin-host.ts +200 -49
  81. package/src/orchestrator/plugin.ts +190 -14
  82. package/src/orchestrator/prepare.ts +168 -137
  83. package/src/orchestrator/projects.ts +270 -0
  84. package/src/orchestrator/remote-prefetch.ts +22 -2
  85. package/src/orchestrator/run-artifacts.ts +34 -3
  86. package/src/orchestrator/run-context.ts +20 -5
  87. package/src/orchestrator/run-lock.ts +349 -0
  88. package/src/orchestrator/run-records.ts +132 -0
  89. package/src/orchestrator/run-report.ts +6 -4
  90. package/src/orchestrator/run.ts +467 -611
  91. package/src/orchestrator/sandbox-request.ts +569 -0
  92. package/src/orchestrator/save-lane.ts +44 -0
  93. package/src/orchestrator/shell-verdict.ts +117 -0
  94. package/src/orchestrator/signals.ts +167 -0
  95. package/src/orchestrator/stable-keys.ts +259 -10
  96. package/src/orchestrator/status-line.ts +35 -2
  97. package/src/orchestrator/summary.ts +115 -25
  98. package/src/orchestrator/task-hash.ts +103 -21
  99. package/src/orchestrator/task-log-buffer.ts +15 -18
  100. package/src/orchestrator/telemetry-host.ts +27 -8
  101. package/src/orchestrator/telemetry.ts +71 -44
  102. package/src/orchestrator/upstream.ts +91 -45
  103. package/src/util/bun-version.ts +43 -0
  104. package/src/util/cgroup.ts +160 -0
  105. package/src/util/edit-distance.ts +49 -0
  106. package/src/util/errors.ts +86 -0
  107. package/src/util/hash.ts +10 -1
  108. package/src/util/index.ts +42 -5
  109. package/src/util/paths.ts +168 -5
  110. package/src/util/procfs.ts +20 -0
  111. package/src/util/settle.ts +23 -2
  112. package/src/util/size.ts +33 -0
  113. package/src/util/task-id.ts +20 -0
  114. package/src/util/timing.ts +11 -1
  115. package/src/util/verbs.ts +39 -0
  116. package/src/util/which.ts +48 -0
  117. package/src/workspace/affected.ts +368 -60
  118. package/src/workspace/config-cache.ts +271 -32
  119. package/src/workspace/config-eval.ts +60 -16
  120. package/src/workspace/config-imports.ts +131 -9
  121. package/src/workspace/config-schema.ts +1033 -0
  122. package/src/workspace/filter.ts +69 -17
  123. package/src/workspace/fingerprint.ts +59 -7
  124. package/src/workspace/index.ts +37 -3
  125. package/src/workspace/json-data.ts +90 -0
  126. package/src/workspace/load-reads.ts +32 -0
  127. package/src/workspace/lockfile.ts +41 -14
  128. package/src/workspace/migrate-scripts.ts +187 -0
  129. package/src/workspace/migration.ts +353 -0
  130. package/src/workspace/package-graph.ts +239 -31
  131. package/src/workspace/project-loader.ts +295 -815
  132. package/src/workspace/workspace.ts +266 -31
  133. package/plugins/local-cache/index.ts +0 -2
  134. package/plugins/local-executor/index.ts +0 -2
  135. package/plugins/schedule-history/index.ts +0 -2
  136. package/src/cache/cas-backend.ts +0 -115
  137. package/src/cache/digest.ts +0 -32
  138. package/src/cli/migrate-emit.ts +0 -21
  139. package/src/cli/migrate-nx.ts +0 -473
  140. package/src/cli/migrate-scripts.ts +0 -127
  141. package/src/cli/migrate-turbo.ts +0 -401
  142. package/src/cli/migrate.ts +0 -305
  143. package/src/cli/prune.ts +0 -291
  144. package/src/orchestrator/missing-plugin.ts +0 -9
  145. package/src/orchestrator/resources.ts +0 -45
  146. package/src/orchestrator/verify.ts +0 -174
  147. package/src/plugins/local-cache/index.ts +0 -11
  148. package/src/plugins/local-executor/index.ts +0 -45
  149. package/src/plugins/schedule-history/index.ts +0 -109
@@ -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
@@ -42,10 +42,23 @@ import { FULL_CACHE_POLICY, type OutputDirRow } from './cache.js'
42
42
  * policy gating, in-flight dedup, remote provenance, and the never-fail
43
43
  * contract (implementations THROW on failure; LayeredCache degrades every
44
44
  * throw to a cache miss via `onRemoteError`). The artifact bytes are the
45
- * local `<hash>.tar.zst` verbatim. See
46
- * docs/design/native-cache-wire-2026-07.md.
45
+ * local `<hash>.tar.zst` verbatim. The wires live in plugin packages
46
+ * (`@vzn/vx-migrate`'s `turboCache()` and `nxCache()`, `@vzn/vx-reapi`); see
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).
47
53
  */
48
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
49
62
  /** Existence probe (drives the plan path's `--dry` remote prediction). */
50
63
  has(hash: string): Promise<boolean>
51
64
  /**
@@ -57,11 +70,47 @@ export interface RemoteCacheLayer {
57
70
  * info; use per-hash".
58
71
  */
59
72
  hasMany?(hashes: readonly string[]): Promise<Set<string> | null>
60
- /** Fetch an artifact's bytes; `null` = miss. `durationMs` is the
61
- * producing task's duration when the wire carries it. */
62
- get(hash: string): Promise<{ body: ArrayBuffer; durationMs: number | undefined } | null>
63
- /** Store an artifact (fire-and-forget from LayeredCache's perspective). */
64
- 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
65
114
  }
66
115
 
67
116
  /**
@@ -70,8 +119,51 @@ export interface RemoteCacheLayer {
70
119
  */
71
120
  const UPLOAD_CONCURRENCY = 4
72
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
+
73
160
  export interface LayeredCacheOptions {
74
- /** 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
+ */
75
167
  onRemoteError?: (err: Error) => void
76
168
  /**
77
169
  * The 4-axis read/write policy. The local slice (read/write) is
@@ -120,6 +212,15 @@ export class LayeredCache implements CacheLayer {
120
212
  private drainWaiters: Array<() => void> = []
121
213
 
122
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 }>()
123
224
 
124
225
  constructor(
125
226
  readonly local: Cache,
@@ -127,10 +228,11 @@ export class LayeredCache implements CacheLayer {
127
228
  private readonly options: LayeredCacheOptions = {},
128
229
  ) {
129
230
  this.policy = options.policy ?? FULL_CACHE_POLICY
231
+ this.endpoint = printableEndpoint(remote.endpoint)
130
232
  }
131
233
 
132
- async key(input: CacheKeyInput): Promise<string> {
133
- return await this.local.key(input)
234
+ key(input: CacheKeyInput): Promise<string> {
235
+ return this.local.key(input)
134
236
  }
135
237
 
136
238
  async prefetch(hash: string, ctx?: CacheGetContext): Promise<boolean> {
@@ -140,7 +242,7 @@ export class LayeredCache implements CacheLayer {
140
242
  // point): pullFromRemote registers the `inflight` entry SYNCHRONOUSLY,
141
243
  // so a concurrent markRemoteAbsent can't clobber a pending pull — a
142
244
  // guard done here (behind an async local.has) would reopen that race.
143
- return await this.pullFromRemote(hash, ctx)
245
+ return this.pullFromRemote(hash, ctx)
144
246
  }
145
247
 
146
248
  /**
@@ -154,9 +256,22 @@ export class LayeredCache implements CacheLayer {
154
256
  async remoteHasMany(hashes: readonly string[]): Promise<Set<string> | null> {
155
257
  if (!this.policy.remoteRead || this.remote.hasMany === undefined) return null
156
258
  try {
157
- return await this.remote.hasMany(hashes)
259
+ // `return await`, deliberately: the catch below is the never-fail
260
+ // contract, and a returned promise's rejection would sail past it.
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
158
273
  } catch (err) {
159
- this.reportRemoteError(err)
274
+ this.reportRemoteError('probe', batchOf(hashes), err)
160
275
  return null
161
276
  }
162
277
  }
@@ -224,7 +339,7 @@ export class LayeredCache implements CacheLayer {
224
339
  try {
225
340
  return (await this.remote.has(hash)) ? 'remote' : null
226
341
  } catch (err) {
227
- this.reportRemoteError(err)
342
+ this.reportRemoteError('probe', hash, err)
228
343
  return null
229
344
  }
230
345
  }
@@ -256,16 +371,31 @@ export class LayeredCache implements CacheLayer {
256
371
  // or remote if a concurrent prefetch set it).
257
372
  if ((await this.local.has(hash)) === 'local') return true
258
373
 
259
- let remoteResult
374
+ let remoteResult: unknown
260
375
  try {
261
376
  remoteResult = await this.remote.get(hash)
262
377
  } catch (err) {
263
- this.reportRemoteError(err)
378
+ this.reportRemoteError('download', hash, err)
264
379
  return false
265
380
  }
266
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 }
267
397
 
268
- // Ingest the remote bytes into local using the caller-supplied
398
+ // Ingest the remote body into local using the caller-supplied
269
399
  // taskId/command plus the remote-reported durationMs. The remote
270
400
  // layer carries durationMs as an HTTP header (x-artifact-duration);
271
401
  // taskId + command come from the orchestrator's TaskNode in scope.
@@ -275,16 +405,21 @@ export class LayeredCache implements CacheLayer {
275
405
  const meta: IngestMeta = {
276
406
  taskId: ctx?.taskId ?? `${hash}#unknown`,
277
407
  command: ctx?.command ?? '',
278
- 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 } : {}),
279
414
  }
280
415
  try {
281
- await this.local.ingest(hash, new Uint8Array(remoteResult.body), meta)
416
+ await this.local.ingest(hash, remoteBody.body, meta)
282
417
  } catch (err) {
283
418
  // The bytes came off the network — a corrupt/truncated remote
284
419
  // artifact must degrade to a cache miss (task re-executes), not
285
420
  // crash the run. Local-layer reads outside this block still
286
421
  // propagate: local corruption is a real fault, not a network one.
287
- this.reportRemoteError(err)
422
+ this.reportRemoteError('download', hash, err)
288
423
  return false
289
424
  }
290
425
  this.remoteSourced.add(hash)
@@ -295,8 +430,8 @@ export class LayeredCache implements CacheLayer {
295
430
  return this.local.outputsPath(hash)
296
431
  }
297
432
 
298
- async hashFile(filePath: string): Promise<string> {
299
- return await this.local.hashFile(filePath)
433
+ hashFile(filePath: string): Promise<string> {
434
+ return this.local.hashFile(filePath)
300
435
  }
301
436
 
302
437
  async restoreOutputs(hash: string, projectDir: string, workspaceRoot?: string): Promise<void> {
@@ -315,16 +450,13 @@ export class LayeredCache implements CacheLayer {
315
450
  // cache. Errors are logged, not propagated: the task already
316
451
  // succeeded; we don't fail it on cache-server issues.
317
452
  //
318
- // The artifact bytes are read INSIDE the job, not here.
319
- // UPLOAD_CONCURRENCY bounds sockets, not memory: a queued closure
320
- // holding its own artifact keeps the WHOLE backlog resident, so peak
321
- // RSS scaled with a run's total miss artifact bytes rather than with
322
- // the pool — any cold monorepo run whose remote uploads slower than the
323
- // build produces artifacts held every pending one at once. Reading in
324
- // 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
325
456
  // artifact is content-addressed and immutable, so a deferred read sees
326
457
  // the same bytes; if a concurrent `vx cache prune` removed it first the
327
- // 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.
328
460
  const hash = args.hash
329
461
  const durationMs = args.entry.durationMs
330
462
  // Local writes disabled (`--cache=local:,remote:rw`): there is no
@@ -338,16 +470,17 @@ export class LayeredCache implements CacheLayer {
338
470
  try {
339
471
  packed = await this.local.packArtifactBytes(args)
340
472
  } catch (err) {
341
- this.reportRemoteError(err)
473
+ this.reportRemoteError('upload', hash, err)
342
474
  return
343
475
  }
344
476
  }
345
477
  this.enqueueUpload(async () => {
346
478
  try {
347
- const bytes = packed ?? (await Bun.file(this.local.outputsPath(hash)).bytes())
348
- 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 })
349
482
  } catch (err) {
350
- this.reportRemoteError(err)
483
+ this.reportRemoteError('upload', hash, err)
351
484
  }
352
485
  })
353
486
  }
@@ -384,8 +517,8 @@ export class LayeredCache implements CacheLayer {
384
517
  }
385
518
  }
386
519
 
387
- async ingest(hash: string, compressed: Uint8Array, meta: IngestMeta): Promise<void> {
388
- 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)
389
522
  }
390
523
 
391
524
  loadOutputFilesBatch(hashes: readonly string[]): Map<string, OutputFileRow[]> {
@@ -401,6 +534,9 @@ export class LayeredCache implements CacheLayer {
401
534
  recordOutputDirs(hash: string, projectDir: string, prefixes: readonly string[]): Promise<void> {
402
535
  return this.local.recordOutputDirs(hash, projectDir, prefixes)
403
536
  }
537
+ recordOutputStamps(hash: string, projectDir: string, workspaceRoot: string): void {
538
+ this.local.recordOutputStamps(hash, projectDir, workspaceRoot)
539
+ }
404
540
 
405
541
  loadOutputDirsBatch(hashes: readonly string[]): Map<string, OutputDirRow[]> {
406
542
  return this.local.loadOutputDirsBatch(hashes)
@@ -410,14 +546,6 @@ export class LayeredCache implements CacheLayer {
410
546
  return this.local.outputDirsCurrent(projectDir, rows)
411
547
  }
412
548
 
413
- recordRun(run: RunRecord): void {
414
- this.local.recordRun(run)
415
- }
416
-
417
- recordRuns(runs: readonly RunRecord[]): void {
418
- this.local.recordRuns(runs)
419
- }
420
-
421
549
  recordRunBundle(bundle: { runs: readonly RunRecord[]; invocation: InvocationRecord }): void {
422
550
  this.local.recordRunBundle(bundle)
423
551
  }
@@ -426,16 +554,46 @@ export class LayeredCache implements CacheLayer {
426
554
  return this.local.stats(opts)
427
555
  }
428
556
 
429
- async prune(options: PruneOptions): Promise<PruneResult> {
430
- return await this.local.prune(options)
557
+ prune(options: PruneOptions): Promise<PruneResult> {
558
+ return this.local.prune(options)
431
559
  }
432
560
 
561
+ /** `run()` drains the uploads and prefetches first, so the repeat counts are final here. */
433
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()
434
572
  this.local.close()
435
573
  }
436
574
 
437
- 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 {
438
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 {
439
597
  // The remote cache is fully optional: NO remote failure — a 500, a
440
598
  // network drop, a corrupt artifact, even a throwing onRemoteError
441
599
  // callback — may ever fail the run. We report and degrade to a
@@ -452,3 +610,7 @@ export class LayeredCache implements CacheLayer {
452
610
  }
453
611
  }
454
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
+ }