@vzn/vx-reapi 0.0.0 → 0.0.485

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/wire.ts ADDED
@@ -0,0 +1,1658 @@
1
+ // The full gRPC surface of the Bazel Remote Execution API: Execution,
2
+ // ActionCache, ContentAddressableStorage, Capabilities and ByteStream.
3
+ // `@grpc/proto-loader` parses the vendored protos (28 ms) and `@grpc/grpc-js`
4
+ // carries the calls.
5
+
6
+ import * as grpc from '@grpc/grpc-js'
7
+ import * as protoLoader from '@grpc/proto-loader'
8
+ import path from 'node:path'
9
+ import { canDigest, concat, digestWith, hasherFor, type DigestFunctionName } from './merkle.js'
10
+
11
+ /** Bare varint bytes, for the hand-encoded RequestMetadata header. */
12
+ function varintBytes(n: number): Uint8Array {
13
+ const out: number[] = []
14
+ let v = n
15
+ while (v >= 0x80) {
16
+ out.push((v & 0x7f) | 0x80)
17
+ v >>>= 7
18
+ }
19
+ out.push(v)
20
+ return new Uint8Array(out)
21
+ }
22
+
23
+ /**
24
+ * Default bytes per ByteStream message.
25
+ *
26
+ * NOT a throughput knob. Bun's `node:http2` client HANGS — it does not error —
27
+ * when a request carries more than one message and any single message exceeds
28
+ * a threshold that the PEER's flow-control behaviour decides. Go's gRPC server
29
+ * grows its window dynamically (a `WINDOW_UPDATE` then a `SETTINGS` raise) and
30
+ * Bun mishandles the tail of that sequence; a `node:http2` server, which does
31
+ * not do it, accepts 4 MB writes happily. See Bun #30342 / #26915, largely
32
+ * fixed by #31584 — which is why the ceiling ROSE from ~64 KB on 1.3.x to
33
+ * ~216 KB on 1.4.0 rather than the hang disappearing.
34
+ *
35
+ * The default is therefore `SAFE_CHUNK_BYTES` (65535, the RFC 7540 default
36
+ * initial window every peer must honour with no WINDOW_UPDATE at all), the
37
+ * one size with no peer-dependence. 128 KB was the default and stalled a
38
+ * 1 MiB write against bazel-remote in 2 of 12 fresh runs on Bun 1.4.2, each
39
+ * costing the call's 30 s deadline before the downgrade below retried it;
40
+ * 65535 stalled in none (F-20). It costs ~51% on a 32 MiB upload
41
+ * (390 → 590 ms on loopback), far below one expected stall. A larger
42
+ * `chunkBytes` stays available, with the downgrade as its net.
43
+ *
44
+ * Full probe matrix: `docs/design/plugin-executor-reapi-2026-08.md` §14.
45
+ */
46
+ export const CHUNK_BYTES = 65535
47
+
48
+ /**
49
+ * The largest message needing no `WINDOW_UPDATE` from any conformant peer, so
50
+ * the one size with no peer-dependence: the default, and what a larger
51
+ * `chunkBytes` downgrades to when a write stalls.
52
+ */
53
+ export const SAFE_CHUNK_BYTES = 65535
54
+
55
+ /** The oldest Bun whose http2 client survives a CHUNK_BYTES-sized message. */
56
+ export const MIN_BUN = [1, 4, 0] as const
57
+
58
+ /**
59
+ * Refuse to run on a Bun that would hang instead of uploading. A version this
60
+ * plugin cannot use is a startup error naming the fix, never a wedged run —
61
+ * the failure mode being guarded is a HANG, which gives a user nothing to go
62
+ * on.
63
+ */
64
+ export function assertBunSupportsChunking(version: string = Bun.version): void {
65
+ const parts = version.split('.').map((p) => Number.parseInt(p, 10))
66
+ const [maj = 0, min = 0, patch = 0] = parts
67
+ const older =
68
+ maj < MIN_BUN[0] ||
69
+ (maj === MIN_BUN[0] && min < MIN_BUN[1]) ||
70
+ (maj === MIN_BUN[0] && min === MIN_BUN[1] && patch < MIN_BUN[2])
71
+ if (older) {
72
+ throw new Error(
73
+ `@vzn/vx-reapi needs Bun >= ${MIN_BUN.join('.')} (running ${version}). ` +
74
+ `Older Bun hangs on the chunked uploads this plugin makes — see ` +
75
+ `docs/design/plugin-executor-reapi-2026-08.md §14. Upgrade with \`bun upgrade\`.`,
76
+ )
77
+ }
78
+ }
79
+
80
+ export interface Digest {
81
+ hash: string
82
+ size_bytes: number
83
+ }
84
+
85
+ export interface ServerCapabilities {
86
+ digestFunctions: string[]
87
+ maxBatchBytes: number
88
+ acUpdateEnabled: boolean
89
+ /** False for a cache-only deployment (bazel-remote). Gates the executor. */
90
+ execEnabled: boolean
91
+ /** `Compressor.Value` names the server accepts on ByteStream. */
92
+ supportedCompressors: string[]
93
+ /** Compressors accepted specifically on `BatchUpdateBlobs`. */
94
+ supportedBatchCompressors: string[]
95
+ /** How the server treats absolute symlink targets. */
96
+ symlinkAbsolutePathStrategy: string
97
+ /** Experimental `SplitBlob`/`SpliceBlob` support. */
98
+ splitBlobSupport: boolean
99
+ spliceBlobSupport: boolean
100
+ }
101
+
102
+ /** REAPI `Directory` node types — the Merkle input root. */
103
+ export interface FileNode {
104
+ name: string
105
+ digest: Digest
106
+ is_executable: boolean
107
+ node_properties?: { unixMode?: number; mtimeMs?: number }
108
+ }
109
+ export interface DirectoryNode {
110
+ name: string
111
+ digest: Digest
112
+ }
113
+ export interface SymlinkNode {
114
+ name: string
115
+ target: string
116
+ }
117
+ export interface Directory {
118
+ files: FileNode[]
119
+ directories: DirectoryNode[]
120
+ symlinks: SymlinkNode[]
121
+ }
122
+
123
+ const PROTO_ROOT = path.join(import.meta.dir, '..', 'protos')
124
+ /**
125
+ * Download-integrity check: bytes that came back for `digest` must hash to
126
+ * it. A corrupt or poisoned remote otherwise lands wrong bytes in the local
127
+ * content-addressed store under a trusted name — served forever under a
128
+ * green hit. Hashed with the NEGOTIATED digest function via the same
129
+ * helper every upload uses; a function this build cannot compute falls
130
+ * back to the length check alone (it also could never have negotiated).
131
+ */
132
+ function assertBlobIntegrity(
133
+ bytes: Uint8Array,
134
+ digest: Digest,
135
+ digestFunction: DigestFunctionName,
136
+ ): void {
137
+ assertServed(
138
+ bytes.length,
139
+ canDigest(digestFunction) ? () => digestWith(digestFunction, bytes).hash : undefined,
140
+ digest,
141
+ )
142
+ }
143
+
144
+ /**
145
+ * A zstd reply decoded no further than the size its digest declares, plus
146
+ * one byte to tell "more" from "exactly": `Bun.zstdDecompressSync` expands
147
+ * a frame to its end, and a 1 MiB batch entry or a ByteStream body of a
148
+ * few KB expanded to GiBs before the size check ran (L-3). A body that
149
+ * decodes past the digest is refused as it passes the bound.
150
+ */
151
+ async function unzstdBounded(data: Uint8Array, digest: Digest): Promise<Uint8Array> {
152
+ const max = Number(digest.size_bytes)
153
+ const parts: Uint8Array[] = []
154
+ let size = 0
155
+ const reader = new Blob([data]).stream().pipeThrough(new DecompressionStream('zstd')).getReader()
156
+ for (;;) {
157
+ const { done, value } = await reader.read()
158
+ if (done) break
159
+ size += value.byteLength
160
+ if (size > max) {
161
+ await reader.cancel()
162
+ throw overServed(digest)
163
+ }
164
+ parts.push(value)
165
+ }
166
+ return parts.length === 1 ? parts[0]! : new Uint8Array(Buffer.concat(parts))
167
+ }
168
+
169
+ /** More bytes than the digest declares, refused before the rest arrive. */
170
+ const overServed = (digest: Digest): Error =>
171
+ new Error(
172
+ `@vzn/vx-reapi: blob integrity failure for ${digest.hash.slice(0, 16)}…: ` +
173
+ `served past its declared ${digest.size_bytes} bytes`,
174
+ )
175
+
176
+ /**
177
+ * The most a zstd body of `size` decoded bytes can take on the wire: the
178
+ * format's own bound for incompressible input (ZSTD_COMPRESSBOUND) and a
179
+ * frame header's slack.
180
+ */
181
+ const wireBound = (size: number): number => size + (size >> 8) + 1024
182
+
183
+ /** The check itself, over a size and a hash however they were computed (whole or streamed). */
184
+ function assertServed(size: number, hash: (() => string) | undefined, digest: Digest): void {
185
+ if (size !== Number(digest.size_bytes)) {
186
+ throw new Error(
187
+ `@vzn/vx-reapi: blob integrity failure for ${digest.hash.slice(0, 16)}…: ` +
188
+ `size ${size} != declared ${digest.size_bytes}`,
189
+ )
190
+ }
191
+ if (hash === undefined) return
192
+ const got = hash()
193
+ if (got !== digest.hash) {
194
+ throw new Error(
195
+ `@vzn/vx-reapi: blob integrity failure: bytes hash to ${got.slice(0, 16)}… ` +
196
+ `but were served for digest ${digest.hash.slice(0, 16)}…`,
197
+ )
198
+ }
199
+ }
200
+
201
+ const LOAD_OPTIONS: protoLoader.Options = {
202
+ includeDirs: [PROTO_ROOT],
203
+ keepCase: true,
204
+ longs: String,
205
+ enums: String,
206
+ defaults: true,
207
+ oneofs: true,
208
+ }
209
+
210
+ interface ServiceClients {
211
+ cas: grpc.Client
212
+ ac: grpc.Client
213
+ bs: grpc.Client
214
+ caps: grpc.Client
215
+ exec: grpc.Client
216
+ ops: grpc.Client
217
+ }
218
+
219
+ type Ctors = Record<string, new (...a: unknown[]) => grpc.Client>
220
+ let loaded: { v2: Ctors; bs: Ctors; ops: Ctors } | undefined
221
+
222
+ /** Parsed ONCE per process: proto parsing is ~28 ms and identical every time. */
223
+ function ctors(): { v2: Ctors; bs: Ctors; ops: Ctors } {
224
+ if (loaded !== undefined) return loaded
225
+ // remote_execution.proto imports google.longrunning: its Operations
226
+ // service comes with the same parse.
227
+ const reapi = grpc.loadPackageDefinition(
228
+ protoLoader.loadSync('build/bazel/remote/execution/v2/remote_execution.proto', LOAD_OPTIONS),
229
+ ) as unknown as {
230
+ build: { bazel: { remote: { execution: { v2: Ctors } } } }
231
+ google: { longrunning: Ctors }
232
+ }
233
+ const bytestream = grpc.loadPackageDefinition(
234
+ protoLoader.loadSync('google/bytestream/bytestream.proto', LOAD_OPTIONS),
235
+ ) as unknown as { google: { bytestream: Ctors } }
236
+ loaded = {
237
+ v2: reapi.build.bazel.remote.execution.v2,
238
+ bs: bytestream.google.bytestream,
239
+ ops: reapi.google.longrunning,
240
+ }
241
+ return loaded
242
+ }
243
+
244
+ /** Per-entry encoding cost in a Batch request: a 64-char hex hash, a size,
245
+ * and the nested field tags and length prefixes around them. Rounded up. */
246
+ const BATCH_ENTRY_OVERHEAD = 128
247
+
248
+ /** One batch must fit in a single gRPC message; 4 MiB is the ecosystem
249
+ * default and 64 KiB leaves room for the envelope. */
250
+ const SAFE_BATCH_BYTES = 4 * 1024 * 1024 - 64 * 1024
251
+
252
+ /** Batches and streamed writes one `uploadBlobs` keeps in flight. */
253
+ const UPLOAD_CONCURRENCY = 8
254
+
255
+ /** A `Digest` in a request past its hash: size varint and field framing. */
256
+ const FIND_MISSING_ENTRY_OVERHEAD = 16
257
+
258
+ /** grpc's 4 MiB receive default is far below REAPI's real message sizes. */
259
+ const MAX_MESSAGE_BYTES = 256 * 1024 * 1024
260
+
261
+ /**
262
+ * The receive window the client offers, per stream and per connection.
263
+ * HTTP/2's default 64 KiB moves one window per round trip: a read at 30 ms
264
+ * round trip ran at 2 MB/s (8 MB in 3988 ms, 142 ms at 16 MiB; F-32).
265
+ */
266
+ const FLOW_CONTROL_WINDOW = 16 * 1024 * 1024
267
+
268
+ /**
269
+ * All six service stubs share ONE channel. Constructing them independently
270
+ * opens one HTTP/2 connection per service to the same endpoint — six times
271
+ * the sockets, six times the flow-control state, and a server that sees six
272
+ * clients where there is one. `channelOverride` is grpc-js's supported way to
273
+ * bind extra stubs onto an existing channel.
274
+ */
275
+ function loadServices(target: string, creds: grpc.ChannelCredentials): ServiceClients {
276
+ const { v2, bs, ops } = ctors()
277
+ // An ActionResult listing a real dependency tree is megabytes, and the
278
+ // default limit rejects it with RESOURCE_EXHAUSTED — measured at 4 359 595
279
+ // bytes against the 4 194 304 default. Applied to EVERY stub: the limit is
280
+ // per-client, so sharing one channel does not share it.
281
+ const opts = {
282
+ 'grpc.max_receive_message_length': MAX_MESSAGE_BYTES,
283
+ 'grpc.max_send_message_length': MAX_MESSAGE_BYTES,
284
+ 'grpc-node.flow_control_window': FLOW_CONTROL_WINDOW,
285
+ }
286
+ const cas = new v2['ContentAddressableStorage']!(target, creds, opts)
287
+ const shared = { ...opts, channelOverride: cas.getChannel() }
288
+ return {
289
+ cas,
290
+ ac: new v2['ActionCache']!(target, creds, shared),
291
+ caps: new v2['Capabilities']!(target, creds, shared),
292
+ exec: new v2['Execution']!(target, creds, shared),
293
+ bs: new bs['ByteStream']!(target, creds, shared),
294
+ ops: new ops['Operations']!(target, creds, shared),
295
+ }
296
+ }
297
+
298
+ /** Transient statuses a retry can heal: UNAVAILABLE, RESOURCE_EXHAUSTED when
299
+ * the server is shedding load, and INTERNAL, which is how grpc-js spells a
300
+ * call cut in transit — an RST_STREAM(INTERNAL_ERROR), the frame a proxy
301
+ * sends when the backend behind it goes away, and a stream that ended with
302
+ * no gRPC status at all. Bazel's remote executor retries all three.
303
+ * NOT_FOUND/INVALID_ARGUMENT never heal. */
304
+ function isRetryable(code: number | undefined): boolean {
305
+ return (
306
+ code === grpc.status.UNAVAILABLE ||
307
+ code === grpc.status.RESOURCE_EXHAUSTED ||
308
+ code === grpc.status.INTERNAL
309
+ )
310
+ }
311
+
312
+ const RETRY_DELAYS_MS = [100, 400, 1600]
313
+
314
+ const deadlineOf = (bounds: grpc.CallOptions): number => (bounds.deadline as Date).getTime()
315
+
316
+ const executionAborted = (): Error => new Error('reapi: execution aborted')
317
+
318
+ /** A backoff an abort ends at once: the caller's deadline may fire mid-wait. */
319
+ function abortableSleep(ms: number, signal: AbortSignal | undefined): Promise<void> {
320
+ return new Promise((resolve, reject) => {
321
+ const onAbort = (): void => {
322
+ clearTimeout(timer)
323
+ reject(executionAborted())
324
+ }
325
+ const timer = setTimeout(() => {
326
+ signal?.removeEventListener('abort', onAbort)
327
+ resolve()
328
+ }, ms)
329
+ signal?.addEventListener('abort', onAbort, { once: true })
330
+ })
331
+ }
332
+
333
+ const NOT_FOUND = grpc.status.NOT_FOUND
334
+
335
+ export interface ReapiOptions {
336
+ /** `host:port` of the REAPI server. */
337
+ endpoint: string
338
+ /** Multi-tenant servers scope by instance; most single-tenant ones use ''. */
339
+ instanceName?: string
340
+ /** Sent on every call (auth, routing). */
341
+ headers?: Record<string, string>
342
+ /** TLS. Default: on with any PEM below or an `https://`/`grpcs://` endpoint, else insecure. */
343
+ tls?: boolean
344
+ /** PEM of the CA that signed the server's certificate, in place of the system roots. */
345
+ tlsCaPem?: string
346
+ /** PEM of a client certificate and its key, for a server that asks for mutual TLS. */
347
+ tlsClientCertPem?: string
348
+ tlsClientKeyPem?: string
349
+ /** Reported in REAPI `RequestMetadata.tool_details`. */
350
+ toolName?: string
351
+ toolVersion?: string
352
+ /** Groups several vx runs as one logical build in a server's UI. */
353
+ correlatedInvocationsId?: string
354
+ /** Surfaced for degraded-but-recovered operations (e.g. a chunk-size downgrade). */
355
+ onWarn?: (message: string) => void
356
+ /**
357
+ * Deadline for every CACHE-PATH call (unary RPCs, ByteStream transfers).
358
+ * Default 30 000 ms. This is what turns a WEDGED server — accepts TCP,
359
+ * never answers — into an error the layer above can degrade to a MISS;
360
+ * without it the first probe hangs the whole run. Execution streams are
361
+ * NOT bounded by this (queueing behind a busy worker pool is legitimate
362
+ * and unbounded); a wedged server still cannot reach Execute, because the
363
+ * deadline-bounded Capabilities call runs first and fails.
364
+ */
365
+ callTimeoutMs?: number
366
+ /**
367
+ * Deadline for CONTROL-PLANE calls — Capabilities, GetActionResult,
368
+ * UpdateActionResult, FindMissingBlobs, QueryWriteStatus. Defaults to
369
+ * `min(callTimeoutMs, 15 000)`.
370
+ *
371
+ * Separate from `callTimeoutMs` because the two classes have nothing in
372
+ * common but a transport. A control-plane message is small and bounded — a
373
+ * healthy server answers in single-digit milliseconds — while a bulk
374
+ * transfer is size-proportional and legitimately slow: capturing a
375
+ * `node_modules` tree is the case that pushes real deployments to raise
376
+ * `callTimeoutMs` into the minutes. With one knob for both, buying enough
377
+ * headroom for the upload also means every metadata probe against a WEDGED
378
+ * server costs those same minutes before it can degrade to a miss, which is
379
+ * the opposite of what the deadline exists for. Measured against a
380
+ * NativeLink that had degraded into serving every AC hit never: misses
381
+ * answered in 3 ms while every hit burned the full deadline.
382
+ */
383
+ metaTimeoutMs?: number
384
+ /**
385
+ * Bytes per ByteStream message. Defaults to `CHUNK_BYTES` (65535, the size
386
+ * with no peer-dependence); a larger value risks the stall described on
387
+ * `CHUNK_BYTES`, retried once at `SAFE_CHUNK_BYTES`.
388
+ */
389
+ chunkBytes?: number
390
+ }
391
+
392
+ /** The ceiling a derived control-plane deadline never exceeds, however far
393
+ * `callTimeoutMs` is raised for a bulk transfer. */
394
+ export const META_TIMEOUT_CAP_MS = 15_000
395
+
396
+ /**
397
+ * `body` from `from` as ByteStream messages of `chunkBytes`. A `Blob` is read
398
+ * from its stream as the caller asks, re-cut to the message size: a file
399
+ * stream's pieces are its own (256 KiB and up), not the wire's.
400
+ */
401
+ async function* messagesOf(
402
+ body: Uint8Array | Blob,
403
+ from: number,
404
+ chunkBytes: number,
405
+ ): AsyncGenerator<Uint8Array> {
406
+ if (body instanceof Uint8Array) {
407
+ for (let at = from; at < body.length; at += chunkBytes) {
408
+ yield body.subarray(at, Math.min(at + chunkBytes, body.length))
409
+ }
410
+ return
411
+ }
412
+ let carry: Uint8Array = new Uint8Array(0)
413
+ let skip = from
414
+ for await (const whole of body.stream()) {
415
+ // A resumed write starts past what the server committed.
416
+ const piece = skip === 0 ? whole : whole.subarray(Math.min(skip, whole.length))
417
+ skip -= whole.length - piece.length
418
+ if (piece.length === 0) continue
419
+ const data = carry.length === 0 ? piece : concat([carry, piece])
420
+ let at = 0
421
+ for (; data.length - at >= chunkBytes; at += chunkBytes)
422
+ yield data.subarray(at, at + chunkBytes)
423
+ carry = data.slice(at)
424
+ }
425
+ if (carry.length > 0) yield carry
426
+ }
427
+
428
+ export class ReapiClient {
429
+ private readonly svc: ServiceClients
430
+ private readonly instance: string
431
+ private readonly headers: Record<string, string>
432
+ private readonly chunkBytes: number
433
+ private readonly callTimeoutMs: number
434
+ /** What `negotiate()` learned the server will accept in one Batch call.
435
+ * Used whenever a caller does not pass a budget, so a call site cannot
436
+ * silently fall back to a larger default than the server allows. */
437
+ private negotiatedBatchBytes = 0
438
+ /** The control-plane deadline in force: `metaTimeoutMs` as given, else
439
+ * `min(callTimeoutMs, META_TIMEOUT_CAP_MS)`. Readable so the derivation is
440
+ * pinned on the instance, not waited out on the wire. */
441
+ readonly metaTimeoutMs: number
442
+ private readonly onWarn: (message: string) => void
443
+ private digestFunction: DigestFunctionName = 'SHA256'
444
+ private compression = false
445
+ private batchCompression = false
446
+ private negotiated: ServerCapabilities | undefined
447
+ private readonly toolName: string
448
+ private readonly toolVersion: string
449
+ private readonly correlatedInvocationsId: string
450
+ /** Set when a call spent its retries on UNAVAILABLE; cleared by any answer. */
451
+ private unreachable = false
452
+ /** Set per action so the server can group its RPCs under one action. */
453
+ actionId = ''
454
+ toolInvocationId = ''
455
+
456
+ constructor(opts: ReapiOptions) {
457
+ assertBunSupportsChunking()
458
+ const pem = (text: string | undefined): Buffer | null =>
459
+ text === undefined ? null : Buffer.from(text)
460
+ const tls =
461
+ opts.tls ??
462
+ (opts.tlsCaPem !== undefined ||
463
+ opts.tlsClientCertPem !== undefined ||
464
+ /^(https|grpcs):\/\//.test(opts.endpoint))
465
+ const target = opts.endpoint.replace(/^(https?|grpcs?):\/\//, '')
466
+ this.svc = loadServices(
467
+ target,
468
+ tls
469
+ ? grpc.credentials.createSsl(
470
+ pem(opts.tlsCaPem),
471
+ pem(opts.tlsClientKeyPem),
472
+ pem(opts.tlsClientCertPem),
473
+ )
474
+ : grpc.credentials.createInsecure(),
475
+ )
476
+ this.instance = opts.instanceName ?? ''
477
+ this.headers = opts.headers ?? {}
478
+ // grpc-js refuses a metadata value outside printable ASCII on EVERY call
479
+ // and quotes it in the error, so an auth header's secret reached each
480
+ // degrade warning (item 928). Refused once, here, by key alone.
481
+ for (const [k, v] of Object.entries(this.headers)) {
482
+ if (!/^[ -~]*$/.test(v)) {
483
+ throw new Error(
484
+ `reapi: header ${JSON.stringify(k)} holds a character gRPC metadata cannot carry (printable ASCII only) — check it (its value is not printed)`,
485
+ )
486
+ }
487
+ }
488
+ this.toolName = opts.toolName ?? 'vx'
489
+ this.toolVersion = opts.toolVersion ?? '0.0.0'
490
+ this.correlatedInvocationsId = opts.correlatedInvocationsId ?? ''
491
+ this.chunkBytes = opts.chunkBytes ?? CHUNK_BYTES
492
+ this.callTimeoutMs = opts.callTimeoutMs ?? 30_000
493
+ // Derived, not required: raising `callTimeoutMs` for a big upload must
494
+ // not silently lengthen every metadata probe too.
495
+ this.metaTimeoutMs = opts.metaTimeoutMs ?? Math.min(this.callTimeoutMs, META_TIMEOUT_CAP_MS)
496
+ this.onWarn = opts.onWarn ?? (() => undefined)
497
+ // A zero, negative or non-number deadline ended every call as it began.
498
+ for (const [name, ms] of [
499
+ ['callTimeoutMs', this.callTimeoutMs],
500
+ ['metaTimeoutMs', this.metaTimeoutMs],
501
+ ] as const) {
502
+ if (!(typeof ms === 'number' && Number.isFinite(ms) && ms > 0)) {
503
+ throw new Error(
504
+ `@vzn/vx-reapi: ${name} must be a positive number of ms (got ${JSON.stringify(ms)})`,
505
+ )
506
+ }
507
+ }
508
+ if (!Number.isInteger(this.chunkBytes) || this.chunkBytes < 1) {
509
+ throw new Error(
510
+ `@vzn/vx-reapi: chunkBytes must be a positive integer (got ${this.chunkBytes})`,
511
+ )
512
+ }
513
+ }
514
+
515
+ /**
516
+ * The wait before retry `attempt` of a cache-path call that failed with
517
+ * `code`, or undefined to give up. A call that spends its retries on
518
+ * UNAVAILABLE marks the server unreachable, and until a call succeeds the
519
+ * next ones give up at their first UNAVAILABLE, until a call succeeds or
520
+ * the server answers with a status of its own: a refused port cost every
521
+ * request 2.1 s of backoff, and a five-task run 24 s (J-74).
522
+ */
523
+ private retryDelay(attempt: number, code: number | undefined): number | undefined {
524
+ const unavailable = code === grpc.status.UNAVAILABLE
525
+ // A status the server sent (NOT_FOUND is a miss) proves it answers.
526
+ if (!unavailable && code !== grpc.status.DEADLINE_EXCEEDED && code !== grpc.status.CANCELLED) {
527
+ this.unreachable = false
528
+ }
529
+ if (!isRetryable(code)) return undefined
530
+ if (unavailable && this.unreachable) return undefined
531
+ const delay = RETRY_DELAYS_MS[attempt]
532
+ if (delay === undefined && unavailable) this.unreachable = true
533
+ return delay
534
+ }
535
+
536
+ /**
537
+ * Promisified unary call with bounded retry on transient failure. Every
538
+ * unary REAPI call is idempotent by construction (CAS writes are
539
+ * content-addressed, AC updates are last-writer-wins on an immutable key),
540
+ * so retrying cannot double-apply anything.
541
+ */
542
+ private async unary<T>(
543
+ client: grpc.Client,
544
+ method: string,
545
+ req: unknown,
546
+ meta: grpc.Metadata,
547
+ options: grpc.CallOptions = {},
548
+ ): Promise<T> {
549
+ for (let attempt = 0; ; attempt++) {
550
+ try {
551
+ const res = await new Promise<T>((resolve, reject) => {
552
+ ;(client as unknown as Record<string, Function>)[method]!(
553
+ req,
554
+ meta,
555
+ options,
556
+ (err: grpc.ServiceError | null, res: T) => (err ? reject(err) : resolve(res)),
557
+ )
558
+ })
559
+ this.unreachable = false
560
+ return res
561
+ } catch (err) {
562
+ const delay = this.retryDelay(attempt, (err as grpc.ServiceError).code)
563
+ if (delay === undefined) throw err
564
+ await Bun.sleep(delay)
565
+ }
566
+ }
567
+ }
568
+
569
+ /** Call options for a BULK transfer — deadline scales with payload size. */
570
+ private bounded(): grpc.CallOptions {
571
+ return { deadline: new Date(Date.now() + this.callTimeoutMs) }
572
+ }
573
+
574
+ /** Call options for a CONTROL-PLANE call — small message, short deadline. */
575
+ private boundedMeta(): grpc.CallOptions {
576
+ return { deadline: new Date(Date.now() + this.metaTimeoutMs) }
577
+ }
578
+
579
+ /**
580
+ * Per-call metadata. Beyond the user's headers this carries REAPI's
581
+ * `RequestMetadata` in the well-known binary header, which is how a server
582
+ * groups the dozens of CAS/AC calls an action makes into one build in its
583
+ * UI. Omitting it is legal and makes vx invisible in every REAPI server's
584
+ * dashboard, which is the whole reason the field exists.
585
+ */
586
+ private meta(): grpc.Metadata {
587
+ const m = new grpc.Metadata()
588
+ for (const [k, v] of Object.entries(this.headers)) m.set(k, v)
589
+ m.set(
590
+ 'build.bazel.remote.execution.v2.requestmetadata-bin',
591
+ Buffer.from(this.requestMetadata()),
592
+ )
593
+ return m
594
+ }
595
+
596
+ /**
597
+ * `RequestMetadata { tool_details = 1, action_id = 2, tool_invocation_id = 3,
598
+ * correlated_invocations_id = 4, action_mnemonic = 6 }`
599
+ * with `ToolDetails { tool_name = 1, tool_version = 2 }`.
600
+ */
601
+ private requestMetadata(): Uint8Array {
602
+ const str = (field: number, v: string): Uint8Array => {
603
+ if (v === '') return new Uint8Array()
604
+ const bytes = new TextEncoder().encode(v)
605
+ return concat([varintBytes((field << 3) | 2), varintBytes(bytes.length), bytes])
606
+ }
607
+ const tool = concat([str(1, this.toolName), str(2, this.toolVersion)])
608
+ const toolField =
609
+ tool.length === 0
610
+ ? new Uint8Array()
611
+ : concat([varintBytes((1 << 3) | 2), varintBytes(tool.length), tool])
612
+ return concat([
613
+ toolField,
614
+ str(2, this.actionId),
615
+ str(3, this.toolInvocationId),
616
+ str(4, this.correlatedInvocationsId),
617
+ ])
618
+ }
619
+
620
+ /**
621
+ * `GetCapabilities`. `execEnabled` is what decides whether this server can
622
+ * take an `executor` at all — a cache-only deployment (bazel-remote)
623
+ * advertises no execution capability, and offering it work would hang a run
624
+ * on a server that will never answer.
625
+ */
626
+ async capabilities(): Promise<ServerCapabilities> {
627
+ const res = await this.unary<{
628
+ cache_capabilities?: {
629
+ digest_functions?: string[]
630
+ max_batch_total_size_bytes?: string
631
+ action_cache_update_capabilities?: { update_enabled?: boolean }
632
+ supported_compressors?: string[]
633
+ supported_batch_update_compressors?: string[]
634
+ symlink_absolute_path_strategy?: string
635
+ split_blob_support?: boolean
636
+ splice_blob_support?: boolean
637
+ }
638
+ execution_capabilities?: { exec_enabled?: boolean; digest_function?: string }
639
+ }>(
640
+ this.svc.caps,
641
+ 'getCapabilities',
642
+ { instance_name: this.instance },
643
+ this.meta(),
644
+ this.boundedMeta(),
645
+ )
646
+ const cc = res.cache_capabilities
647
+ return {
648
+ digestFunctions: cc?.digest_functions ?? [],
649
+ // CLAMPED, not trusted. `max_batch_total_size_bytes` is what the
650
+ // server's CAS logic will accept in one Batch call; it says nothing
651
+ // about what its gRPC layer will carry, and grpc's default caps a
652
+ // single message at 4 MiB. Buildbarn advertises its configured message
653
+ // size here, so a generous config produced batches it then refused —
654
+ // `batchUpdateBlobs` rejected at 4 356 859 vs 4 194 304. Anything
655
+ // larger than the safe bound goes by ByteStream anyway, which chunks.
656
+ maxBatchBytes: this.rememberBatchBytes(Number(cc?.max_batch_total_size_bytes ?? 0)),
657
+ acUpdateEnabled: cc?.action_cache_update_capabilities?.update_enabled === true,
658
+ execEnabled: res.execution_capabilities?.exec_enabled === true,
659
+ supportedCompressors: cc?.supported_compressors ?? [],
660
+ supportedBatchCompressors: cc?.supported_batch_update_compressors ?? [],
661
+ symlinkAbsolutePathStrategy: cc?.symlink_absolute_path_strategy ?? 'UNKNOWN',
662
+ splitBlobSupport: cc?.split_blob_support === true,
663
+ spliceBlobSupport: cc?.splice_blob_support === true,
664
+ }
665
+ }
666
+
667
+ /**
668
+ * Negotiate once against the server's advertised capabilities: the digest
669
+ * function to hash with, and whether blobs may ride compressed. Mixing
670
+ * digest functions within an action is invalid, so this is a per-client
671
+ * decision, not a per-call one.
672
+ */
673
+ async negotiate(prefer?: {
674
+ digestFunction?: DigestFunctionName
675
+ compression?: boolean
676
+ }): Promise<void> {
677
+ const caps = await this.capabilities()
678
+ const wanted = prefer?.digestFunction
679
+ if (wanted !== undefined) {
680
+ if (!caps.digestFunctions.includes(wanted)) {
681
+ throw new Error(
682
+ `@vzn/vx-reapi: server does not support digest function ${wanted} (has ${caps.digestFunctions.join(', ')})`,
683
+ )
684
+ }
685
+ if (!canDigest(wanted))
686
+ throw new Error(`@vzn/vx-reapi: this runtime cannot compute ${wanted}`)
687
+ this.digestFunction = wanted
688
+ } else {
689
+ // Default stays SHA256 even when the server advertises stronger
690
+ // functions: the Merkle/action encoders digest with the SAME function
691
+ // as every blob upload, and auto-upgrading here while a caller still
692
+ // hashes trees with sha256 would mix functions inside one action —
693
+ // which servers reject at best and mis-address at worst. Opting into
694
+ // another function is `negotiate({ digestFunction: 'SHA512' })`, a
695
+ // caller-level decision made where the tree hashing can follow it.
696
+ this.digestFunction = 'SHA256'
697
+ }
698
+ this.compression =
699
+ prefer?.compression !== false &&
700
+ caps.supportedCompressors.includes('ZSTD') &&
701
+ typeof Bun.zstdCompressSync === 'function'
702
+ this.batchCompression = this.compression && caps.supportedBatchCompressors.includes('ZSTD')
703
+ this.negotiated = caps
704
+ }
705
+
706
+ /** The negotiated digest function; SHA256 until `negotiate()` says otherwise. */
707
+ get digest(): DigestFunctionName {
708
+ return this.digestFunction
709
+ }
710
+
711
+ /** Whether ByteStream transfers will be zstd-compressed. */
712
+ get compressionEnabled(): boolean {
713
+ return this.compression
714
+ }
715
+
716
+ /** Digest a payload under the negotiated function. */
717
+ digestOf(data: Uint8Array): Digest {
718
+ return digestWith(this.digestFunction, data)
719
+ }
720
+
721
+ /**
722
+ * `QueryWriteStatus` — how much of a resource the server already holds, so
723
+ * an interrupted upload resumes instead of restarting. Returns null when the
724
+ * server has no record of it (a fresh upload).
725
+ */
726
+ async queryWriteStatus(
727
+ resourceName: string,
728
+ ): Promise<{ committedSize: number; complete: boolean } | null> {
729
+ try {
730
+ const res = await this.unary<{ committed_size?: string; complete?: boolean }>(
731
+ this.svc.bs,
732
+ 'queryWriteStatus',
733
+ { resource_name: resourceName },
734
+ this.meta(),
735
+ this.boundedMeta(),
736
+ )
737
+ return { committedSize: Number(res.committed_size ?? 0), complete: res.complete === true }
738
+ } catch (err) {
739
+ if ((err as grpc.ServiceError).code === NOT_FOUND) return null
740
+ throw err
741
+ }
742
+ }
743
+
744
+ /**
745
+ * Upload many small blobs in ONE round trip. The server caps the total
746
+ * (`max_batch_total_size_bytes`, 0 = unspecified → assume the 4 MB gRPC
747
+ * default), so callers must partition; `uploadBlobs` does that and falls
748
+ * back to ByteStream for anything too large on its own.
749
+ */
750
+ async batchUpdateBlobs(
751
+ blobs: ReadonlyArray<{ digest: Digest; data: Uint8Array }>,
752
+ ): Promise<void> {
753
+ if (blobs.length === 0) return
754
+ const res = await this.unary<{
755
+ responses?: Array<{ digest: Digest; status?: { code?: number; message?: string } }>
756
+ }>(
757
+ this.svc.cas,
758
+ 'batchUpdateBlobs',
759
+ {
760
+ instance_name: this.instance,
761
+ requests: blobs.map((b) => ({
762
+ digest: b.digest,
763
+ data: this.batchCompression ? Bun.zstdCompressSync(b.data) : b.data,
764
+ ...(this.batchCompression ? { compressor: 'ZSTD' } : {}),
765
+ })),
766
+ ...(this.digestFunction === 'SHA256' ? {} : { digest_function: this.digestFunction }),
767
+ },
768
+ this.meta(),
769
+ this.bounded(),
770
+ )
771
+ // A batch call succeeds at the RPC layer while individual blobs fail; a
772
+ // silently dropped input blob would surface later as an unexplained
773
+ // remote execution failure, so surface it here.
774
+ for (const r of res.responses ?? []) {
775
+ const code = r.status?.code ?? 0
776
+ if (code !== 0) {
777
+ throw new Error(
778
+ `reapi: BatchUpdateBlobs rejected ${r.digest?.hash}: code ${code} ${r.status?.message ?? ''}`,
779
+ )
780
+ }
781
+ }
782
+ }
783
+
784
+ /**
785
+ * Fetch many small blobs, partitioned to the server's batch budget so one
786
+ * call can never exceed the message cap. Missing entries are omitted. When
787
+ * compression was negotiated the request declares ZSTD acceptable and each
788
+ * response is decompressed per its OWN `compressor` field — a server is
789
+ * free to answer some entries compressed and others not.
790
+ */
791
+ async batchReadBlobs(
792
+ digests: readonly Digest[],
793
+ maxBatchBytes = 0,
794
+ ): Promise<Map<string, Uint8Array>> {
795
+ const out = new Map<string, Uint8Array>()
796
+ if (digests.length === 0) return out
797
+ const budget =
798
+ maxBatchBytes > 0
799
+ ? Math.min(maxBatchBytes, SAFE_BATCH_BYTES)
800
+ : this.negotiatedBatchBytes || SAFE_BATCH_BYTES
801
+ let group: Digest[] = []
802
+ let grouped = 0
803
+ const flush = async (): Promise<void> => {
804
+ if (group.length === 0) return
805
+ const res = await this.unary<{
806
+ responses?: Array<{
807
+ digest: Digest
808
+ data?: Uint8Array
809
+ compressor?: string | number
810
+ status?: { code?: number }
811
+ }>
812
+ }>(
813
+ this.svc.cas,
814
+ 'batchReadBlobs',
815
+ {
816
+ instance_name: this.instance,
817
+ digests: group,
818
+ ...(this.compression ? { acceptable_compressors: ['ZSTD'] } : {}),
819
+ ...(this.digestFunction === 'SHA256' ? {} : { digest_function: this.digestFunction }),
820
+ },
821
+ this.meta(),
822
+ this.bounded(),
823
+ )
824
+ // Each entry is held to the digest ASKED for: the response's own
825
+ // digest is the server's word, and a bound read from it bounds nothing.
826
+ const asked = new Map(group.map((d) => [d.hash, d]))
827
+ for (const r of res.responses ?? []) {
828
+ if ((r.status?.code ?? 0) !== 0 || r.data === undefined) continue
829
+ const digest = asked.get(r.digest?.hash)
830
+ if (digest === undefined) continue
831
+ const zstd = r.compressor === 'ZSTD' || r.compressor === 1
832
+ const body = zstd ? await unzstdBounded(r.data, digest) : r.data
833
+ assertBlobIntegrity(body, digest, this.digestFunction)
834
+ out.set(digest.hash, body)
835
+ }
836
+ group = []
837
+ grouped = 0
838
+ }
839
+ for (const d of digests) {
840
+ // Same encoding charge as the upload side: the RESPONSE carries a
841
+ // digest and framing per blob on top of the bytes, so counting payload
842
+ // alone overshoots the server's ceiling — observed as
843
+ // `Attempted to read a total of at least 2 291 516 bytes, while a
844
+ // maximum of 2 097 152 bytes is permitted`.
845
+ const cost = d.size_bytes + BATCH_ENTRY_OVERHEAD
846
+ // A blob too large for ANY batch has to go by ByteStream, which chunks.
847
+ // The upload side always did this; the read side did not, so one large
848
+ // blob was put in a group of its own and the request still exceeded the
849
+ // ceiling no matter how the rest were grouped.
850
+ if (cost > budget) {
851
+ const bytes = await this.readBlob(d)
852
+ if (bytes !== null) out.set(d.hash, bytes)
853
+ continue
854
+ }
855
+ if (grouped + cost > budget && group.length > 0) await flush()
856
+ group.push(d)
857
+ grouped += cost
858
+ }
859
+ await flush()
860
+ return out
861
+ }
862
+
863
+ /**
864
+ * Upload blobs, choosing the transport per blob: batched while they fit the
865
+ * server's batch budget, ByteStream for the rest. Only what
866
+ * `FindMissingBlobs` reports absent is sent at all.
867
+ */
868
+ async uploadBlobs(
869
+ blobs: ReadonlyArray<{ digest: Digest; data: Uint8Array }>,
870
+ maxBatchBytes = 0,
871
+ ): Promise<void> {
872
+ if (blobs.length === 0) return
873
+ const missing = new Set(
874
+ (await this.findMissingBlobs(blobs.map((b) => b.digest))).map((d) => d.hash),
875
+ )
876
+ const todo = blobs.filter((b) => missing.has(b.digest.hash))
877
+ // 0 means the server did not say; the gRPC default max message is 4 MB and
878
+ // the request carries framing on top, so leave headroom rather than
879
+ // discovering the limit as a RESOURCE_EXHAUSTED mid-run.
880
+ const budget =
881
+ maxBatchBytes > 0
882
+ ? Math.min(maxBatchBytes, SAFE_BATCH_BYTES)
883
+ : this.negotiatedBatchBytes || SAFE_BATCH_BYTES
884
+ const jobs: Array<() => Promise<void>> = []
885
+ let batch: Array<{ digest: Digest; data: Uint8Array }> = []
886
+ let batched = 0
887
+ for (const b of todo) {
888
+ // Charge the ENCODING, not just the payload. Each entry also carries a
889
+ // 64-character hash, a size, and several layers of field tags and
890
+ // length prefixes; summing raw sizes alone under-counts by ~100 bytes
891
+ // per blob, which for a few thousand small files is hundreds of
892
+ // kilobytes — enough to push a batch that "fits" over the wire limit
893
+ // and have the server refuse it (observed: 4 356 859 sent against a
894
+ // 4 194 304 ceiling).
895
+ const cost = b.digest.size_bytes + BATCH_ENTRY_OVERHEAD
896
+ if (cost > budget) {
897
+ jobs.push(() => this.writeBlob(b.digest, b.data))
898
+ continue
899
+ }
900
+ if (batched + cost > budget) {
901
+ const full = batch
902
+ jobs.push(() => this.batchUpdateBlobs(full))
903
+ batch = []
904
+ batched = 0
905
+ }
906
+ batch.push(b)
907
+ batched += cost
908
+ }
909
+ if (batch.length > 0) jobs.push(() => this.batchUpdateBlobs(batch))
910
+ // UPLOAD_CONCURRENCY at once, not one after another: a grpc-go server
911
+ // grows its receive window from what it measures arriving, and one
912
+ // stream at a time held four 8 MB writes to 10.2 s through 15 ms each
913
+ // way, 0.49 s at once (F-34). The first failure stops the queue.
914
+ let next = 0
915
+ let failed = false
916
+ const worker = async (): Promise<void> => {
917
+ while (!failed && next < jobs.length) {
918
+ const job = jobs[next++]!
919
+ await job().catch((err: unknown) => {
920
+ failed = true
921
+ throw err
922
+ })
923
+ }
924
+ }
925
+ await Promise.all(Array.from({ length: Math.min(UPLOAD_CONCURRENCY, jobs.length) }, worker))
926
+ }
927
+
928
+ private rememberBatchBytes(advertised: number): number {
929
+ this.negotiatedBatchBytes = Math.min(advertised || SAFE_BATCH_BYTES, SAFE_BATCH_BYTES)
930
+ return this.negotiatedBatchBytes
931
+ }
932
+
933
+ /** The subset of `digests` the server does NOT have — the upload-minimality primitive. */
934
+ async findMissingBlobs(digests: readonly Digest[]): Promise<Digest[]> {
935
+ if (digests.length === 0) return []
936
+ // One request per SAFE_BATCH_BYTES of digests: a server's receive limit
937
+ // (4 MiB by default) refused an input tree of 70 000 files in one
938
+ // message, RESOURCE_EXHAUSTED on every attempt.
939
+ const groups: Digest[][] = [[]]
940
+ let size = 0
941
+ for (const d of digests) {
942
+ const cost = d.hash.length + FIND_MISSING_ENTRY_OVERHEAD
943
+ if (size + cost > SAFE_BATCH_BYTES && groups.at(-1)!.length > 0) {
944
+ groups.push([])
945
+ size = 0
946
+ }
947
+ groups.at(-1)!.push(d)
948
+ size += cost
949
+ }
950
+ const answers = await Promise.all(
951
+ groups.map((group) =>
952
+ this.unary<{ missing_blob_digests?: Digest[] }>(
953
+ this.svc.cas,
954
+ 'findMissingBlobs',
955
+ { instance_name: this.instance, blob_digests: group },
956
+ this.meta(),
957
+ this.boundedMeta(),
958
+ ),
959
+ ),
960
+ )
961
+ return answers.flatMap((res) => res.missing_blob_digests ?? [])
962
+ }
963
+
964
+ /**
965
+ * `GetActionResult`; `null` on NOT_FOUND — a miss is not an error.
966
+ * `inline` asks the server to return stdout and the named output files in
967
+ * the reply. The spec lets it decline (and requires it past the message
968
+ * limit), so a caller reads `stdout_raw` / `contents` when present and
969
+ * fetches otherwise.
970
+ */
971
+ async getActionResult(
972
+ action: Digest,
973
+ inline?: { stdout: boolean; files: readonly string[] },
974
+ ): Promise<ActionResult | null> {
975
+ try {
976
+ return await this.unary<ActionResult>(
977
+ this.svc.ac,
978
+ 'getActionResult',
979
+ {
980
+ instance_name: this.instance,
981
+ action_digest: action,
982
+ ...(inline === undefined
983
+ ? {}
984
+ : { inline_stdout: inline.stdout, inline_output_files: inline.files }),
985
+ },
986
+ this.meta(),
987
+ this.boundedMeta(),
988
+ )
989
+ } catch (err) {
990
+ if ((err as grpc.ServiceError).code === NOT_FOUND) return null
991
+ throw err
992
+ }
993
+ }
994
+
995
+ async updateActionResult(action: Digest, result: ActionResult): Promise<void> {
996
+ await this.unary(
997
+ this.svc.ac,
998
+ 'updateActionResult',
999
+ { instance_name: this.instance, action_digest: action, action_result: result },
1000
+ this.meta(),
1001
+ this.boundedMeta(),
1002
+ )
1003
+ }
1004
+
1005
+ /**
1006
+ * Upload a blob via ByteStream, chunked at `chunkBytes`, RESUMING an
1007
+ * interrupted identity upload from the server's committed offset
1008
+ * (`QueryWriteStatus`) instead of restarting. A compressed upload restarts
1009
+ * under a fresh resource name — compressed write offsets count compressed
1010
+ * bytes, and mid-stream resumption of a zstd frame is not a thing a server
1011
+ * can honour.
1012
+ *
1013
+ * A `Blob` past the batch limit is sent from its own stream and never held
1014
+ * whole: a file-backed one (the vx artifact) is read from disk message by
1015
+ * message as the channel drains. It goes identity-encoded — re-compressing
1016
+ * it would need the bytes in hand, and the one Blob this carries is a
1017
+ * zstd artifact already. A Blob under the limit is small enough to read.
1018
+ */
1019
+ async writeBlob(digest: Digest, source: Uint8Array | Blob): Promise<void> {
1020
+ const streamed =
1021
+ source instanceof Blob && source.size > (this.negotiatedBatchBytes || SAFE_BATCH_BYTES)
1022
+ const body = source instanceof Blob && !streamed ? await source.bytes() : source
1023
+ const compressed = this.compression && body instanceof Uint8Array
1024
+ // REAPI carries compression in the RESOURCE NAME:
1025
+ // uploads/{uuid}/compressed-blobs/{compressor}/{hash}/{uncompressed_size}
1026
+ // The digest and size stay those of the UNCOMPRESSED bytes — the server
1027
+ // decompresses and verifies against them — so only the wire payload
1028
+ // changes. vx artifacts are already-compressed tarballs, but source input
1029
+ // trees are not, and those are the bulk of a remote-execution upload.
1030
+ let chunk = this.chunkBytes
1031
+ for (let attempt = 0; ; attempt++) {
1032
+ const wire = compressed ? Bun.zstdCompressSync(body) : body
1033
+ const wireBytes = wire instanceof Blob ? wire.size : wire.length
1034
+ const segment = compressed
1035
+ ? `compressed-blobs/zstd/${digest.hash}/${digest.size_bytes}`
1036
+ : `blobs/${digest.hash}/${digest.size_bytes}`
1037
+ const resource = `${this.instance ? `${this.instance}/` : ''}uploads/${crypto.randomUUID()}/${segment}`
1038
+ const bounds = this.bounded()
1039
+ try {
1040
+ await this.writeResource(resource, wire, digest, 0, chunk, compressed, bounds)
1041
+ return
1042
+ } catch (err) {
1043
+ const code = (err as grpc.ServiceError).code
1044
+ // ADAPTIVE DOWNGRADE. The Bun node:http2 flow-control defect is a
1045
+ // RACE, not a boundary: chunk sizes above the RFC default initial
1046
+ // window (65535) usually work and occasionally wedge — observed as a
1047
+ // one-off DEADLINE_EXCEEDED at 128 KB on the same Bun that passed it
1048
+ // hundreds of times. A deadline on a multi-message write therefore
1049
+ // retries ONCE at SAFE_CHUNK_BYTES, the size never observed hanging,
1050
+ // instead of failing the task over a lost coin-flip.
1051
+ // Only a MULTI-message write can be the chunk race — a body that fit
1052
+ // one message never exercised flow control between messages, so its
1053
+ // deadline is the server's problem and re-chunking cannot help.
1054
+ // The deadline has TWO spellings: this client's own timer
1055
+ // (DEADLINE_EXCEEDED), or the server's. A grpc-go server (bazel-remote)
1056
+ // arms a timer at the call's `grpc-timeout` and ends the stream with
1057
+ // RST_STREAM(CANCEL), which grpc-js reports as `CANCELLED: Call
1058
+ // cancelled`; whichever lands first names the error, and on a stalled
1059
+ // write the server's usually does (item 1017: 5 of 6 against a grpc-go
1060
+ // server that stopped reading). This client never cancels a write it
1061
+ // reports — its own `cancel()` rethrows the error that caused it — so
1062
+ // a CANCELLED at or past this attempt's deadline IS the deadline. The
1063
+ // header carries `ceil(deadline - now)`, so the server's timer cannot
1064
+ // fire before ours: no slack. An earlier CANCELLED is the server's own.
1065
+ if (
1066
+ (code === grpc.status.DEADLINE_EXCEEDED ||
1067
+ (code === grpc.status.CANCELLED && Date.now() >= deadlineOf(bounds))) &&
1068
+ chunk > SAFE_CHUNK_BYTES &&
1069
+ wireBytes > chunk
1070
+ ) {
1071
+ this.onWarn(
1072
+ `vx/reapi: chunked write of ${digest.hash.slice(0, 12)} hit the ${chunk}-byte chunk stall (Bun http2 flow control); retrying at ${SAFE_CHUNK_BYTES}`,
1073
+ )
1074
+ chunk = SAFE_CHUNK_BYTES
1075
+ continue
1076
+ }
1077
+ const delay = this.retryDelay(attempt, code)
1078
+ if (delay === undefined) throw err
1079
+ if (!compressed) {
1080
+ // Identity path: ask how far the server got and resume there.
1081
+ const status = await this.queryWriteStatus(resource).catch(() => null)
1082
+ if (status?.complete === true) return
1083
+ if (status !== null && status.committedSize > 0 && status.committedSize < wireBytes) {
1084
+ try {
1085
+ await this.writeResource(
1086
+ resource,
1087
+ wire,
1088
+ digest,
1089
+ status.committedSize,
1090
+ chunk,
1091
+ false,
1092
+ this.bounded(),
1093
+ )
1094
+ return
1095
+ } catch {
1096
+ // fall through to a fresh attempt
1097
+ }
1098
+ }
1099
+ }
1100
+ await Bun.sleep(delay)
1101
+ }
1102
+ }
1103
+ }
1104
+
1105
+ /**
1106
+ * One ByteStream `Write` from `startOffset`. Each message waits for the
1107
+ * channel to drain before the next is read, so a streamed `Blob` holds at
1108
+ * most a few messages in memory however large it is.
1109
+ */
1110
+ private async writeResource(
1111
+ resource: string,
1112
+ body: Uint8Array | Blob,
1113
+ digest: Digest,
1114
+ startOffset: number,
1115
+ chunkBytes: number,
1116
+ compressed: boolean,
1117
+ bounds: grpc.CallOptions,
1118
+ ): Promise<void> {
1119
+ const total = body instanceof Blob ? body.size : body.length
1120
+ let stream!: {
1121
+ write(m: unknown): boolean
1122
+ end(): void
1123
+ cancel(): void
1124
+ on(e: string, f: (x: unknown) => void): void
1125
+ once(e: string, f: () => void): void
1126
+ }
1127
+ const done = new Promise<void>((resolve, reject) => {
1128
+ stream = (this.svc.bs as unknown as Record<string, Function>)['write']!(
1129
+ this.meta(),
1130
+ bounds,
1131
+ (err: grpc.ServiceError | null, res: { committed_size?: string }) => {
1132
+ if (err) return reject(err)
1133
+ const committed = Number(res.committed_size ?? 0)
1134
+ // For a compressed upload the server reports the COMPRESSED byte
1135
+ // count it accepted, so the equality only holds on the identity
1136
+ // path; on the compressed path a non-zero commit is the signal.
1137
+ // A server that already holds the blob ends a compressed write
1138
+ // with `-1` (the spec's word for it); read as a short write, it
1139
+ // failed the upload of an input two actions share (F-44).
1140
+ const expected = compressed ? total : digest.size_bytes
1141
+ if (committed !== expected && !(compressed && (committed > 0 || committed === -1))) {
1142
+ return reject(
1143
+ new Error(`reapi: short write for ${digest.hash}: ${committed}/${expected}`),
1144
+ )
1145
+ }
1146
+ resolve()
1147
+ },
1148
+ )
1149
+ stream.on('error', reject)
1150
+ })
1151
+ // Raced against every drain wait and awaited at the end; a failure that
1152
+ // lands between the two is not an unhandled rejection.
1153
+ done.catch(() => undefined)
1154
+ let offset = startOffset
1155
+ let first = true
1156
+ try {
1157
+ for await (const data of messagesOf(body, startOffset, chunkBytes)) {
1158
+ const end = offset + data.length
1159
+ const flowing = stream.write({
1160
+ resource_name: first ? resource : '',
1161
+ write_offset: offset,
1162
+ finish_write: end === total,
1163
+ data,
1164
+ })
1165
+ first = false
1166
+ offset = end
1167
+ if (!flowing) await Promise.race([new Promise<void>((r) => stream.once('drain', r)), done])
1168
+ }
1169
+ // Empty blobs still need one message so the server sees finish_write.
1170
+ if (first) {
1171
+ stream.write({
1172
+ resource_name: resource,
1173
+ write_offset: offset,
1174
+ finish_write: true,
1175
+ data: new Uint8Array(0),
1176
+ })
1177
+ }
1178
+ } catch (err) {
1179
+ // The source failed mid-write (a pruned artifact) or the call did:
1180
+ // either way the half-sent write must not be left open on the channel.
1181
+ stream.cancel()
1182
+ throw err
1183
+ }
1184
+ stream.end()
1185
+ await done
1186
+ }
1187
+
1188
+ /**
1189
+ * Read a blob via ByteStream; `null` on NOT_FOUND. A transient status is
1190
+ * retried as a unary call's is (the read is whole and idempotent): one
1191
+ * UNAVAILABLE reading a finished action's stdout or outputs failed the
1192
+ * task after the action succeeded (item 919).
1193
+ */
1194
+ async readBlob(digest: Digest): Promise<Uint8Array | null> {
1195
+ for (let attempt = 0; ; attempt++) {
1196
+ try {
1197
+ const blob = await this.readBlobOnce(digest)
1198
+ this.unreachable = false
1199
+ return blob
1200
+ } catch (err) {
1201
+ const delay = this.retryDelay(attempt, (err as grpc.ServiceError).code)
1202
+ if (delay === undefined) throw err
1203
+ await Bun.sleep(delay)
1204
+ }
1205
+ }
1206
+ }
1207
+
1208
+ private readBlobOnce(digest: Digest): Promise<Uint8Array | null> {
1209
+ const segment = this.compression
1210
+ ? `compressed-blobs/zstd/${digest.hash}/${digest.size_bytes}`
1211
+ : `blobs/${digest.hash}/${digest.size_bytes}`
1212
+ const resource = `${this.instance ? `${this.instance}/` : ''}${segment}`
1213
+ const compressed = this.compression
1214
+ // Refused as the bytes pass the bound, not once the server stops: a
1215
+ // body that never ends held every chunk it sent (L-3).
1216
+ const bound = compressed ? wireBound(Number(digest.size_bytes)) : Number(digest.size_bytes)
1217
+ return new Promise((resolve, reject) => {
1218
+ const chunks: Uint8Array[] = []
1219
+ let received = 0
1220
+ let over = false
1221
+ const stream = (this.svc.bs as unknown as Record<string, Function>)['read']!(
1222
+ { resource_name: resource, read_offset: 0, read_limit: 0 },
1223
+ this.meta(),
1224
+ this.bounded(),
1225
+ ) as { on(e: string, f: (x: never) => void): void; cancel(): void }
1226
+ stream.on('data', (m: { data: Uint8Array }) => {
1227
+ if (over) return
1228
+ received += m.data.length
1229
+ if (received > bound) {
1230
+ over = true
1231
+ chunks.length = 0
1232
+ reject(overServed(digest))
1233
+ stream.cancel()
1234
+ return
1235
+ }
1236
+ chunks.push(m.data)
1237
+ })
1238
+ stream.on('error', (err: grpc.ServiceError) =>
1239
+ err.code === NOT_FOUND ? resolve(null) : reject(err),
1240
+ )
1241
+ stream.on('end', async () => {
1242
+ if (over) return
1243
+ const total = chunks.reduce((n, c) => n + c.length, 0)
1244
+ const out = new Uint8Array(total)
1245
+ let at = 0
1246
+ for (const c of chunks) {
1247
+ out.set(c, at)
1248
+ at += c.length
1249
+ }
1250
+ try {
1251
+ const body = compressed ? await unzstdBounded(out, digest) : out
1252
+ assertBlobIntegrity(body, digest, this.digestFunction)
1253
+ resolve(body)
1254
+ } catch (err) {
1255
+ reject(err)
1256
+ }
1257
+ })
1258
+ })
1259
+ }
1260
+
1261
+ /**
1262
+ * `readBlob` as a stream: each ByteStream message is taken from the call
1263
+ * only when the reader asks for the next, so a blob of any size costs the
1264
+ * call's small read-ahead in memory. `null` on NOT_FOUND, known from the
1265
+ * first message before the stream is handed over. The digest is checked as
1266
+ * the bytes pass and a mismatch errors the stream at its end, so no reader
1267
+ * reaches the end of a blob that is not the one asked for.
1268
+ * Identity-encoded, as `writeBlob` streams: the blob this reads is a zstd
1269
+ * artifact already.
1270
+ */
1271
+ async readBlobStream(digest: Digest): Promise<ReadableStream<Uint8Array> | null> {
1272
+ const resource = `${this.instance ? `${this.instance}/` : ''}blobs/${digest.hash}/${digest.size_bytes}`
1273
+ type Message = { data: Uint8Array }
1274
+ let call: AsyncIterable<Message> & { cancel(): void }
1275
+ let messages: AsyncIterator<Message>
1276
+ const open = (offset: number): void => {
1277
+ call = (this.svc.bs as unknown as Record<string, Function>)['read']!(
1278
+ { resource_name: resource, read_offset: offset, read_limit: 0 },
1279
+ this.meta(),
1280
+ this.bounded(),
1281
+ ) as AsyncIterable<Message> & { cancel(): void }
1282
+ messages = call[Symbol.asyncIterator]()
1283
+ }
1284
+ const hasher = hasherFor(this.digestFunction)
1285
+ let size = 0
1286
+ let attempt = 0
1287
+ // A transient status is retried as `readBlob`'s is, and not only before
1288
+ // the first message: a cut past it (a proxy's RST, a server's GOAWAY)
1289
+ // re-opens the Read at `read_offset` = the bytes the reader already has,
1290
+ // so the hash carries on over the same byte sequence. The budget is one
1291
+ // for the whole blob. Before the first message a NOT_FOUND is a miss.
1292
+ const take = async (): Promise<IteratorResult<Message>> => {
1293
+ for (;;) {
1294
+ try {
1295
+ return await messages.next()
1296
+ } catch (err) {
1297
+ const code = (err as grpc.ServiceError).code
1298
+ const delay = this.retryDelay(attempt++, code)
1299
+ if (delay === undefined) throw err
1300
+ await Bun.sleep(delay)
1301
+ open(size)
1302
+ }
1303
+ }
1304
+ }
1305
+ open(0)
1306
+ let next: IteratorResult<Message> | undefined
1307
+ try {
1308
+ next = await take()
1309
+ } catch (err) {
1310
+ if ((err as grpc.ServiceError).code === NOT_FOUND) return null
1311
+ throw err
1312
+ }
1313
+ return new ReadableStream<Uint8Array>({
1314
+ pull: async (controller) => {
1315
+ const got = next ?? (await take())
1316
+ next = undefined
1317
+ if (got.done) {
1318
+ assertServed(size, hasher && (() => hasher.digest('hex')), digest)
1319
+ controller.close()
1320
+ return
1321
+ }
1322
+ size += got.value.data.length
1323
+ if (size > Number(digest.size_bytes)) {
1324
+ call.cancel()
1325
+ throw overServed(digest)
1326
+ }
1327
+ hasher?.update(got.value.data)
1328
+ controller.enqueue(got.value.data)
1329
+ },
1330
+ cancel: () => {
1331
+ call.cancel()
1332
+ },
1333
+ })
1334
+ }
1335
+
1336
+ /**
1337
+ * `Execute` — a SERVER-STREAMING call yielding `Operation`s until one is
1338
+ * `done`. Resolves with the terminal operation. If the stream drops
1339
+ * mid-flight with a transient status, or ends before the operation is
1340
+ * done, the call RE-ATTACHES to the same operation through
1341
+ * `WaitExecution` instead of re-running the action — that is exactly what
1342
+ * the RPC exists for.
1343
+ *
1344
+ * `skip_cache_lookup` is TRUE by design: vx has already decided this is a
1345
+ * miss (it owns the cache key and consulted its own layers), so letting the
1346
+ * server re-check its ActionCache would be a second, differently-keyed
1347
+ * cache deciding whether the user's task runs.
1348
+ */
1349
+ async execute(
1350
+ actionDigest: Digest,
1351
+ opts: ExecuteOptions = {},
1352
+ signal?: AbortSignal,
1353
+ ): Promise<Operation> {
1354
+ const req = {
1355
+ instance_name: this.instance,
1356
+ action_digest: actionDigest,
1357
+ skip_cache_lookup: opts.skipCacheLookup ?? true,
1358
+ // Ask the server to INLINE stdout/stderr in the ActionResult. Without
1359
+ // this every finished action costs two extra CAS round trips just to
1360
+ // read what it printed.
1361
+ inline_stdout: opts.inlineStdout ?? true,
1362
+ inline_stderr: opts.inlineStderr ?? true,
1363
+ ...(opts.inlineOutputFiles === undefined
1364
+ ? {}
1365
+ : { inline_output_files: opts.inlineOutputFiles }),
1366
+ ...(opts.priority === undefined ? {} : { execution_policy: { priority: opts.priority } }),
1367
+ ...(opts.resultsCachePriority === undefined
1368
+ ? {}
1369
+ : { results_cache_policy: { priority: opts.resultsCachePriority } }),
1370
+ ...(this.digestFunction === 'SHA256' ? {} : { digest_function: this.digestFunction }),
1371
+ }
1372
+ let operationName = ''
1373
+ for (let attempt = 0; ; attempt++) {
1374
+ let failure: unknown
1375
+ try {
1376
+ const op =
1377
+ operationName === ''
1378
+ ? await this.operationStream('execute', req, signal, opts.onStage, (n) => {
1379
+ operationName = n
1380
+ opts.onOperation?.(n)
1381
+ })
1382
+ : await this.operationStream(
1383
+ 'waitExecution',
1384
+ { name: operationName },
1385
+ signal,
1386
+ opts.onStage,
1387
+ )
1388
+ if (op.done) return op
1389
+ // A stream can END cleanly on an operation still QUEUED or EXECUTING
1390
+ // (a server or proxy cutting long streams); the operation lives on,
1391
+ // so it is re-attached as a dropped stream is, on the same budget.
1392
+ // An unnamed one cannot be re-attached, and a plain Error is not
1393
+ // retried.
1394
+ if (operationName === '') {
1395
+ throw new Error('reapi: execution stream closed before the operation finished')
1396
+ }
1397
+ failure = new Error(
1398
+ `reapi: execution stream for ${operationName} closed before the operation finished`,
1399
+ )
1400
+ } catch (err) {
1401
+ const code = (err as grpc.ServiceError).code
1402
+ // WaitExecution's NOT_FOUND: the server no longer knows the operation
1403
+ // (a restart lost it). Nothing is running to re-attach to, and the
1404
+ // action is the same bytes, so it is executed again, on the same
1405
+ // budget — as Bazel's executor does.
1406
+ if (code === NOT_FOUND && operationName !== '') operationName = ''
1407
+ else if (!isRetryable(code)) throw err
1408
+ failure = err
1409
+ }
1410
+ const delay = RETRY_DELAYS_MS[attempt]
1411
+ if (delay === undefined) throw failure
1412
+ await abortableSleep(delay, signal)
1413
+ }
1414
+ }
1415
+
1416
+ /**
1417
+ * `Operations.CancelOperation`: closing the Execute stream leaves a queued
1418
+ * action to the server, which may still run it. One attempt on the
1419
+ * control-plane deadline: a cancel is a courtesy, and a server without
1420
+ * the service answers UNIMPLEMENTED.
1421
+ */
1422
+ cancelOperation(name: string): Promise<void> {
1423
+ return new Promise((resolve, reject) => {
1424
+ ;(this.svc.ops as unknown as Record<string, Function>)['cancelOperation']!(
1425
+ { name },
1426
+ this.meta(),
1427
+ this.boundedMeta(),
1428
+ (err: grpc.ServiceError | null) => (err ? reject(err) : resolve()),
1429
+ )
1430
+ })
1431
+ }
1432
+
1433
+ /** Re-attach to an in-flight operation after a disconnect. */
1434
+ waitExecution(
1435
+ operationName: string,
1436
+ signal?: AbortSignal,
1437
+ onStage?: (stage: string) => void,
1438
+ ): Promise<Operation> {
1439
+ return this.operationStream('waitExecution', { name: operationName }, signal, onStage)
1440
+ }
1441
+
1442
+ private operationStream(
1443
+ method: string,
1444
+ req: unknown,
1445
+ signal?: AbortSignal,
1446
+ onStage?: (stage: string) => void,
1447
+ onName?: (name: string) => void,
1448
+ ): Promise<Operation> {
1449
+ return new Promise((resolve, reject) => {
1450
+ // An aborted signal never fires `abort` again, and the stream has no
1451
+ // deadline of its own (time QUEUED is unbounded), so a listener added
1452
+ // after the abort would wait on a wedged server forever.
1453
+ if (signal?.aborted === true) return reject(executionAborted())
1454
+ const stream = (this.svc.exec as unknown as Record<string, Function>)[method]!(
1455
+ req,
1456
+ this.meta(),
1457
+ ) as { on(e: string, f: (x: never) => void): void; cancel(): void }
1458
+ let last: Operation | undefined
1459
+ const onAbort = (): void => {
1460
+ stream.cancel()
1461
+ reject(executionAborted())
1462
+ }
1463
+ signal?.addEventListener('abort', onAbort, { once: true })
1464
+ stream.on('data', (op: Operation) => {
1465
+ last = op
1466
+ if (op.name !== '' && op.name !== undefined && onName !== undefined) onName(op.name)
1467
+ // ExecuteOperationMetadata carries the action's STAGE
1468
+ // (QUEUED / EXECUTING / COMPLETED). Surfacing it is the difference
1469
+ // between "vx is hung" and "the action is queued behind 40 others".
1470
+ if (onStage !== undefined && op.metadata?.value !== undefined) {
1471
+ const stage = decodeStage(op.metadata.value)
1472
+ if (stage !== undefined) onStage(stage)
1473
+ }
1474
+ })
1475
+ stream.on('error', (err: grpc.ServiceError) => {
1476
+ signal?.removeEventListener('abort', onAbort)
1477
+ reject(err)
1478
+ })
1479
+ stream.on('end', () => {
1480
+ signal?.removeEventListener('abort', onAbort)
1481
+ if (last === undefined)
1482
+ return reject(new Error('reapi: execution stream closed with no operation'))
1483
+ resolve(last)
1484
+ })
1485
+ })
1486
+ }
1487
+
1488
+ /**
1489
+ * `SplitBlob` — ask the server to content-defined-chunk a blob and return
1490
+ * the chunk digests. With `FindMissingBlobs` over those chunks, a client
1491
+ * transfers only the parts of a large blob it does not already hold. Gated
1492
+ * by `split_blob_support`; experimental, so callers must check first.
1493
+ */
1494
+ async splitBlob(blobDigest: Digest): Promise<{ chunks: Digest[]; chunkingFunction: string }> {
1495
+ const res = await this.unary<{ chunk_digests?: Digest[]; chunking_function?: string }>(
1496
+ this.svc.cas,
1497
+ 'splitBlob',
1498
+ {
1499
+ instance_name: this.instance,
1500
+ blob_digest: blobDigest,
1501
+ ...(this.digestFunction === 'SHA256' ? {} : { digest_function: this.digestFunction }),
1502
+ },
1503
+ this.meta(),
1504
+ this.bounded(),
1505
+ )
1506
+ return { chunks: res.chunk_digests ?? [], chunkingFunction: res.chunking_function ?? 'UNKNOWN' }
1507
+ }
1508
+
1509
+ /**
1510
+ * `SpliceBlob` — the inverse: hand the server an ordered chunk list and it
1511
+ * reassembles the blob in CAS, so the client never uploads the parts it
1512
+ * already knows are there. Gated by `splice_blob_support`.
1513
+ */
1514
+ async spliceBlob(chunkDigests: readonly Digest[], expected?: Digest): Promise<Digest> {
1515
+ const res = await this.unary<{ blob_digest?: Digest }>(
1516
+ this.svc.cas,
1517
+ 'spliceBlob',
1518
+ {
1519
+ instance_name: this.instance,
1520
+ chunk_digests: chunkDigests,
1521
+ ...(expected === undefined ? {} : { blob_digest: expected }),
1522
+ ...(this.digestFunction === 'SHA256' ? {} : { digest_function: this.digestFunction }),
1523
+ },
1524
+ this.meta(),
1525
+ this.bounded(),
1526
+ )
1527
+ return res.blob_digest ?? { hash: '', size_bytes: 0 }
1528
+ }
1529
+
1530
+ /**
1531
+ * `GetTree` — every Directory under `rootDigest`. The RPC is
1532
+ * SERVER-STREAMING: one call carries every page, and `next_page_token`
1533
+ * only names where a later call would resume. It went through the unary
1534
+ * helper before (item 824), whose callback a streaming stub never calls,
1535
+ * so every call hung until its deadline passed unheard.
1536
+ */
1537
+ async getTree(rootDigest: Digest): Promise<Directory[]> {
1538
+ const call = (this.svc.cas as unknown as Record<string, Function>)['getTree']!(
1539
+ { instance_name: this.instance, root_digest: rootDigest, page_token: '' },
1540
+ this.meta(),
1541
+ this.bounded(),
1542
+ ) as AsyncIterable<{ directories?: Directory[] }>
1543
+ const out: Directory[] = []
1544
+ for await (const page of call) out.push(...(page.directories ?? []))
1545
+ return out
1546
+ }
1547
+
1548
+ /**
1549
+ * Closing the channel-owning stub tears the connection down; the others
1550
+ * borrow it, so closing them too would double-close one channel.
1551
+ */
1552
+ close(): void {
1553
+ this.svc.cas.close()
1554
+ }
1555
+ }
1556
+
1557
+ /** google.longrunning.Operation, narrowed to what Execute returns. */
1558
+ export interface Operation {
1559
+ name: string
1560
+ done: boolean
1561
+ /** `google.rpc.Status` when the EXECUTION ITSELF failed (not a non-zero exit). */
1562
+ error?: { code?: number; message?: string }
1563
+ /** `ExecuteResponse`, packed in an Any. */
1564
+ response?: { type_url?: string; value?: Uint8Array }
1565
+ /** `ExecuteOperationMetadata`, packed in an Any. */
1566
+ metadata?: { type_url?: string; value?: Uint8Array }
1567
+ }
1568
+
1569
+ export interface ExecuteOptions {
1570
+ /** The operation's name, once the server has given one. */
1571
+ onOperation?: (name: string) => void
1572
+ /** Default TRUE: vx owns the cache decision, so the server must not re-check its own AC. */
1573
+ skipCacheLookup?: boolean
1574
+ inlineStdout?: boolean
1575
+ inlineStderr?: boolean
1576
+ /** Output files the server should inline in the result, sparing a fetch. */
1577
+ inlineOutputFiles?: readonly string[]
1578
+ /** `ExecutionPolicy.priority` — lower runs sooner. */
1579
+ priority?: number
1580
+ /** `ResultsCachePolicy.priority` — retention hint for the result. */
1581
+ resultsCachePriority?: number
1582
+ /** QUEUED / EXECUTING / COMPLETED as the operation progresses. */
1583
+ onStage?: (stage: string) => void
1584
+ }
1585
+
1586
+ const EXEC_STAGE = ['UNKNOWN', 'CACHE_CHECK', 'QUEUED', 'EXECUTING', 'COMPLETED'] as const
1587
+
1588
+ /** `ExecuteOperationMetadata { stage = 1 (enum), action_digest = 2, ... }` */
1589
+ function decodeStage(buf: Uint8Array): string | undefined {
1590
+ let i = 0
1591
+ while (i < buf.length) {
1592
+ let key = 0
1593
+ let shift = 0
1594
+ for (;;) {
1595
+ const b = buf[i++]
1596
+ if (b === undefined) return undefined
1597
+ key |= (b & 0x7f) << shift
1598
+ if ((b & 0x80) === 0) break
1599
+ shift += 7
1600
+ }
1601
+ const field = key >>> 3
1602
+ const wire = key & 7
1603
+ if (wire === 0) {
1604
+ let v = 0
1605
+ let sh = 0
1606
+ for (;;) {
1607
+ const b = buf[i++]
1608
+ if (b === undefined) return undefined
1609
+ v |= (b & 0x7f) << sh
1610
+ if ((b & 0x80) === 0) break
1611
+ sh += 7
1612
+ }
1613
+ if (field === 1) return EXEC_STAGE[v] ?? `STAGE_${v}`
1614
+ } else if (wire === 2) {
1615
+ let len = 0
1616
+ let sh = 0
1617
+ for (;;) {
1618
+ const b = buf[i++]
1619
+ if (b === undefined) return undefined
1620
+ len |= (b & 0x7f) << sh
1621
+ if ((b & 0x80) === 0) break
1622
+ sh += 7
1623
+ }
1624
+ i += len
1625
+ } else break
1626
+ }
1627
+ return undefined
1628
+ }
1629
+
1630
+ export interface ExecuteResponse {
1631
+ result?: ActionResult
1632
+ cached_result?: boolean
1633
+ status?: { code?: number; message?: string }
1634
+ message?: string
1635
+ }
1636
+
1637
+ /** The subset of REAPI's ActionResult a cache entry uses. */
1638
+ export interface ActionResult {
1639
+ exit_code?: number
1640
+ output_files?: Array<{
1641
+ path: string
1642
+ digest: Digest
1643
+ is_executable?: boolean
1644
+ contents?: Uint8Array
1645
+ }>
1646
+ output_directories?: Array<{ path: string; tree_digest: Digest }>
1647
+ output_symlinks?: Array<{ path: string; target: string }>
1648
+ /** Servers MAY normalise inline stdout/stderr into CAS and return digests instead. */
1649
+ stdout_digest?: Digest
1650
+ stdout_raw?: Uint8Array
1651
+ stderr_digest?: Digest
1652
+ stderr_raw?: Uint8Array
1653
+ execution_metadata?: {
1654
+ worker?: string
1655
+ execution_start_timestamp?: unknown
1656
+ execution_completed_timestamp?: unknown
1657
+ }
1658
+ }