@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/LICENSE +21 -0
- package/README.md +502 -0
- package/index.ts +4 -0
- package/package.json +45 -6
- package/protos/build/bazel/remote/execution/v2/remote_execution.proto +2516 -0
- package/protos/build/bazel/semver/semver.proto +41 -0
- package/protos/google/api/annotations.proto +31 -0
- package/protos/google/api/client.proto +598 -0
- package/protos/google/api/field_behavior.proto +104 -0
- package/protos/google/api/http.proto +370 -0
- package/protos/google/api/launch_stage.proto +72 -0
- package/protos/google/bytestream/bytestream.proto +178 -0
- package/protos/google/longrunning/operations.proto +265 -0
- package/protos/google/rpc/code.proto +186 -0
- package/protos/google/rpc/status.proto +48 -0
- package/src/cache.ts +199 -0
- package/src/executor.ts +1805 -0
- package/src/index.ts +277 -0
- package/src/merkle.ts +867 -0
- package/src/wire.ts +1658 -0
package/src/executor.ts
ADDED
|
@@ -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('/')
|