@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.
@@ -0,0 +1,1805 @@
1
+ // The `executor` capability: run ONE task's command on a REAPI worker.
2
+ //
3
+ // The shape core hands over (`ExecuteRequest`) is already fully resolved —
4
+ // command, cwd, env, declared inputs WITH values, declared output globs — so
5
+ // this module's job is purely translation: inputs → Merkle tree, command →
6
+ // `Command`, the pair → `Action`, then `Execute` and materialise the outputs.
7
+ //
8
+ // Placement (which tasks may come here at all) is core's: a persistent task,
9
+ // anything depending on one, and `exec.remote: false` never reach an
10
+ // executor. This declines the rest of what it cannot honour.
11
+
12
+ import { mkdir, writeFile, chmod, readlink, realpath, rm, symlink, unlink } from 'node:fs/promises'
13
+ import { constants, existsSync } from 'node:fs'
14
+ import path from 'node:path'
15
+ import { executorFallback, isLiteralPattern, isUserError, normalizeGlob, UserError } from '@vzn/vx'
16
+ import type { ExecuteRequest, ExecuteResult, TaskExecutor, TaskPlacement } from '@vzn/vx'
17
+ import {
18
+ buildInputTree,
19
+ decodeTreeWithBytes,
20
+ digestWith,
21
+ encodeAction,
22
+ encodeCommand,
23
+ encodeTree,
24
+ mapBounded,
25
+ sha256,
26
+ type Blob,
27
+ type FileGraft,
28
+ type TreeGraft,
29
+ } from './merkle.js'
30
+ import { execDigestFor } from './cache.js'
31
+ import type { ActionResult, Digest, Directory, Operation, ReapiClient } from './wire.js'
32
+
33
+ /**
34
+ * Split a coarse output directory into the paths its GLOB actually names.
35
+ *
36
+ * REAPI `output_paths` are literal, so a glob with a wildcard in the MIDDLE
37
+ * (`packages/*/node_modules`) has to be requested as its static prefix
38
+ * (`packages`) — and the worker duly returns that whole directory, manifests
39
+ * and all. Grafting THAT into a consumer replaces its entire `packages/`,
40
+ * taking every project's sources with it.
41
+ *
42
+ * The producer is the one place that knows the glob, so it decomposes here,
43
+ * on the way INTO the execution record: one entry per real match, each a Tree
44
+ * of its own. A consumer then grafts `packages/vx/node_modules` and nothing
45
+ * else, and replacement stays exactly what it was — it just lands at the path
46
+ * the user actually declared.
47
+ *
48
+ * The glob's LAST segment matches files and symlinks as well as directories,
49
+ * as the local glob does (`dist/*` saves `dist/index.js`): those become
50
+ * output files and symlinks of the record, or a replay drops them and core
51
+ * saves the short tree under the pure-input key.
52
+ *
53
+ * Nothing reads the submitter's filesystem: the matches come from the tree the
54
+ * worker returned, so the record is a function of the action's own result.
55
+ */
56
+ async function decomposeOutputDir(
57
+ client: ReapiClient,
58
+ entry: { path: string; tree_digest: Digest },
59
+ globs: readonly string[],
60
+ warn: (m: string) => void,
61
+ ): Promise<DecomposedOutputs> {
62
+ const whole: DecomposedOutputs = { directories: [entry], files: [], symlinks: [] }
63
+ // Only globs that COLLAPSED to this entry are interesting; a literal glob
64
+ // already names its own path.
65
+ const wild = globs
66
+ .filter((g) => globToOutputPath(g) === entry.path && g !== entry.path)
67
+ .map((g) => g.slice(entry.path.length + 1).split('/'))
68
+ if (wild.length === 0) return whole
69
+ // Only whole-segment wildcards are walked. One glob the walk cannot follow
70
+ // (`*.js`, `**`) keeps the entry whole: splitting for its siblings would
71
+ // record their matches and drop its own.
72
+ if (wild.some((rest) => rest.some((seg) => seg !== '*' && !isLiteralPattern(seg)))) return whole
73
+
74
+ const blob = await client.readBlob(entry.tree_digest)
75
+ if (blob === null) {
76
+ warn(`vx/reapi: could not read the Tree for ${entry.path} — recording it whole`)
77
+ return whole
78
+ }
79
+ // Keyed by the WORKER's own bytes, not by re-encoding our parse of them —
80
+ // see decodeTreeWithBytes. Re-encoding resolved 4 of 649 directories here.
81
+ const tree = decodeTreeWithBytes(blob)
82
+ if (tree.root === undefined) return whole
83
+ const byDigest = new Map<string, Directory>()
84
+ tree.children.forEach((c, i) => byDigest.set(tree.childDigests[i]!, c))
85
+
86
+ const out: Array<{ path: string; tree_digest: Digest }> = []
87
+ const files: DecomposedOutputs['files'] = []
88
+ const symlinks: DecomposedOutputs['symlinks'] = []
89
+ const seen = new Set<string>()
90
+
91
+ // Every transitive child of `dir`, which is what a Tree message must carry.
92
+ const descendants = (dir: Directory, acc: Directory[] = []): Directory[] => {
93
+ for (const d of dir.directories) {
94
+ const child = byDigest.get(d.digest.hash)
95
+ if (child === undefined) continue
96
+ acc.push(child)
97
+ descendants(child, acc)
98
+ }
99
+ return acc
100
+ }
101
+
102
+ const walk = (dir: Directory, segments: readonly string[], prefix: string): void => {
103
+ if (segments.length === 0) {
104
+ if (seen.has(prefix)) return
105
+ seen.add(prefix)
106
+ const data = encodeTree(dir, descendants(dir))
107
+ out.push({ path: prefix, tree_digest: sha256(data), __data: data } as never)
108
+ return
109
+ }
110
+ const [head, ...rest] = segments
111
+ const matches = (name: string): boolean => head === '*' || name === head
112
+ if (rest.length === 0) {
113
+ for (const f of dir.files) {
114
+ const at = `${prefix}/${f.name}`
115
+ if (!matches(f.name) || seen.has(at)) continue
116
+ seen.add(at)
117
+ files.push({ path: at, digest: f.digest, is_executable: f.is_executable })
118
+ }
119
+ for (const sl of dir.symlinks) {
120
+ const at = `${prefix}/${sl.name}`
121
+ if (!matches(sl.name) || seen.has(at)) continue
122
+ seen.add(at)
123
+ symlinks.push({ path: at, target: sl.target })
124
+ }
125
+ }
126
+ for (const d of dir.directories) {
127
+ if (!matches(d.name)) continue
128
+ const child = byDigest.get(d.digest.hash)
129
+ if (child === undefined) continue
130
+ walk(child, rest, `${prefix}/${d.name}`)
131
+ }
132
+ }
133
+
134
+ for (const rest of wild) walk(tree.root, rest, entry.path)
135
+ if (out.length + files.length + symlinks.length === 0) return whole
136
+
137
+ // Upload the Tree blobs the new entries point at: one probe for all, the
138
+ // small ones batched, one past the batch limit streamed. One probe and
139
+ // write per entry cost 100 packages 3.4 s through 15 ms each way (F-31).
140
+ // A failed probe or batch still uploads: each blob is written.
141
+ const blobs = out.map((e) => ({
142
+ digest: e.tree_digest,
143
+ data: (e as unknown as { __data: Uint8Array }).__data,
144
+ }))
145
+ await client.uploadBlobs(blobs).catch(async () => {
146
+ for (const b of blobs) await client.writeBlob(b.digest, b.data)
147
+ })
148
+ return {
149
+ directories: out.map((e) => ({ path: e.path, tree_digest: e.tree_digest })),
150
+ files,
151
+ symlinks,
152
+ }
153
+ }
154
+
155
+ interface DecomposedOutputs {
156
+ directories: Array<{ path: string; tree_digest: Digest }>
157
+ files: Array<{ path: string; digest: Digest; is_executable: boolean }>
158
+ symlinks: Array<{ path: string; target: string }>
159
+ }
160
+
161
+ /** `ExecuteRequest.inputs` past the executor's own undefined guard. Derived
162
+ * rather than imported: the façade does not export `TaskInputs`, and widening
163
+ * it for one alias is the speculative widening the project rejects. */
164
+ type DescribedInputs = NonNullable<ExecuteRequest['inputs']>
165
+
166
+ /** Message text from an unknown throw, for a warning line. */
167
+ function errText(err: unknown): string {
168
+ return err instanceof Error ? err.message.split('\n')[0]! : String(err)
169
+ }
170
+
171
+ interface DecodedExecuteResponse {
172
+ result?: ActionResult
173
+ message?: string
174
+ cachedResult?: boolean
175
+ status?: { code: number; message: string }
176
+ /** name → log blob; fetched and surfaced when the action FAILS. */
177
+ serverLogs: Array<{ name: string; digest: Digest; humanReadable: boolean }>
178
+ }
179
+
180
+ /** REAPI's ExecuteResponse arrives packed in an Any; hand-decoded (proto-loader
181
+ * exposes no decoder for an arbitrary packed type). */
182
+ function decodeExecuteResponse(op: Operation): DecodedExecuteResponse {
183
+ const value = op.response?.value
184
+ if (value === undefined || value.length === 0) return { serverLogs: [] }
185
+ return decodeExecuteResponseBytes(value)
186
+ }
187
+
188
+ /**
189
+ * `ExecuteResponse { result = 1, cached_result = 2, status = 3,
190
+ * server_logs = 4 (map<string, LogFile>), message = 5 }`
191
+ */
192
+ export function decodeExecuteResponseBytes(buf: Uint8Array): DecodedExecuteResponse {
193
+ const out: DecodedExecuteResponse = { serverLogs: [] }
194
+ let i = 0
195
+ while (i < buf.length) {
196
+ const [key, k] = readVarint(buf, i)
197
+ i = k
198
+ const field = key >>> 3
199
+ const wire = key & 7
200
+ if (wire === 2) {
201
+ const [len, l] = readVarint(buf, i)
202
+ i = l
203
+ const slice = buf.subarray(i, i + len)
204
+ i += len
205
+ if (field === 1) out.result = decodeActionResult(slice)
206
+ else if (field === 3) out.status = decodeRpcStatus(slice)
207
+ else if (field === 4) {
208
+ const entry = decodeLogEntry(slice)
209
+ if (entry !== undefined) out.serverLogs.push(entry)
210
+ } else if (field === 5) out.message = new TextDecoder().decode(slice)
211
+ } else if (wire === 0) {
212
+ const [v, n] = readVarint(buf, i)
213
+ i = n
214
+ if (field === 2) out.cachedResult = v === 1
215
+ } else if (wire === 5) i += 4
216
+ else if (wire === 1) i += 8
217
+ else break
218
+ }
219
+ return out
220
+ }
221
+
222
+ /** `google.rpc.Status { code = 1, message = 2 }` */
223
+ function decodeRpcStatus(buf: Uint8Array): { code: number; message: string } {
224
+ const st = { code: 0, message: '' }
225
+ let i = 0
226
+ while (i < buf.length) {
227
+ const [key, k] = readVarint(buf, i)
228
+ i = k
229
+ const field = key >>> 3
230
+ const wire = key & 7
231
+ if (wire === 0) {
232
+ const [v, n] = readVarint(buf, i)
233
+ i = n
234
+ if (field === 1) st.code = v
235
+ } else if (wire === 2) {
236
+ const [len, l] = readVarint(buf, i)
237
+ i = l
238
+ if (field === 2) st.message = new TextDecoder().decode(buf.subarray(i, i + len))
239
+ i += len
240
+ } else break
241
+ }
242
+ return st
243
+ }
244
+
245
+ /** One `server_logs` map entry: `{ key = 1 (string), value = 2 (LogFile{digest=1, human_readable=2}) }` */
246
+ function decodeLogEntry(
247
+ buf: Uint8Array,
248
+ ): { name: string; digest: Digest; humanReadable: boolean } | undefined {
249
+ let name = ''
250
+ let digest: Digest | undefined
251
+ let humanReadable = false
252
+ let i = 0
253
+ while (i < buf.length) {
254
+ const [key, k] = readVarint(buf, i)
255
+ i = k
256
+ if ((key & 7) !== 2) break
257
+ const [len, l] = readVarint(buf, i)
258
+ i = l
259
+ const slice = buf.subarray(i, i + len)
260
+ i += len
261
+ if (key >>> 3 === 1) name = new TextDecoder().decode(slice)
262
+ else if (key >>> 3 === 2) {
263
+ let j = 0
264
+ while (j < slice.length) {
265
+ const [k2, j2] = readVarint(slice, j)
266
+ j = j2
267
+ if ((k2 & 7) === 2) {
268
+ const [len2, j3] = readVarint(slice, j)
269
+ j = j3
270
+ if (k2 >>> 3 === 1) digest = decodeDigest(slice.subarray(j, j + len2))
271
+ j += len2
272
+ } else if ((k2 & 7) === 0) {
273
+ const [v, j3] = readVarint(slice, j)
274
+ j = j3
275
+ if (k2 >>> 3 === 2) humanReadable = v === 1
276
+ } else break
277
+ }
278
+ }
279
+ }
280
+ return digest === undefined ? undefined : { name, digest, humanReadable }
281
+ }
282
+
283
+ function decodeActionResult(buf: Uint8Array): ActionResult {
284
+ const res: ActionResult = {}
285
+ const legacyLinks: { path: string; target: string }[] = []
286
+ let i = 0
287
+ while (i < buf.length) {
288
+ const [key, k] = readVarint(buf, i)
289
+ i = k
290
+ const field = key >>> 3
291
+ const wire = key & 7
292
+ if (wire === 0) {
293
+ const [v, n] = readVarint(buf, i)
294
+ i = n
295
+ if (field === 4) res.exit_code = v | 0
296
+ } else if (wire === 2) {
297
+ const [len, l] = readVarint(buf, i)
298
+ i = l
299
+ const slice = buf.subarray(i, i + len)
300
+ i += len
301
+ // Field numbers TRANSCRIBED FROM THE PROTO, not from memory — the
302
+ // first version of this decoder guessed them and read output_files
303
+ // (2) as output_directories, stdout_raw (5) as a digest, and so on:
304
+ // a decoder that parses garbage without ever erroring.
305
+ if (field === 2) (res.output_files ??= []).push(decodeOutputFile(slice))
306
+ else if (field === 3) (res.output_directories ??= []).push(decodeOutputDirectory(slice))
307
+ else if (field === 5) res.stdout_raw = slice
308
+ else if (field === 6) res.stdout_digest = decodeDigest(slice)
309
+ else if (field === 7) res.stderr_raw = slice
310
+ else if (field === 8) res.stderr_digest = decodeDigest(slice)
311
+ else if (field === 9) res.execution_metadata = decodeExecutedActionMetadata(slice)
312
+ else if (field === 12) (res.output_symlinks ??= []).push(decodeOutputSymlink(slice))
313
+ // output_file_symlinks (10) and output_directory_symlinks (11): all a
314
+ // v2.0 server sends, and a v2.1 server repeats them beside field 12 (F-48).
315
+ else if (field === 10 || field === 11) legacyLinks.push(decodeOutputSymlink(slice))
316
+ } else if (wire === 5) i += 4
317
+ else if (wire === 1) i += 8
318
+ else break
319
+ }
320
+ if (res.output_symlinks === undefined && legacyLinks.length > 0) res.output_symlinks = legacyLinks
321
+ return res
322
+ }
323
+
324
+ /** `OutputFile { path = 1, digest = 2, is_executable = 4, contents = 5 }` —
325
+ * `contents` is populated when the request named the file in
326
+ * `inline_output_files`, sparing a CAS fetch. */
327
+ function decodeOutputFile(buf: Uint8Array): {
328
+ path: string
329
+ digest: Digest
330
+ is_executable?: boolean
331
+ contents?: Uint8Array
332
+ } {
333
+ const out: { path: string; digest: Digest; is_executable: boolean; contents?: Uint8Array } = {
334
+ path: '',
335
+ digest: { hash: '', size_bytes: 0 },
336
+ is_executable: false,
337
+ }
338
+ let i = 0
339
+ while (i < buf.length) {
340
+ const [key, k] = readVarint(buf, i)
341
+ i = k
342
+ const field = key >>> 3
343
+ const wire = key & 7
344
+ if (wire === 2) {
345
+ const [len, l] = readVarint(buf, i)
346
+ i = l
347
+ const slice = buf.subarray(i, i + len)
348
+ i += len
349
+ if (field === 1) out.path = new TextDecoder().decode(slice)
350
+ else if (field === 2) out.digest = decodeDigest(slice)
351
+ else if (field === 5 && len > 0) out.contents = slice
352
+ } else if (wire === 0) {
353
+ const [v, n] = readVarint(buf, i)
354
+ i = n
355
+ if (field === 4) out.is_executable = v === 1
356
+ } else break
357
+ }
358
+ return out
359
+ }
360
+
361
+ /** `OutputSymlink { path = 1, target = 2 }` */
362
+ function decodeOutputSymlink(buf: Uint8Array): { path: string; target: string } {
363
+ const out = { path: '', target: '' }
364
+ let i = 0
365
+ while (i < buf.length) {
366
+ const [key, k] = readVarint(buf, i)
367
+ i = k
368
+ if ((key & 7) !== 2) break
369
+ const [len, l] = readVarint(buf, i)
370
+ i = l
371
+ const slice = buf.subarray(i, i + len)
372
+ i += len
373
+ if (key >>> 3 === 1) out.path = new TextDecoder().decode(slice)
374
+ else if (key >>> 3 === 2) out.target = new TextDecoder().decode(slice)
375
+ }
376
+ return out
377
+ }
378
+
379
+ /**
380
+ * `ExecutedActionMetadata { worker = 1, queued_timestamp = 2,
381
+ * worker_start = 3, worker_completed = 4, input_fetch_start = 5,
382
+ * input_fetch_completed = 6, execution_start = 7, execution_completed = 8 }`
383
+ * Timestamps decode to epoch seconds — enough for phase attribution.
384
+ */
385
+ function decodeExecutedActionMetadata(
386
+ buf: Uint8Array,
387
+ ): NonNullable<ActionResult['execution_metadata']> {
388
+ const meta: NonNullable<ActionResult['execution_metadata']> = {}
389
+ let i = 0
390
+ while (i < buf.length) {
391
+ const [key, k] = readVarint(buf, i)
392
+ i = k
393
+ const field = key >>> 3
394
+ const wire = key & 7
395
+ if (wire === 2) {
396
+ const [len, l] = readVarint(buf, i)
397
+ i = l
398
+ const slice = buf.subarray(i, i + len)
399
+ i += len
400
+ if (field === 1) meta.worker = new TextDecoder().decode(slice)
401
+ else if (field === 7) meta.execution_start_timestamp = decodeTimestamp(slice)
402
+ else if (field === 8) meta.execution_completed_timestamp = decodeTimestamp(slice)
403
+ } else if (wire === 0) {
404
+ const [, n] = readVarint(buf, i)
405
+ i = n
406
+ } else break
407
+ }
408
+ return meta
409
+ }
410
+
411
+ /** `google.protobuf.Timestamp { seconds = 1, nanos = 2 }` */
412
+ function decodeTimestamp(buf: Uint8Array): { seconds?: string; nanos?: number } {
413
+ const ts: { seconds?: string; nanos?: number } = {}
414
+ let i = 0
415
+ while (i < buf.length) {
416
+ const [key, k] = readVarint(buf, i)
417
+ i = k
418
+ if ((key & 7) !== 0) break
419
+ const [v, n] = readVarint(buf, i)
420
+ i = n
421
+ if (key >>> 3 === 1) ts.seconds = String(v)
422
+ else if (key >>> 3 === 2) ts.nanos = v
423
+ }
424
+ return ts
425
+ }
426
+
427
+ /** `OutputDirectory { path = 1, tree_digest = 3, is_topologically_sorted = 4,
428
+ * root_directory_digest = 5 }` — field 2 is RESERVED,
429
+ * which is exactly the trap a from-memory decoder falls into. */
430
+ function decodeOutputDirectory(buf: Uint8Array): { path: string; tree_digest: Digest } {
431
+ const out = { path: '', tree_digest: { hash: '', size_bytes: 0 } }
432
+ let i = 0
433
+ while (i < buf.length) {
434
+ const [key, k] = readVarint(buf, i)
435
+ i = k
436
+ const field = key >>> 3
437
+ const wire = key & 7
438
+ if (wire === 0) {
439
+ const [, n] = readVarint(buf, i)
440
+ i = n
441
+ continue
442
+ }
443
+ if (wire !== 2) break
444
+ const [len, l] = readVarint(buf, i)
445
+ i = l
446
+ const slice = buf.subarray(i, i + len)
447
+ i += len
448
+ if (field === 1) out.path = new TextDecoder().decode(slice)
449
+ else if (field === 3) out.tree_digest = decodeDigest(slice)
450
+ }
451
+ return out
452
+ }
453
+
454
+ function decodeDigest(buf: Uint8Array): Digest {
455
+ const d: Digest = { hash: '', size_bytes: 0 }
456
+ let i = 0
457
+ while (i < buf.length) {
458
+ const [key, k] = readVarint(buf, i)
459
+ i = k
460
+ const field = key >>> 3
461
+ const wire = key & 7
462
+ if (wire === 2) {
463
+ const [len, l] = readVarint(buf, i)
464
+ i = l
465
+ if (field === 1) d.hash = new TextDecoder().decode(buf.subarray(i, i + len))
466
+ i += len
467
+ } else if (wire === 0) {
468
+ const [v, n] = readVarint(buf, i)
469
+ i = n
470
+ if (field === 2) d.size_bytes = v
471
+ } else break
472
+ }
473
+ return d
474
+ }
475
+
476
+ function readVarint(buf: Uint8Array, at: number): [number, number] {
477
+ let result = 0
478
+ let low = 0
479
+ let shift = 0
480
+ let i = at
481
+ for (;;) {
482
+ const byte = buf[i++]
483
+ if (byte === undefined) break
484
+ // Added, not OR-ed: a size of 4 GiB or more wrapped to 32 bits (F-43).
485
+ // Past 2^53 the value is a negative int32 sent as ten bytes (an exit
486
+ // code): its low 32 bits, which `| 0` reads back as the negative.
487
+ result += (byte & 0x7f) * 2 ** shift
488
+ if (shift < 32) low |= (byte & 0x7f) << shift
489
+ if ((byte & 0x80) === 0) break
490
+ shift += 7
491
+ }
492
+ return [result <= Number.MAX_SAFE_INTEGER ? result : low >>> 0, i]
493
+ }
494
+
495
+ /**
496
+ * Backstop for an action the server never finishes. Generous on purpose: it
497
+ * exists so a wedged remote cannot hang a run forever, not to police slow
498
+ * builds — a task that knows its own bound should declare `exec.timeout`,
499
+ * which takes precedence.
500
+ */
501
+ const DEFAULT_EXECUTE_TIMEOUT_MS = 600_000
502
+
503
+ export interface ReapiExecutorOptions {
504
+ /**
505
+ * Client-side bound on a single action, measured from the EXECUTING
506
+ * transition (time spent QUEUED behind a busy pool is legitimate and is not
507
+ * counted). Overridden per task by `exec.timeout`. Defaults to 10 minutes.
508
+ */
509
+ executeTimeoutMs?: number
510
+ /**
511
+ * Bound on the time an action waits for a worker: from Execute until the
512
+ * EXECUTING transition. Past it the operation is cancelled and the
513
+ * task is given back to run on this machine, said once (B-100). Unset, an
514
+ * action waits for a worker as long as the task's own `exec.timeout`
515
+ * lets it (core's, from the request), and with neither, without bound.
516
+ */
517
+ queueTimeoutMs?: number
518
+ /** REAPI platform properties (`container-image`, `OSFamily`, …). */
519
+ platform?: Record<string, string>
520
+ /** How many tasks this executor runs at once; becomes the scheduler's pool. */
521
+ capacity?: number
522
+ /** `ExecutionPolicy.priority` — lower runs sooner on a contended pool. */
523
+ priority?: number
524
+ /** `Action.salt` — force distinct cache entries without changing the work. */
525
+ salt?: string
526
+ warn?: (message: string) => void
527
+ }
528
+
529
+ /**
530
+ * A task is remote-eligible only if vx can DESCRIBE its inputs — that is the
531
+ * miss path of a cacheable task. A task with no `cache` block ships nothing,
532
+ * so a worker would run it against an empty input root and produce garbage:
533
+ * decline it and let a later executor (the local one) take it.
534
+ */
535
+ export function acceptsTask(task: TaskPlacement): boolean {
536
+ return task.cacheable && !task.pinnedLocal
537
+ }
538
+
539
+ export function reapiExecutor(client: ReapiClient, opts: ReapiExecutorOptions = {}): TaskExecutor {
540
+ // A zero or non-number bound stopped every action the moment it began
541
+ // executing.
542
+ for (const key of ['executeTimeoutMs', 'queueTimeoutMs'] as const) {
543
+ const ms = opts[key]
544
+ if (ms !== undefined && !(typeof ms === 'number' && Number.isFinite(ms) && ms > 0)) {
545
+ throw new Error(
546
+ `@vzn/vx-reapi: ${key} must be a positive number of ms (got ${JSON.stringify(ms)})`,
547
+ )
548
+ }
549
+ }
550
+ const warn = opts.warn ?? (() => undefined)
551
+ // One Capabilities round trip per executor, not per task — the answer
552
+ // cannot change mid-run, and a 400-task graph would otherwise ask 400 times.
553
+ let capsPromise: ReturnType<ReapiClient['capabilities']> | undefined
554
+ const capabilitiesOnce = (): ReturnType<ReapiClient['capabilities']> =>
555
+ (capsPromise ??= client.capabilities())
556
+ const executor: TaskExecutor = {
557
+ name: 'vx/reapi',
558
+ remote: true,
559
+ ...(opts.capacity === undefined ? {} : { capacity: opts.capacity }),
560
+ accepts: acceptsTask,
561
+ async execute(req: ExecuteRequest): Promise<ExecuteResult> {
562
+ const started = Bun.nanoseconds()
563
+ // `inputs` is guaranteed by `accepts` (cacheable ⇒ the miss path
564
+ // describes them); the guard is for a host that bypasses placement.
565
+ // The ONLY plain Error left in this file: a host that routed an
566
+ // undescribed task here violated core's own placement contract, which
567
+ // is a vx bug and should read as one. Everything else that throws is
568
+ // the remote store or the server misbehaving — a UserError (a raw gRPC
569
+ // status is turned into one by `namedRefusal`), so the scheduler
570
+ // prints it plainly instead of "internal error in <task>".
571
+ if (req.inputs === undefined) {
572
+ throw new Error(
573
+ `vx/reapi: ${req.taskId} reached the remote executor with no described inputs`,
574
+ )
575
+ }
576
+
577
+ // A key that already has an execution record needs no worker at all:
578
+ // the record IS this task's entry, and its outputs are in the CAS.
579
+ // Two shapes reach here. `remote: 'only'` — the repeat-run path for
580
+ // `install`, once per lockfile change, ever. And any DEFERRED
581
+ // producer: deferral writes no local entry, so vx's own probe misses
582
+ // on every later run and the record is what makes the second run
583
+ // cheap. `--force` reaches this through `refresh` and skips it — a
584
+ // private cache that ignores the flag is still a cache.
585
+ if (req.cacheKey !== undefined && req.refresh !== true) {
586
+ // A CACHE READ, and nothing more: the record is a shortcut past the
587
+ // worker, so a transport failure here means "no usable record", never
588
+ // "this task failed". Core's standing rule is that a remote cache
589
+ // error degrades to a MISS, and an executor that instead propagates
590
+ // one turns a degraded server into a red build — observed against a
591
+ // NativeLink that had stopped answering AC hits, where every task
592
+ // died on the deadline rather than simply re-running.
593
+ //
594
+ // Deliberately NOT extended to the UPSTREAM record reads below. Those
595
+ // decide whether a dependency's bytes exist at all, and treating a
596
+ // failure there as "carry on" is how an action runs without its
597
+ // inputs and caches the result.
598
+ // stdout asked inline: a server that moved it into CAS otherwise
599
+ // costs the replay a Read for it (F-25).
600
+ const prior = await client
601
+ .getActionResult(execDigestFor(req.cacheKey), { stdout: true, files: [] })
602
+ .catch((err) => {
603
+ warn(
604
+ `vx/reapi: ${req.taskId} could not read its execution record (${errText(err)}) — executing`,
605
+ )
606
+ return null
607
+ })
608
+ if (prior !== null) {
609
+ const referenced = [
610
+ ...(prior.output_files ?? []).map((f) => f.digest),
611
+ ...(prior.output_directories ?? []).map((d) => d.tree_digest),
612
+ ]
613
+ // AC and CAS evict independently, so a record can outlive its
614
+ // blobs. A gap means it cannot produce the outputs — fall through
615
+ // and execute for real rather than "succeed" with nothing.
616
+ // Same reasoning: if the completeness probe cannot run, the record
617
+ // is unusable, which is a miss. `[]` would mean "nothing missing"
618
+ // and would replay a record whose blobs were never checked.
619
+ const gone =
620
+ referenced.length > 0
621
+ ? await client.findMissingBlobs(referenced).catch(() => referenced)
622
+ : []
623
+ // The record's paths are WORKSPACE-relative (rebased when it was
624
+ // written), so the anchor is the workspace root, not the cwd.
625
+ const fromRecord = (created?: string[]): Promise<void> =>
626
+ materialiseOutputs(
627
+ client,
628
+ {
629
+ ...req,
630
+ cwd: req.workspaceRoot,
631
+ projectRel: toPosix(path.relative(req.workspaceRoot, req.cwd)),
632
+ // A blob the probe could not see (one inside a Tree, which
633
+ // `FindMissingBlobs` checks only by the Tree's digest) was
634
+ // replayed as a warning under a whole-tree capture: the task
635
+ // succeeded, a declared output missing, on every run.
636
+ replay: true,
637
+ },
638
+ prior,
639
+ warn,
640
+ created,
641
+ )
642
+ const deferRecord = req.remoteOnly !== true && req.download === 'deferred'
643
+ // The replay is still the cache read: a Read that fails on its
644
+ // stdout or on any output is a record that cannot be served, and
645
+ // the task executes. Core cleaned the declared outputs once, before
646
+ // this call, and does not again, so a replay that wrote part of the
647
+ // tree takes back what it CREATED — a real run that does not write
648
+ // those paths must not have them saved as its outputs. What it
649
+ // overwrote was on disk before (a whole-tree capture lists the
650
+ // inputs) and stays.
651
+ const created: string[] = []
652
+ const replay = async (): Promise<string> => {
653
+ const stdout = await this_readStream(client, prior.stdout_raw, prior.stdout_digest)
654
+ if (req.remoteOnly !== true && !deferRecord) await fromRecord(created)
655
+ return stdout
656
+ }
657
+ const priorStdout =
658
+ gone.length > 0
659
+ ? null
660
+ : await replay().catch(async (err: unknown) => {
661
+ for (const p of created.reverse()) await rm(p, { recursive: true, force: true })
662
+ warn(
663
+ `vx/reapi: ${req.taskId} could not replay its execution record (${errText(err)}) — executing`,
664
+ )
665
+ return null
666
+ })
667
+ if (priorStdout !== null) {
668
+ // Delivered whatever `capture` says, as on the execute path below:
669
+ // a deferred producer saves nothing, so its replay had printed
670
+ // nothing at all (item 827). Only once the replay has landed, so
671
+ // a replay that falls through does not print twice.
672
+ if (priorStdout.length > 0) req.onStdout(priorStdout)
673
+ return {
674
+ exitCode: 0,
675
+ durationMs: Math.round((Bun.nanoseconds() - started) / 1e6),
676
+ stdout: req.capture.stdout === false ? '' : priorStdout,
677
+ stderr: '',
678
+ violations: [],
679
+ ...(deferRecord
680
+ ? { outputs: { kind: 'deferred' as const, materialize: () => fromRecord() } }
681
+ : {}),
682
+ }
683
+ }
684
+ }
685
+ }
686
+
687
+ const projectRel = toPosix(path.relative(req.workspaceRoot, req.cwd))
688
+ // REAPI `output_paths` are relative to `working_directory`, so a
689
+ // ROOT-anchored output (`cache.outputs.workspaceFiles`) declared by a
690
+ // NESTED project can only be spelled with `..` — `packages/vx/../..
691
+ // /node_modules`. The spec allows `..` in SYMLINK targets but servers
692
+ // are entitled to refuse it in output paths, and NativeLink does
693
+ // ("Could not convert path contains non-relative component to
694
+ // RelativePath"). So when a task declares one, run the action at the
695
+ // INPUT ROOT and `cd` into the project instead: every output path is
696
+ // then root-relative and no `..` is ever emitted. Tasks with only
697
+ // project-relative outputs keep the narrower working directory.
698
+ // A wildcard in the MIDDLE of a root-anchored output
699
+ // (`packages/*/node_modules`) still collapses to its static prefix
700
+ // here, because REAPI output paths are literal. That used to be
701
+ // dangerous — the whole `packages` directory came back and REPLACED the
702
+ // sources in every consumer's input tree — so the globs were expanded
703
+ // against the submitter's filesystem first. They are not any more:
704
+ // grafts MERGE into the input tree (see buildInputTree), so a broad
705
+ // capture folds in alongside what a consumer declared instead of over
706
+ // it. Expanding was also impure — it made the action digest depend on
707
+ // which directories happened to exist on the machine that submitted it.
708
+ const outReq: ExecuteRequest = req
709
+ const rootAnchored = req.outputs.workspaceFiles.length > 0
710
+ const workingDirectory = rootAnchored ? '' : projectRel
711
+ // The server reports output paths relative to `working_directory`, so
712
+ // in root mode materialisation anchors at the workspace root — the same
713
+ // rebase the record-replay path above already does for its own reason.
714
+ const matReq = rootAnchored ? { ...req, cwd: req.workspaceRoot, projectRel } : req
715
+
716
+ // Upstream outputs reach this action's input root one of two ways.
717
+ // PREFERRED: by REFERENCE — the upstream executed remotely and left an
718
+ // execution record (per-file digests, workspace-relative paths) under
719
+ // its vx key, so its bytes flow worker→CAS→worker and never transit
720
+ // this machine. FALLBACK: from local disk (the upstream ran locally, so
721
+ // core restored its outputs here before this task started).
722
+ const fileGrafts: FileGraft[] = []
723
+ const treeGrafts: TreeGraft[] = []
724
+ const symlinkGrafts: { path: string; target: string }[] = []
725
+ const localUpstreamPaths: string[] = []
726
+ // Every upstream at once, and each one's trees at once (F-30): a
727
+ // remote-only upstream costs a record read, a probe and a read per
728
+ // tree, and in sequence N upstreams cost N times that.
729
+ const grafts = await Promise.all(
730
+ req.inputs.upstream.map(
731
+ async (
732
+ up,
733
+ ): Promise<{
734
+ local?: string[]
735
+ files?: FileGraft[]
736
+ trees?: TreeGraft[]
737
+ symlinks?: { path: string; target: string }[]
738
+ }> => {
739
+ // LOCAL DISK IS TRUTH when the upstream's outputs are materialised
740
+ // here (core restored or produced them before this task started).
741
+ // Grafting from the remote execution record instead can DIVERGE: two
742
+ // machines racing a nondeterministic miss leave the artifact store
743
+ // and the execution record holding results of DIFFERENT executions
744
+ // under one pure-input key, and a worker fed the record would see
745
+ // bytes this machine's own tasks do not. The graft is for outputs
746
+ // that exist nowhere locally — a remote-only upstream.
747
+ // "Materialised here" has to be CHECKED, not assumed. `up.outputs`
748
+ // comes from the local index, which records what an entry contains —
749
+ // not whether those files are on this disk right now. A task whose
750
+ // producer ran remotely and did not bring its outputs home has index
751
+ // rows and no files, and the tree build then dies on `stat` with
752
+ // ENOENT naming a path the user never asked about. When they are
753
+ // genuinely absent, the record graft is the correct source, which is
754
+ // the branch below.
755
+ const present = up.outputs.filter((rel) =>
756
+ existsSync(path.join(req.workspaceRoot, rel)),
757
+ )
758
+ if (present.length > 0) {
759
+ if (present.length !== up.outputs.length) {
760
+ warn(
761
+ `vx/reapi: ${up.taskId} has ${up.outputs.length - present.length} output(s) recorded ` +
762
+ `but missing on disk — grafting is not possible for a partial set, using what is here`,
763
+ )
764
+ }
765
+ return { local: present }
766
+ }
767
+ const record = await client.getActionResult(execDigestFor(up.hash))
768
+ if (record === null) return {}
769
+ // A record can OUTLIVE its blobs: the AC and the CAS evict on
770
+ // independent schedules. On THIS branch nothing is local (that is
771
+ // why we are grafting), so an evicted blob has no local path to
772
+ // demote to — the declared upstream's outputs exist NOWHERE. An
773
+ // action shipped without them is not a degraded build, it is a
774
+ // different one: a command that tolerates the absence exits 0, and
775
+ // vx caches that result under a key asserting those inputs were
776
+ // present. Which upstream bytes a command reads is unknowable —
777
+ // that is what `dependsOn` declares — so refuse, exactly as core's
778
+ // own materialisation path does. Verified in one round trip.
779
+ const referenced = [
780
+ ...(record.output_files ?? []).map((f) => f.digest),
781
+ ...(record.output_directories ?? []).map((d) => d.tree_digest),
782
+ ]
783
+ const gone = await client.findMissingBlobs(referenced)
784
+ if (gone.length > 0) {
785
+ throw new UserError(
786
+ `vx/reapi: upstream ${up.taskId} outputs evicted from the remote store (${gone.length} blob(s)) and never materialised locally — re-run it (e.g. --force)`,
787
+ )
788
+ }
789
+ const files = (record.output_files ?? []).map((f) => ({
790
+ path: f.path, // recorded workspace-relative — see the record write below
791
+ digest: f.digest,
792
+ isExecutable: f.is_executable === true,
793
+ }))
794
+ // A record's symlinks are outputs too (a worker's own, and a glob's
795
+ // last-segment matches since item 911); without them the action ran
796
+ // without an input it declared, and its result was cached (item 920).
797
+ const symlinks = (record.output_symlinks ?? []).map((sl) => ({
798
+ path: sl.path,
799
+ target: sl.target,
800
+ }))
801
+ const trees = await Promise.all(
802
+ (record.output_directories ?? []).map(async (d): Promise<TreeGraft | null> => {
803
+ const treeBlob = await client.readBlob(d.tree_digest)
804
+ if (treeBlob === null) {
805
+ // Raced an eviction between the completeness check and this
806
+ // read; on this branch no local copy exists, so the loss is
807
+ // real and silently dropping the graft is the same wrong-result
808
+ // hazard as an evicted file.
809
+ throw new UserError(
810
+ `vx/reapi: upstream ${up.taskId} tree ${d.tree_digest.hash.slice(0, 12)} evicted from CAS — re-run it (e.g. --force)`,
811
+ )
812
+ }
813
+ const decodedTree = decodeTreeWithBytes(treeBlob)
814
+ if (decodedTree.root === undefined) return null
815
+ return {
816
+ path: d.path,
817
+ root: decodedTree.root,
818
+ children: decodedTree.children,
819
+ childDigests: decodedTree.childDigests,
820
+ }
821
+ }),
822
+ )
823
+ return { files, symlinks, trees: trees.filter((t) => t !== null) }
824
+ },
825
+ ),
826
+ )
827
+ for (const g of grafts) {
828
+ localUpstreamPaths.push(...(g.local ?? []))
829
+ fileGrafts.push(...(g.files ?? []))
830
+ treeGrafts.push(...(g.trees ?? []))
831
+ symlinkGrafts.push(...(g.symlinks ?? []))
832
+ }
833
+
834
+ // The key folds the project's package.json whether or not a glob
835
+ // lists it, so the worker sees it too: a `"type": "module"` changes
836
+ // what a bundler emits, and a tree without it ran as another task.
837
+ const expected = new Map(req.inputs.files.map((f) => [f.path, f.digest]))
838
+ const packageJson = projectRel === '' ? 'package.json' : `${projectRel}/package.json`
839
+ if (req.inputs.packageJsonDigest !== '' && !expected.has(packageJson)) {
840
+ expected.set(packageJson, req.inputs.packageJsonDigest)
841
+ }
842
+ const inputPaths = [...expected.keys(), ...localUpstreamPaths]
843
+ const tree = await buildInputTree({
844
+ expected,
845
+ workspaceRoot: req.workspaceRoot,
846
+ paths: inputPaths,
847
+ // The working directory must exist in the input root (REAPI
848
+ // requirement) even for a task with no file inputs at all. The
849
+ // PROJECT dir is ensured too, and separately: in root-anchored mode
850
+ // the action's working directory is the input root and the command
851
+ // `cd`s into the project instead, so a task whose declared inputs
852
+ // all live outside its own directory would otherwise `cd` into a
853
+ // directory the tree never created. Outside root mode the two are
854
+ // the same path and ensureDirs dedupes.
855
+ ensureDirs: [workingDirectory, projectRel],
856
+ fileGrafts,
857
+ treeGrafts,
858
+ symlinkGrafts,
859
+ })
860
+ // The tree is read after the key was taken. Bytes that moved in
861
+ // between (an edit mid-run, `vx watch`) run as this action but must
862
+ // not be recorded under the key: restored to the keyed state, the
863
+ // next run anywhere replayed the edited outputs (item 1037).
864
+ if (tree.moved.length > 0) {
865
+ warn(
866
+ `vx/reapi: ${req.taskId}: \`${tree.moved[0]}\` changed after its key was taken — the execution is not recorded under it`,
867
+ )
868
+ }
869
+ for (const shadowedPath of tree.shadowed) {
870
+ warn(
871
+ `vx/reapi: ${req.taskId} declares input files under ${shadowedPath}, which an upstream graft replaces — those files are NOT in the input tree`,
872
+ )
873
+ }
874
+
875
+ const platformProps = Object.entries(opts.platform ?? {}).map(([name, value]) => ({
876
+ name,
877
+ value,
878
+ }))
879
+ const outputs = outputPathSets(outReq, workingDirectory, projectRel)
880
+ const command = {
881
+ // `sh -c` matches vx's contract exactly: shell IS the API, so the
882
+ // worker must interpret the string the same way the local executor's
883
+ // spawn does.
884
+ arguments: ['/bin/sh', '-c', fullCommand(req, rootAnchored ? projectRel : '', projectRel)],
885
+ environmentVariables: commandEnvironment(req.inputs, req.envDefine, req.env),
886
+ outputPaths: outputs.outputPaths,
887
+ // Both generations of the field are set: a v2.1+ server reads
888
+ // output_paths and ignores the legacy pair; a v2.0 server does the
889
+ // inverse. One Command works against either.
890
+ legacyOutputFiles: outputs.legacyFiles,
891
+ legacyOutputDirectories: outputs.legacyDirectories,
892
+ workingDirectory,
893
+ platform: platformProps,
894
+ }
895
+ const commandBytes = encodeCommand(command)
896
+ const commandDigest = sha256(commandBytes)
897
+ const actionBytes = encodeAction({
898
+ commandDigest,
899
+ inputRootDigest: tree.root,
900
+ ...(req.timeoutMs === undefined ? {} : { timeoutSeconds: Math.ceil(req.timeoutMs / 1000) }),
901
+ // v2.2 moved platform onto Action; Command still carries it for older
902
+ // servers, so both are set and they must agree.
903
+ ...(platformProps.length === 0 ? {} : { platform: platformProps }),
904
+ ...(opts.salt === undefined ? {} : { salt: new TextEncoder().encode(opts.salt) }),
905
+ })
906
+ const actionDigest = sha256(actionBytes)
907
+
908
+ const caps = await capabilitiesOnce()
909
+ const upload: Blob[] = [
910
+ ...tree.blobs,
911
+ { digest: commandDigest, data: commandBytes },
912
+ { digest: actionDigest, data: actionBytes },
913
+ ]
914
+ await client.uploadBlobs(upload, caps.maxBatchBytes)
915
+
916
+ // The action id lets a server group this action's CAS/AC traffic in its
917
+ // UI; set before Execute so the streaming call carries it too.
918
+ client.actionId = actionDigest.hash
919
+ // `exec.timeout` rides the Action so the SERVER can enforce it, but a
920
+ // server that ignores it — or has stopped making progress at all —
921
+ // leaves the client waiting forever, and the operation stream is no
922
+ // help: a stalled NativeLink kept sending EXECUTING heartbeats for
923
+ // eighteen minutes while its worker sat blocked, so neither a total
924
+ // deadline nor an inactivity one would have fired. The task's own
925
+ // declared timeout is the honest bound, and enforcing it here makes it
926
+ // mean the same thing wherever the task runs.
927
+ //
928
+ // Clocked from the EXECUTING transition, not from submission: time
929
+ // spent QUEUED behind a busy pool is legitimate and unbounded, which is
930
+ // why `execute` carries no deadline of its own.
931
+ const stallAfter = req.timeoutMs ?? opts.executeTimeoutMs ?? DEFAULT_EXECUTE_TIMEOUT_MS
932
+ const stall = new AbortController()
933
+ let stallTimer: ReturnType<typeof setTimeout> | undefined
934
+ // Waiting for a worker, bounded only when `queueTimeoutMs` says so.
935
+ const queued = new AbortController()
936
+ let operation = ''
937
+ const queueTimer =
938
+ opts.queueTimeoutMs === undefined
939
+ ? undefined
940
+ : setTimeout(() => queued.abort(), opts.queueTimeoutMs)
941
+ const armStall = (): void => {
942
+ clearTimeout(queueTimer)
943
+ if (stallTimer !== undefined) return
944
+ stallTimer = setTimeout(() => stall.abort(), stallAfter)
945
+ }
946
+ // The run's stop (Ctrl-C, an embedder's abort) cancels the operation
947
+ // stream as the stall does; unheard, vx waited on the remote for as
948
+ // long as the action ran.
949
+ const stop = AbortSignal.any([
950
+ stall.signal,
951
+ queued.signal,
952
+ ...(req.signal === undefined ? [] : [req.signal]),
953
+ ])
954
+ let op: Operation
955
+ try {
956
+ if (req.signal?.aborted === true) throw new Error('aborted before Execute')
957
+ op = await client.execute(
958
+ actionDigest,
959
+ {
960
+ ...(opts.priority === undefined ? {} : { priority: opts.priority }),
961
+ onOperation: (name) => {
962
+ operation = name
963
+ },
964
+ // Stage transitions are consumed, not printed: they arrive many
965
+ // times per action and said nothing a reader could act on.
966
+ onStage: (stage) => {
967
+ if (stage.toUpperCase() === 'EXECUTING') armStall()
968
+ },
969
+ },
970
+ stop,
971
+ )
972
+ } catch (err) {
973
+ if (req.signal?.aborted === true) {
974
+ throw new UserError(
975
+ `vx/reapi: ${req.taskId}: the run stopped before its remote execution finished`,
976
+ )
977
+ }
978
+ if (queued.signal.aborted) {
979
+ // Best effort: a server without the service, or one that has lost
980
+ // the operation, still has the task given back.
981
+ if (operation !== '') await client.cancelOperation(operation).catch(() => undefined)
982
+ throw executorFallback(
983
+ `vx/reapi: no worker started the action within queueTimeoutMs (${opts.queueTimeoutMs}ms); its operation was cancelled`,
984
+ )
985
+ }
986
+ if (stall.signal.aborted) {
987
+ throw new UserError(
988
+ `vx/reapi: ${req.taskId} was still executing ${stallAfter}ms after the worker ` +
989
+ `started it and the server never reported a result — giving up on the remote ` +
990
+ `operation. Raise exec.timeout (or the plugin's executeTimeoutMs) if the task ` +
991
+ `is legitimately this slow; otherwise the remote is not making progress.`,
992
+ )
993
+ }
994
+ throw err
995
+ } finally {
996
+ clearTimeout(queueTimer)
997
+ if (stallTimer !== undefined) clearTimeout(stallTimer)
998
+ }
999
+ if (op.error !== undefined && (op.error.code ?? 0) !== 0) {
1000
+ throw new UserError(
1001
+ `vx/reapi: execution failed for ${req.taskId}: ${op.error.message ?? `code ${op.error.code}`}`,
1002
+ )
1003
+ }
1004
+ const decoded = decodeExecuteResponse(op)
1005
+ const { result } = decoded
1006
+ // A non-OK ExecuteResponse.status means the EXECUTION failed (not the
1007
+ // command): surface the server's message and its logs, which are the
1008
+ // only diagnostics that exist for a worker-side failure.
1009
+ if (decoded.status !== undefined && decoded.status.code !== 0) {
1010
+ // A worker past its timeout answers DEADLINE_EXCEEDED with the
1011
+ // partial result, as the spec suggests: what the command printed
1012
+ // is delivered before the refusal, best effort (F-48).
1013
+ if (result !== undefined) {
1014
+ const [out, err] = await Promise.all([
1015
+ this_readStream(client, result.stdout_raw, result.stdout_digest).catch(() => ''),
1016
+ this_readStream(client, result.stderr_raw, result.stderr_digest).catch(() => ''),
1017
+ ])
1018
+ if (out.length > 0) req.onStdout(out)
1019
+ if (err.length > 0) req.onStderr(err)
1020
+ }
1021
+ const logs = await fetchServerLogs(client, decoded.serverLogs)
1022
+ throw new UserError(
1023
+ `vx/reapi: ${req.taskId} execution failed: ${decoded.status.message || `code ${decoded.status.code}`}` +
1024
+ (decoded.message === undefined ? '' : ` — ${decoded.message}`) +
1025
+ logs,
1026
+ )
1027
+ }
1028
+ if (result === undefined) {
1029
+ throw new UserError(
1030
+ `vx/reapi: ${req.taskId} returned no ActionResult${decoded.message === undefined ? '' : `: ${decoded.message}`}`,
1031
+ )
1032
+ }
1033
+ // The worker id is REPORTED, not logged: it rides `ExecuteResult.where`
1034
+ // into telemetry, where a consumer can attribute a task to a machine.
1035
+ // A line per task said the same thing to everyone, every run.
1036
+ const worker = result.execution_metadata?.worker
1037
+
1038
+ // A remote-only task's outputs stay remote — materialising node_modules
1039
+ // onto the submitter's disk is precisely what `remote: 'only'` forbids.
1040
+ // `--download=none` defers the same transfer WITHOUT making it
1041
+ // permanent: the bytes stay in the CAS and core gets a closure to pull
1042
+ // them if a locally-placed consumer turns out to need them.
1043
+ // Started before the logs are read, not after: a server that keeps
1044
+ // stdout in CAS cost every execution that Read before the first
1045
+ // output byte (F-35).
1046
+ const deferred = req.remoteOnly !== true && req.download === 'deferred'
1047
+ const materialised =
1048
+ req.remoteOnly !== true && !deferred
1049
+ ? materialiseOutputs(client, matReq, result, warn)
1050
+ : Promise.resolve()
1051
+ const logs = Promise.all([
1052
+ this_readStream(client, result.stdout_raw, result.stdout_digest),
1053
+ this_readStream(client, result.stderr_raw, result.stderr_digest),
1054
+ ])
1055
+ // Settled below with the outputs; a log that cannot be read still
1056
+ // fails the task, after the outputs have landed or failed.
1057
+ logs.catch(() => undefined)
1058
+ materialised.catch(() => undefined)
1059
+ const [stdout, stderr] = await logs.catch(async (err: unknown) => {
1060
+ await materialised.catch(() => undefined)
1061
+ throw err
1062
+ })
1063
+ // DELIVERY is unconditional; `capture` governs RETENTION only. Core
1064
+ // sets `capture: { stdout: willWrite, stderr: false }` meaning "do not
1065
+ // keep a copy in memory" — the local executor still streams both to the
1066
+ // logger chunk-by-chunk regardless. Gating delivery on it here meant a
1067
+ // failing REMOTE task printed an EMPTY frame, because a remote task has
1068
+ // no live stream and this callback is the only path its output has.
1069
+ // `bun install` reporting on stderr was invisible.
1070
+ if (stdout.length > 0) req.onStdout(stdout)
1071
+ if (stderr.length > 0) req.onStderr(stderr)
1072
+
1073
+ // Record the execution under the task's vx key, output paths rewritten
1074
+ // WORKSPACE-relative (the raw result's are working-directory-relative)
1075
+ // so a dependent in ANY project can graft them at the right place.
1076
+ // Written for every successful remote execution, not just remote-only
1077
+ // tasks: it is what lets a 50-task chain flow worker→CAS→worker.
1078
+ // The record is written while the outputs come down: the two share
1079
+ // nothing, and in turn the record's round trips (a stdout blob, Tree
1080
+ // reads, the update) all sat before the first output byte (F-27).
1081
+ const recording: Promise<void> =
1082
+ req.cacheKey !== undefined && (result.exit_code ?? 0) === 0 && tree.moved.length === 0
1083
+ ? (async (cacheKey: string): Promise<void> => {
1084
+ // A whole-tree capture's path is '' — the working directory itself,
1085
+ // which `${wd}/` spelled `pkg/`: never matched by a decomposition,
1086
+ // and grafted as a directory with an empty name (item 1040).
1087
+ const rebase = (rel: string): string =>
1088
+ workingDirectory === ''
1089
+ ? rel
1090
+ : rel === ''
1091
+ ? workingDirectory
1092
+ : `${workingDirectory}/${rel}`
1093
+ // Stdout rides the record as a blob so a short-circuited repeat run
1094
+ // can replay it. Best-effort: a record without it replays empty,
1095
+ // never wrong bytes.
1096
+ let stdoutDigest: Digest | undefined
1097
+ const stdoutBytes = new TextEncoder().encode(stdout)
1098
+ // Small stdout rides the record itself (`stdout_raw`), which the
1099
+ // replay reads as it reads a digest: two CAS round trips fewer (F-27).
1100
+ const inlineStdout =
1101
+ stdoutBytes.length > 0 && stdoutBytes.length <= INLINE_STDOUT_BYTES
1102
+ // A larger one the server already keeps in CAS is recorded by
1103
+ // its digest: no hash, probe or upload of our own (F-35).
1104
+ if (!inlineStdout && result.stdout_digest !== undefined && stdoutBytes.length > 0) {
1105
+ stdoutDigest = result.stdout_digest
1106
+ } else if (stdoutBytes.length > 0 && !inlineStdout) {
1107
+ const d = sha256(stdoutBytes)
1108
+ const missing = await client.findMissingBlobs([d]).catch(() => [d])
1109
+ const uploaded =
1110
+ missing.length === 0
1111
+ ? true
1112
+ : await client.writeBlob(d, stdoutBytes).then(
1113
+ () => true,
1114
+ () => false,
1115
+ )
1116
+ if (uploaded) stdoutDigest = d
1117
+ }
1118
+ // A coarse capture is split into the paths its glob really names
1119
+ // before it is recorded — see decomposeOutputDir.
1120
+ const declaredGlobs = [
1121
+ ...req.outputs.workspaceFiles,
1122
+ ...req.outputs.files.map((g) => (projectRel === '' ? g : `${projectRel}/${g}`)),
1123
+ ].map((g) => normalizeGlob(g))
1124
+ const recorded: DecomposedOutputs = {
1125
+ directories: [],
1126
+ files: (result.output_files ?? []).map((f) => ({
1127
+ path: rebase(f.path),
1128
+ digest: f.digest,
1129
+ is_executable: f.is_executable === true,
1130
+ })),
1131
+ symlinks: (result.output_symlinks ?? []).map((sl) => ({
1132
+ path: rebase(sl.path),
1133
+ target: sl.target,
1134
+ })),
1135
+ }
1136
+ for (const d of result.output_directories ?? []) {
1137
+ const rebased = { path: rebase(d.path), tree_digest: d.tree_digest }
1138
+ // The record is best-effort like its write below: a Read that
1139
+ // fails while splitting failed a task whose action had succeeded.
1140
+ const split = await decomposeOutputDir(client, rebased, declaredGlobs, warn).catch(
1141
+ (err: unknown) => {
1142
+ warn(
1143
+ `vx/reapi: could not read the Tree for ${rebased.path} (${errText(err)}) — recording it whole`,
1144
+ )
1145
+ return { directories: [rebased], files: [], symlinks: [] }
1146
+ },
1147
+ )
1148
+ recorded.directories.push(...split.directories)
1149
+ recorded.files.push(...split.files)
1150
+ recorded.symlinks.push(...split.symlinks)
1151
+ }
1152
+ await client
1153
+ .updateActionResult(execDigestFor(cacheKey), {
1154
+ exit_code: 0,
1155
+ ...(stdoutDigest === undefined ? {} : { stdout_digest: stdoutDigest }),
1156
+ ...(inlineStdout ? { stdout_raw: stdoutBytes } : {}),
1157
+ output_files: recorded.files,
1158
+ output_directories: recorded.directories,
1159
+ output_symlinks: recorded.symlinks,
1160
+ })
1161
+ .catch((err: Error) =>
1162
+ warn(`vx/reapi: could not record execution for ${req.taskId}: ${err.message}`),
1163
+ )
1164
+ })(req.cacheKey!)
1165
+ : Promise.resolve()
1166
+ // Both settle before a restore failure is thrown: the record is still
1167
+ // written when the outputs cannot come down, as it was in turn.
1168
+ const [, restored] = await Promise.allSettled([recording, materialised])
1169
+ if (restored.status === 'rejected') throw restored.reason
1170
+
1171
+ return {
1172
+ exitCode: result.exit_code ?? 0,
1173
+ durationMs: Math.round((Bun.nanoseconds() - started) / 1e6),
1174
+ stdout: req.capture.stdout === false ? '' : stdout,
1175
+ stderr: req.capture.stderr === false ? '' : stderr,
1176
+ violations: [],
1177
+ ...(worker !== undefined && worker !== '' ? { where: worker } : {}),
1178
+ ...(deferred
1179
+ ? {
1180
+ outputs: {
1181
+ kind: 'deferred' as const,
1182
+ materialize: () => materialiseOutputs(client, matReq, result, warn),
1183
+ },
1184
+ }
1185
+ : {}),
1186
+ }
1187
+ },
1188
+ }
1189
+ const run = executor.execute.bind(executor)
1190
+ executor.execute = (req) =>
1191
+ run(req).catch((err: unknown) => {
1192
+ throw namedRefusal(req.taskId, err)
1193
+ })
1194
+ return executor
1195
+ }
1196
+
1197
+ /**
1198
+ * A gRPC status that escaped `execute` (an Execute the server refused, an
1199
+ * upload or upstream read that failed) is the server's answer, not a vx bug.
1200
+ * It reached the scheduler as a plain Error and printed as "internal error
1201
+ * in <task>" (F-11); a UserError prints as the refusal it is.
1202
+ */
1203
+ function namedRefusal(taskId: string, err: unknown): unknown {
1204
+ if (isUserError(err) || !(err instanceof Error)) return err
1205
+ const status = err as Error & { code?: unknown; details?: unknown }
1206
+ if (typeof status.code !== 'number' || typeof status.details !== 'string') return err
1207
+ return new UserError(`vx/reapi: ${taskId}: the remote server failed the call — ${err.message}`)
1208
+ }
1209
+
1210
+ /**
1211
+ * Server logs are the only diagnostics a worker-side failure produces; fetch
1212
+ * the human-readable ones (bounded) and fold them into the thrown error.
1213
+ */
1214
+ async function fetchServerLogs(
1215
+ client: ReapiClient,
1216
+ logs: ReadonlyArray<{ name: string; digest: Digest; humanReadable: boolean }>,
1217
+ ): Promise<string> {
1218
+ const readable = logs.filter((l) => l.humanReadable && l.digest.size_bytes <= 64 * 1024)
1219
+ if (readable.length === 0) return ''
1220
+ const parts: string[] = []
1221
+ for (const log of readable) {
1222
+ const bytes = await client.readBlob(log.digest).catch(() => null)
1223
+ if (bytes !== null)
1224
+ parts.push(`\n--- server log ${log.name} ---\n${new TextDecoder().decode(bytes)}`)
1225
+ }
1226
+ return parts.join('')
1227
+ }
1228
+
1229
+ /**
1230
+ * stdout/stderr arrive inline OR as a CAS digest; servers choose.
1231
+ *
1232
+ * `null` is as real as `undefined` here: proto-loader hands back a NULL
1233
+ * message field for an absent `stdout_digest` on the `GetActionResult`
1234
+ * path, where the `Execute` path leaves it undefined. Reading only for
1235
+ * undefined dereferenced the null and crashed the whole execute call —
1236
+ * caught by the node_modules chain test the moment the record
1237
+ * short-circuit widened past `remote: 'only'`.
1238
+ */
1239
+ async function this_readStream(
1240
+ client: ReapiClient,
1241
+ raw: Uint8Array | undefined,
1242
+ digest: Digest | undefined | null,
1243
+ ): Promise<string> {
1244
+ if (raw !== undefined && raw !== null && raw.length > 0) return new TextDecoder().decode(raw)
1245
+ if (digest !== undefined && digest !== null && digest.size_bytes > 0) {
1246
+ const bytes = await client.readBlob(digest)
1247
+ if (bytes !== null) return new TextDecoder().decode(bytes)
1248
+ }
1249
+ return ''
1250
+ }
1251
+
1252
+ /** Forwarded args are appended shell-quoted, exactly as the local executor does. */
1253
+ function fullCommand(req: ExecuteRequest, cdInto: string, projectRel: string): string {
1254
+ const quoted =
1255
+ req.forwardArgs.length === 0
1256
+ ? req.command
1257
+ : `${req.command} ${req.forwardArgs.map((a) => `'${a.replaceAll("'", `'\\''`)}'`).join(' ')}`
1258
+ // A remote action gets NO PATH from this machine — sending one would put a
1259
+ // host path in the action digest and split every laptop from every runner.
1260
+ // But a task's command is normally a package binary (`oxlint`, `tsc`), and
1261
+ // the local executor finds those because core prepends the project's
1262
+ // `node_modules/.bin` and the child inherits the caller's PATH. Neither
1263
+ // reaches a worker, so an unqualified command exits 127. Rebuild the same
1264
+ // two entries HERE, from `$PWD` at runtime: hermetic (nothing host-specific
1265
+ // enters the digest) and correct in both anchoring modes.
1266
+ //
1267
+ // root-anchored → cwd IS the input root, so "$PWD" is the root
1268
+ // otherwise → cwd is the project, so climb back out of projectRel
1269
+ const climb =
1270
+ projectRel === ''
1271
+ ? ''
1272
+ : `/${projectRel
1273
+ .split('/')
1274
+ .map(() => '..')
1275
+ .join('/')}`
1276
+ const root = cdInto === '' ? `"$PWD${climb}"` : '"$PWD"'
1277
+ // Order matters: VX_ROOT is read BEFORE the cd, the project-local bin dir
1278
+ // AFTER it, so both are right whichever mode we are in.
1279
+ // Quoted as the forwarded args are: a project directory is a name like
1280
+ // any other, and `it's` ended the quote and ran the rest as script (L-7).
1281
+ const cd = cdInto === '' ? '' : `cd '${cdInto.replaceAll("'", `'\\''`)}' || exit 1; `
1282
+ return (
1283
+ `VX_ROOT=${root}; ${cd}` +
1284
+ `export PATH="$VX_ROOT/node_modules/.bin:$PWD/node_modules/.bin:$PATH"; ` +
1285
+ quoted
1286
+ )
1287
+ }
1288
+
1289
+ /**
1290
+ * vx declares output GLOBS; REAPI `output_paths` are LITERAL paths. The
1291
+ * mapping the design doc prescribes: each glob contributes the deepest
1292
+ * literal prefix above its first wildcard (`dist/**` → `dist`,
1293
+ * `build/out-*.js` → `build`), a wildcard-free glob is itself the path, and
1294
+ * a glob whose FIRST segment already has the wildcard collapses to `''` —
1295
+ * which REAPI defines as "the entire working directory". Passing the raw
1296
+ * glob instead would name a file literally called `dist/**`, and the action
1297
+ * would return no outputs with no error anywhere.
1298
+ */
1299
+ export function globToOutputPath(glob: string): string {
1300
+ const segments = glob.split('/')
1301
+ const literal: string[] = []
1302
+ for (const seg of segments) {
1303
+ if (!isLiteralPattern(seg)) break
1304
+ literal.push(seg)
1305
+ }
1306
+ if (literal.length === segments.length) return glob // no wildcard: a literal path
1307
+ return literal.join('/')
1308
+ }
1309
+
1310
+ /**
1311
+ * The action's environment: `cache.inputs.env` (values read from THIS
1312
+ * machine's environment, already folded into the cache key) plus
1313
+ * `exec.env.define` (literals from the task config). A define wins on
1314
+ * collision — it is the more explicit statement of intent. An `inputs.env`
1315
+ * name crosses only when the local child gets the same value (`childEnv`,
1316
+ * the request's resolved environment): a name the config only TRACKS is in
1317
+ * the key but not the local child's environment, and shipping it ran the
1318
+ * worker on a value a local run never saw, under the same key (item 1092).
1319
+ * Nothing else from `childEnv` may cross: that is this machine's RESOLVED
1320
+ * environment (its PATH, HOME, TMPDIR), and shipping it would put
1321
+ * host-specific values into the action identity, splitting every machine
1322
+ * from every other.
1323
+ *
1324
+ * ORDER is deliberately not this function's business. The proto requires
1325
+ * environment_variables sorted by name so equivalent Commands hash alike, and
1326
+ * `encodeCommand` already sorts every Command it encodes — one owner, byte-
1327
+ * pinned against protobufjs. Sorting here too would be a second copy of the
1328
+ * same rule, and two copies of a canonicalisation agree until they don't.
1329
+ */
1330
+ export function commandEnvironment(
1331
+ inputs: DescribedInputs,
1332
+ envDefine: Readonly<Record<string, string>>,
1333
+ childEnv: Readonly<Record<string, string | undefined>>,
1334
+ ): Array<{ name: string; value: string }> {
1335
+ const merged = new Map<string, string>()
1336
+ // An unset name stays unset in the action, as `passThrough` leaves it here:
1337
+ // shipping it as "" would run a different command than the key describes.
1338
+ for (const e of inputs.env) {
1339
+ if (e.value !== undefined && childEnv[e.name] === e.value) merged.set(e.name, e.value)
1340
+ }
1341
+ for (const [name, value] of Object.entries(envDefine)) merged.set(name, value)
1342
+ return [...merged].map(([name, value]) => ({ name, value }))
1343
+ }
1344
+
1345
+ export interface OutputPathSets {
1346
+ /** v2.1+ `output_paths` — deduped, sorted. */
1347
+ outputPaths: string[]
1348
+ /** v2.0 legacy split: wildcard-free globs are files, prefix-derived are directories. */
1349
+ legacyFiles: string[]
1350
+ legacyDirectories: string[]
1351
+ }
1352
+
1353
+ export function outputPathSets(
1354
+ req: ExecuteRequest,
1355
+ workingDirectory: string,
1356
+ projectRel = '',
1357
+ ): OutputPathSets {
1358
+ const rebase = (p: string): string =>
1359
+ workingDirectory === '' ? p : toPosix(path.relative(workingDirectory, p))
1360
+ // Mirror image of `rebase`: when the action runs at the input root, the
1361
+ // PROJECT-relative globs are the ones needing a prefix.
1362
+ const prefix = (p: string): string =>
1363
+ workingDirectory === '' && projectRel !== '' ? `${projectRel}/${p}` : p
1364
+ // Core's spelling first: `app/\[id\]/x` names the file `app/[id]/x` (item 667).
1365
+ const globs = [...req.outputs.files.map(prefix), ...req.outputs.workspaceFiles.map(rebase)].map(
1366
+ (g) => normalizeGlob(g),
1367
+ )
1368
+ const paths = new Set<string>()
1369
+ const files = new Set<string>()
1370
+ const dirs = new Set<string>()
1371
+ for (const glob of globs) {
1372
+ const literal = globToOutputPath(glob)
1373
+ paths.add(literal)
1374
+ // The legacy split has to GUESS what a path is; a wildcard-free glob was
1375
+ // declared as a file, a prefix cut at a wildcard is necessarily a dir.
1376
+ if (literal === glob) files.add(literal)
1377
+ else dirs.add(literal)
1378
+ }
1379
+ return {
1380
+ outputPaths: [...paths].sort(),
1381
+ legacyFiles: [...files].sort(),
1382
+ legacyDirectories: [...dirs].sort(),
1383
+ }
1384
+ }
1385
+
1386
+ /**
1387
+ * A request as materialisation reads it: `cwd` is where the result's paths
1388
+ * are anchored, which is not the project when the action ran at the input
1389
+ * root or a record replays; `projectRel` then says where the project is.
1390
+ */
1391
+ type MaterialiseRequest = ExecuteRequest & {
1392
+ readonly projectRel?: string
1393
+ /**
1394
+ * A record replay: it is a cache read, so a blob it cannot fetch fails it
1395
+ * (and the task executes) under any capture shape. Only a fresh result
1396
+ * whose capture holds more than the outputs warns instead.
1397
+ */
1398
+ readonly replay?: boolean
1399
+ }
1400
+
1401
+ /**
1402
+ * Bring the action's outputs back to disk. Core's contract is that after an
1403
+ * executor returns, the declared outputs are where the task would have
1404
+ * written them — that is what lets the ordinary save path tar them up with no
1405
+ * knowledge of where the work happened.
1406
+ */
1407
+ export async function materialiseOutputs(
1408
+ client: ReapiClient,
1409
+ req: MaterialiseRequest,
1410
+ result: ActionResult,
1411
+ warn: (m: string) => void,
1412
+ created?: string[],
1413
+ ): Promise<void> {
1414
+ const files = result.output_files ?? []
1415
+ // A glob with a wildcard FIRST segment has no REAPI spelling, so it is sent
1416
+ // as '' — whole-working-directory capture — and the worker returns inputs
1417
+ // and undeclared siblings alongside the real outputs. There is no way to
1418
+ // tell those apart here, so a blob we cannot fetch under that shape only
1419
+ // warns; refusing would break builds that are fine. Under a LITERAL capture
1420
+ // the server returned only what output_paths named, so every file IS a
1421
+ // declared output and an unfetchable one is a hole that `save` would tar up
1422
+ // and cache under a key claiming a complete build. That fails the task.
1423
+ const wholeTreeCapture = [
1424
+ ...(req.outputs?.files ?? []),
1425
+ ...(req.outputs?.workspaceFiles ?? []),
1426
+ ].some((g) => globToOutputPath(g) === '')
1427
+ // `abs` names a file a declared glob matches: missing, it is a hole in a
1428
+ // declared output under either capture, and warning let `save` cache the
1429
+ // short tree under the key (F-8).
1430
+ const missing = (what: string, hash: string, abs?: string): void => {
1431
+ if (!wholeTreeCapture || req.replay === true || (abs !== undefined && isDeclared(abs))) {
1432
+ throw new UserError(
1433
+ `vx/reapi: ${req.taskId} declared output ${what} is missing from the CAS (${hash.slice(0, 12)}) — re-run it (e.g. --force)`,
1434
+ )
1435
+ }
1436
+ warn(`vx/reapi: output ${what} missing from CAS (${hash.slice(0, 12)})`)
1437
+ }
1438
+ // A glob cut at its wildcard captured a directory that holds more than
1439
+ // the outputs — `src/*.gen.js` returns all of `src`, inputs included.
1440
+ // Only what a declared glob names is written: writing the rest put the
1441
+ // worker's copy of the sources over the user's, an edit made during the
1442
+ // action was lost, and core, seeing its inputs rewritten, never saved
1443
+ // the task (item 1038). A directory a LITERAL glob names is written whole.
1444
+ const projectRel = req.projectRel ?? toPosix(path.relative(req.workspaceRoot, req.cwd))
1445
+ const declared = [
1446
+ ...(req.outputs?.files ?? []).map((g) => (projectRel === '' ? g : `${projectRel}/${g}`)),
1447
+ ...(req.outputs?.workspaceFiles ?? []),
1448
+ ].map((g) => normalizeGlob(g))
1449
+ const matchers = declared.map((g) => new Bun.Glob(g))
1450
+ const isDeclared = (abs: string): boolean => {
1451
+ const rel = toPosix(path.relative(req.workspaceRoot, abs))
1452
+ return declared.includes(rel) || matchers.some((m) => m.match(rel))
1453
+ }
1454
+ // Batch the small ones into one round trip; anything larger goes over
1455
+ // ByteStream, which is also the only path that can be compressed.
1456
+ const fence = new Fence(req.workspaceRoot)
1457
+ const small = files.filter((f) => f.digest.size_bytes > 0 && f.digest.size_bytes <= 1024 * 1024)
1458
+ const batched = await client.batchReadBlobs(small.map((f) => f.digest))
1459
+
1460
+ await mapBounded(files, WRITE_CONCURRENCY, async (f) => {
1461
+ const abs = fence.lexical(req.cwd, f.path)
1462
+ await fence.dir(path.dirname(abs))
1463
+ await makeDir(path.dirname(abs), created)
1464
+ // Inlined only when it has bytes: an ActionResult read back through
1465
+ // proto-loader (the execution-record replay) carries `contents` as an
1466
+ // EMPTY Buffer on every file, and taking that as inline wrote each
1467
+ // replayed output empty (item 827). An empty file is the next branch.
1468
+ // Inline bytes are held to the digest they ride with, as fetched ones
1469
+ // are in wire.ts: a server's wrong inline copy was written as the output
1470
+ // and cached under the key (F-8). A mismatch fetches the blob instead.
1471
+ const inline =
1472
+ f.contents !== undefined &&
1473
+ f.contents.length > 0 &&
1474
+ digestWith(client.digest, f.contents).hash === f.digest.hash
1475
+ const bytes = inline
1476
+ ? f.contents! // inlined by the server (`inline_output_files`): zero fetches
1477
+ : Number(f.digest.size_bytes) === 0
1478
+ ? new Uint8Array()
1479
+ : (batched.get(f.digest.hash) ?? (await client.readBlob(f.digest)))
1480
+ if (bytes === null) {
1481
+ missing(f.path, f.digest.hash, abs)
1482
+ return
1483
+ }
1484
+ await writeOutput(abs, bytes, created)
1485
+ // REAPI carries the executable bit per output; a build that produces a
1486
+ // script and a later task that runs it depends on it surviving.
1487
+ if (f.is_executable === true) await chmod(abs, 0o755)
1488
+ })
1489
+
1490
+ // `OutputSymlink` — a declared output that is a link, not a file. Restoring
1491
+ // it as a copy would silently change what the next task sees.
1492
+ for (const sl of result.output_symlinks ?? []) {
1493
+ const abs = fence.lexical(req.cwd, sl.path)
1494
+ await fence.dir(path.dirname(abs))
1495
+ await makeDir(path.dirname(abs), created)
1496
+ await fence.symlink(sl.target, abs, created)
1497
+ }
1498
+
1499
+ for (const d of result.output_directories ?? []) {
1500
+ const dest = fence.lexical(req.cwd, d.path)
1501
+ const whole = isDeclared(dest)
1502
+ await materialiseTree(
1503
+ client,
1504
+ fence,
1505
+ dest,
1506
+ d.tree_digest,
1507
+ missing,
1508
+ created,
1509
+ whole ? null : isDeclared,
1510
+ )
1511
+ }
1512
+ await fence.verifyLinks()
1513
+ }
1514
+
1515
+ /**
1516
+ * Where a server's paths may land: under the workspace root, through no
1517
+ * link that leads out of it (L-2). The ActionResult is the server's word —
1518
+ * a fresh one or a record replayed from its action cache — and its paths
1519
+ * and Tree names were joined as given: `../../../.bashrc`, a Tree name
1520
+ * `..`, a symlink `a -> ~` followed by a directory `a` holding
1521
+ * `.ssh/authorized_keys`, a file written through a link the result placed,
1522
+ * or a link out of the tree that the save then packed and uploaded. Each
1523
+ * is refused as the server's fault. A directory's check is memoized: it
1524
+ * is created a real directory right after, and a link is never placed over
1525
+ * a directory (`rm` without `recursive` refuses one). An absolute path
1526
+ * needs no case of its own: the join lands it where it names, and the
1527
+ * containment check judges that.
1528
+ */
1529
+ class Fence {
1530
+ private realRoot: string | undefined
1531
+ private readonly checked = new Set<string>()
1532
+ private readonly links: { abs: string; target: string }[] = []
1533
+ private readonly root: string
1534
+
1535
+ constructor(root: string) {
1536
+ this.root = path.resolve(root)
1537
+ }
1538
+
1539
+ /** `rel` joined under `base`, refused when the join leaves the root. */
1540
+ lexical(base: string, rel: string): string {
1541
+ // A NUL reached the file system as a raw ERR_INVALID_ARG_VALUE.
1542
+ if (rel.includes('\0')) throw this.refuse(rel)
1543
+ const abs = path.resolve(base, rel)
1544
+ if (abs !== this.root && !abs.startsWith(this.root + path.sep)) throw this.refuse(rel)
1545
+ return abs
1546
+ }
1547
+
1548
+ /** A Tree entry's name: one path component, as REAPI defines it. */
1549
+ name(at: string, name: string): string {
1550
+ if (name === '' || name === '.' || name === '..' || name.includes('/') || name.includes('\0')) {
1551
+ throw this.refuse(path.join(at, name))
1552
+ }
1553
+ return path.join(at, name)
1554
+ }
1555
+
1556
+ /** `dir`, or its deepest existing ancestor, resolves inside the root. */
1557
+ async dir(dir: string): Promise<void> {
1558
+ if (this.checked.has(dir)) return
1559
+ this.realRoot ??= await realpath(this.root).catch(() => this.root)
1560
+ let probe = dir
1561
+ for (;;) {
1562
+ const real = await realpath(probe).catch(() => null)
1563
+ if (real !== null) {
1564
+ if (real !== this.realRoot && !real.startsWith(this.realRoot + path.sep)) {
1565
+ throw this.refuse(dir)
1566
+ }
1567
+ break
1568
+ }
1569
+ if (probe === this.root || path.dirname(probe) === probe) break
1570
+ probe = path.dirname(probe)
1571
+ }
1572
+ this.checked.add(dir)
1573
+ }
1574
+
1575
+ /** A link whose target, read from where it stands, stays under the root. */
1576
+ async symlink(target: string, abs: string, created: string[] | undefined): Promise<void> {
1577
+ const to = path.resolve(path.dirname(abs), target)
1578
+ if (target.includes('\0') || (to !== this.root && !to.startsWith(this.root + path.sep))) {
1579
+ throw this.refuse(`${abs} -> ${target}`)
1580
+ }
1581
+ await placeSymlink(target, abs, created)
1582
+ this.links.push({ abs, target })
1583
+ }
1584
+
1585
+ /**
1586
+ * Every placed link, resolved as the OS follows it. The text check above
1587
+ * collapses `x/..` where the OS follows `x`: with `x -> ..` placed, `y ->
1588
+ * x/../../outside` reads as inside and leads out (F-47). Judged once all
1589
+ * are placed, since a later link changes what an earlier one names. An
1590
+ * escaping link is removed before the refusal.
1591
+ */
1592
+ async verifyLinks(): Promise<void> {
1593
+ this.realRoot ??= await realpath(this.root).catch(() => this.root)
1594
+ for (const { abs, target } of this.links) {
1595
+ const to = await resolveThrough(
1596
+ path.isAbsolute(target) ? target : `${path.dirname(abs)}${path.sep}${target}`,
1597
+ )
1598
+ if (to !== this.realRoot && !to.startsWith(this.realRoot + path.sep)) {
1599
+ await rm(abs, { force: true })
1600
+ throw this.refuse(`${abs} -> ${target}`)
1601
+ }
1602
+ }
1603
+ }
1604
+
1605
+ private refuse(what: string): UserError {
1606
+ return new UserError(
1607
+ `vx/reapi: the server returned an output outside the workspace (${what}) — refused; nothing is written outside ${this.root}`,
1608
+ )
1609
+ }
1610
+ }
1611
+
1612
+ /** A write that never follows a link standing at its path. */
1613
+ const WRITE_NOFOLLOW =
1614
+ constants.O_WRONLY | constants.O_CREAT | constants.O_TRUNC | constants.O_NOFOLLOW
1615
+
1616
+ /**
1617
+ * The three ways materialisation touches disk. Given `created` (a replay that
1618
+ * may have to be taken back), each also records what did not exist before it:
1619
+ * the outermost directory `mkdir` made, a file `wx` could create, a link that
1620
+ * found its name free. One syscall in the usual case, where core has cleaned.
1621
+ */
1622
+ async function makeDir(dir: string, created: string[] | undefined): Promise<void> {
1623
+ const first = await mkdir(dir, { recursive: true })
1624
+ if (first !== undefined) created?.push(first)
1625
+ }
1626
+
1627
+ async function writeOutput(
1628
+ abs: string,
1629
+ bytes: Uint8Array,
1630
+ created: string[] | undefined,
1631
+ ): Promise<void> {
1632
+ if (created !== undefined) {
1633
+ try {
1634
+ await writeFile(abs, bytes, { flag: 'wx' })
1635
+ created.push(abs)
1636
+ return
1637
+ } catch (err) {
1638
+ if ((err as NodeJS.ErrnoException).code !== 'EEXIST') throw err
1639
+ }
1640
+ }
1641
+ // A link at the path is replaced, never written through: the result may
1642
+ // have placed it (L-2).
1643
+ await writeFile(abs, bytes, { flag: WRITE_NOFOLLOW }).catch(async (err: unknown) => {
1644
+ if ((err as NodeJS.ErrnoException).code !== 'ELOOP') throw err
1645
+ await unlink(abs)
1646
+ await writeFile(abs, bytes, { flag: WRITE_NOFOLLOW })
1647
+ })
1648
+ }
1649
+
1650
+ /**
1651
+ * `p` as the OS resolves it: one component at a time, a link's target
1652
+ * spliced in where it stands, so `x/..` leaves what `x` names. Bun's
1653
+ * `realpath` collapses `..` as text before it follows a link, which is the
1654
+ * very reading this must not make. A missing component is taken as text;
1655
+ * a loop (which the OS refuses to follow) stops where it is.
1656
+ */
1657
+ async function resolveThrough(p: string): Promise<string> {
1658
+ const parts = (s: string): string[] => s.split(/[\\/]/).filter((c) => c !== '' && c !== '.')
1659
+ let at = path.parse(p).root
1660
+ const todo = parts(p)
1661
+ for (let hops = 0; todo.length > 0;) {
1662
+ const c = todo.shift()!
1663
+ if (c === '..') {
1664
+ at = path.dirname(at)
1665
+ continue
1666
+ }
1667
+ const next = path.join(at, c)
1668
+ const target = await readlink(next).catch(() => null)
1669
+ if (target === null || ++hops > 40) {
1670
+ at = next
1671
+ continue
1672
+ }
1673
+ if (path.isAbsolute(target)) at = path.parse(target).root
1674
+ todo.unshift(...parts(target))
1675
+ }
1676
+ return at
1677
+ }
1678
+
1679
+ async function placeSymlink(
1680
+ target: string,
1681
+ abs: string,
1682
+ created: string[] | undefined,
1683
+ ): Promise<void> {
1684
+ try {
1685
+ await symlink(target, abs)
1686
+ created?.push(abs)
1687
+ } catch (err) {
1688
+ if ((err as NodeJS.ErrnoException).code !== 'EEXIST') throw err
1689
+ await rm(abs, { force: true })
1690
+ await symlink(target, abs)
1691
+ }
1692
+ }
1693
+
1694
+ /** Stdout up to this size is recorded inline (`stdout_raw`) rather than as a CAS blob. */
1695
+ const INLINE_STDOUT_BYTES = 64 * 1024
1696
+
1697
+ /** Small blobs a tree restore holds in memory at once, fetched batched. */
1698
+ const TREE_FETCH_WINDOW_BYTES = 64 * 1024 * 1024
1699
+
1700
+ /**
1701
+ * An `OutputDirectory` points at the digest of an encoded **`Tree` proto**
1702
+ * (root Directory + every descendant), NOT at a Directory to be walked with
1703
+ * the `GetTree` RPC. Reading it as the latter is a real interop bug — it was
1704
+ * this module's first version — because `GetTree` takes a Directory root and
1705
+ * would either error or, worse, traverse something else.
1706
+ */
1707
+ async function materialiseTree(
1708
+ client: ReapiClient,
1709
+ fence: Fence,
1710
+ destDir: string,
1711
+ treeDigest: Digest,
1712
+ // Same policy as the file path: under a literal capture an unmaterialisable
1713
+ // entry is a hole in a DECLARED output directory, so it fails the task.
1714
+ missing: (what: string, hash: string, abs?: string) => void,
1715
+ created: string[] | undefined,
1716
+ // Null writes the whole Tree; otherwise only an entry it names, or one
1717
+ // under a directory it names, is written.
1718
+ declared: ((abs: string) => boolean) | null,
1719
+ ): Promise<void> {
1720
+ const blob = await client.readBlob(treeDigest)
1721
+ if (blob === null) {
1722
+ missing('tree', treeDigest.hash)
1723
+ return
1724
+ }
1725
+ const tree = decodeTreeWithBytes(blob)
1726
+ if (tree.root === undefined) {
1727
+ missing('tree (no root directory)', treeDigest.hash)
1728
+ return
1729
+ }
1730
+ // Children are addressed by the digest of the bytes the WORKER encoded.
1731
+ // Re-encoding our parse reproduces it only when both encoders agree byte
1732
+ // for byte, and a child that did not was "not present in the Tree blob"
1733
+ // (item 827), the miss decomposeOutputDir's own comment measured.
1734
+ const byDigest = new Map<string, Directory>()
1735
+ tree.children.forEach((child, i) => byDigest.set(tree.childDigests[i]!, child))
1736
+
1737
+ // Files are gathered from the whole tree before any is fetched: one
1738
+ // BatchReadBlobs per DIRECTORY was a round trip each, so a `dist/` of 200
1739
+ // directories restored in 200 sequential calls (F-18). Windows bound what
1740
+ // is held. A symlink placed later in the walk lies under a directory not
1741
+ // yet visited, so no file gathered earlier is written through one.
1742
+ const files: { abs: string; f: Directory['files'][number] }[] = []
1743
+ const walk = async (dir: Directory, at: string, whole: boolean): Promise<void> => {
1744
+ const wanted = (abs: string): boolean => whole || declared!(abs)
1745
+ const here = dir.files.filter((f) => wanted(fence.name(at, f.name)))
1746
+ const symlinks = dir.symlinks.filter((sl) => wanted(fence.name(at, sl.name)))
1747
+ if (whole || here.length + symlinks.length > 0) {
1748
+ await fence.dir(at)
1749
+ await makeDir(at, created)
1750
+ }
1751
+ for (const f of here) files.push({ abs: path.join(at, f.name), f })
1752
+ for (const sl of symlinks) {
1753
+ await fence.symlink(sl.target, path.join(at, sl.name), created)
1754
+ }
1755
+ for (const child of dir.directories) {
1756
+ const childAt = fence.name(at, child.name)
1757
+ const node = byDigest.get(child.digest.hash)
1758
+ if (node === undefined) {
1759
+ if (wanted(childAt)) {
1760
+ missing(`${childAt} (not present in the Tree blob)`, child.digest.hash)
1761
+ }
1762
+ continue
1763
+ }
1764
+ await walk(node, childAt, whole || declared!(childAt))
1765
+ }
1766
+ }
1767
+ await walk(tree.root, destDir, declared === null)
1768
+
1769
+ const isSmall = (f: { digest: Digest }): boolean =>
1770
+ f.digest.size_bytes > 0 && f.digest.size_bytes <= 1024 * 1024
1771
+ for (let i = 0; i < files.length;) {
1772
+ let held = 0
1773
+ let j = i
1774
+ while (j < files.length && (j === i || held < TREE_FETCH_WINDOW_BYTES)) {
1775
+ if (isSmall(files[j]!.f)) held += Number(files[j]!.f.digest.size_bytes)
1776
+ j++
1777
+ }
1778
+ const window = files.slice(i, j)
1779
+ const batched = await client.batchReadBlobs(
1780
+ window.filter((w) => isSmall(w.f)).map((w) => w.f.digest),
1781
+ )
1782
+ await mapBounded(window, WRITE_CONCURRENCY, async ({ abs, f }) => {
1783
+ const bytes =
1784
+ f.digest.size_bytes === 0
1785
+ ? new Uint8Array()
1786
+ : (batched.get(f.digest.hash) ?? (await client.readBlob(f.digest)))
1787
+ if (bytes === null) {
1788
+ missing(abs, f.digest.hash, abs)
1789
+ return
1790
+ }
1791
+ await writeOutput(abs, bytes, created)
1792
+ if (f.is_executable) await chmod(abs, 0o755)
1793
+ // NodeProperties.unix_mode is authoritative when the server sent it.
1794
+ const mode = f.node_properties?.unixMode
1795
+ // Permission bits only: a server's setuid or setgid bit is not a build output's.
1796
+ if (mode !== undefined) await chmod(abs, mode & 0o777)
1797
+ })
1798
+ i = j
1799
+ }
1800
+ }
1801
+
1802
+ /** Output files `materialiseOutputs` writes at once. */
1803
+ const WRITE_CONCURRENCY = 32
1804
+
1805
+ const toPosix = (p: string): string => p.split(path.sep).join('/')