@vzn/vx-reapi 0.0.646 → 0.0.648
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/README.md +52 -40
- package/package.json +3 -3
- package/src/cache.ts +89 -23
- package/src/executor.ts +289 -109
- package/src/index.ts +67 -51
- package/src/merkle.ts +57 -18
- package/src/wire.ts +106 -32
package/src/index.ts
CHANGED
|
@@ -23,7 +23,7 @@ import {
|
|
|
23
23
|
} from '@vzn/vx'
|
|
24
24
|
import { ReapiRemoteCache } from './cache.js'
|
|
25
25
|
import { reapiExecutor } from './executor.js'
|
|
26
|
-
import { ReapiClient, type ReapiOptions, usesTls } from './wire.js'
|
|
26
|
+
import { MAX_TIMER_MS, ReapiClient, type ReapiOptions, usesTls } from './wire.js'
|
|
27
27
|
|
|
28
28
|
// `ReapiRemoteCache` is public for a workspace that composes cache
|
|
29
29
|
// layers by hand; the wire, the Merkle encoders and the executor are
|
|
@@ -50,8 +50,8 @@ export interface ReapiPluginOptions extends Partial<
|
|
|
50
50
|
*/
|
|
51
51
|
queueTimeoutMs?: number
|
|
52
52
|
/**
|
|
53
|
-
* Endpoint
|
|
54
|
-
* DECLINES — a declared-but-unconfigured plugin costs nothing and must
|
|
53
|
+
* Endpoint (`process.env.REAPI_ENDPOINT` in `.vx/workspace.ts`). Unset
|
|
54
|
+
* or empty, the plugin DECLINES — a declared-but-unconfigured plugin costs nothing and must
|
|
55
55
|
* never fail a run. TLS unless it starts `grpc://`, `http://`, `unix:` or
|
|
56
56
|
* `unix-abstract:`, or `tls: false` is set, as Bazel reads a schemeless
|
|
57
57
|
* `--remote_cache`.
|
|
@@ -61,8 +61,7 @@ export interface ReapiPluginOptions extends Partial<
|
|
|
61
61
|
* Contribute the `executor` capability too, so tasks RUN on the REAPI
|
|
62
62
|
* server rather than only caching there. Default false: remote execution
|
|
63
63
|
* changes where a user's build runs, which is not something a plugin should
|
|
64
|
-
* switch on merely by being configured for caching.
|
|
65
|
-
* also enables it.
|
|
64
|
+
* switch on merely by being configured for caching.
|
|
66
65
|
*/
|
|
67
66
|
execute?: boolean
|
|
68
67
|
/** REAPI platform properties for remote execution (`container-image`, …). */
|
|
@@ -71,14 +70,12 @@ export interface ReapiPluginOptions extends Partial<
|
|
|
71
70
|
capacity?: number
|
|
72
71
|
/**
|
|
73
72
|
* PEM file of the CA that signed the server's certificate, for a server
|
|
74
|
-
* behind a private CA.
|
|
75
|
-
* `--tls_certificate`.
|
|
73
|
+
* behind a private CA. Bazel's `--tls_certificate`.
|
|
76
74
|
*/
|
|
77
75
|
tlsCertificate?: string
|
|
78
76
|
/**
|
|
79
77
|
* PEM files of a client certificate and its key, for a server that asks
|
|
80
|
-
* for mutual TLS
|
|
81
|
-
* `--tls_client_certificate` / `--tls_client_key`.
|
|
78
|
+
* for mutual TLS. Bazel's `--tls_client_certificate` / `--tls_client_key`.
|
|
82
79
|
*/
|
|
83
80
|
tlsClientCertificate?: string
|
|
84
81
|
tlsClientKey?: string
|
|
@@ -95,69 +92,51 @@ export interface ReapiPluginOptions extends Partial<
|
|
|
95
92
|
* Declines when no endpoint is configured, so it is safe to leave declared.
|
|
96
93
|
*/
|
|
97
94
|
function connection(options: ReapiPluginOptions): ReapiOptions | undefined {
|
|
98
|
-
|
|
99
|
-
//
|
|
100
|
-
|
|
101
|
-
const
|
|
95
|
+
const from = '`reapi({ endpoint })`'
|
|
96
|
+
// A trailing slash is stripped, as every other plugin strips its URL's; a
|
|
97
|
+
// socket path is the file system's to read.
|
|
98
|
+
const given = options.endpoint?.trim()
|
|
99
|
+
const endpoint =
|
|
100
|
+
given?.startsWith('unix') === true ? given : given?.replace(/^(.*[^/:])\/+$/, '$1')
|
|
102
101
|
if (endpoint === undefined || endpoint === '') return undefined
|
|
103
102
|
assertEndpoint(endpoint, from)
|
|
104
103
|
// A secrets file's trailing newline sent a name no blob is under, and
|
|
105
104
|
// every lookup missed in silence.
|
|
106
|
-
const instanceName =
|
|
107
|
-
(options.instanceName ?? process.env['VX_REAPI_INSTANCE'])?.trim() || undefined
|
|
105
|
+
const instanceName = options.instanceName?.trim() || undefined
|
|
108
106
|
// A server behind a private CA, or one that asks for a client
|
|
109
107
|
// certificate, was unreachable: TLS used the system roots and no client
|
|
110
108
|
// pair (F-41). Each is a PEM file, read here; one that cannot be read is
|
|
111
109
|
// a setting to fix, refused naming it.
|
|
112
|
-
const pem = (
|
|
113
|
-
|
|
114
|
-
name: string,
|
|
115
|
-
fromEnv: string | undefined,
|
|
116
|
-
env: string,
|
|
117
|
-
): string | undefined => {
|
|
118
|
-
const file = option ?? fromEnv?.trim()
|
|
119
|
-
if (file === undefined || file === '') return undefined
|
|
110
|
+
const pem = (file: string | undefined, name: string): string | undefined => {
|
|
111
|
+
if (file === undefined || file.trim() === '') return undefined
|
|
120
112
|
try {
|
|
121
|
-
return readFileSync(file, 'utf8')
|
|
113
|
+
return readFileSync(file.trim(), 'utf8')
|
|
122
114
|
} catch (err) {
|
|
123
|
-
const from = option !== undefined ? `\`reapi({ ${name} })\`` : env
|
|
124
115
|
throw new UserError(
|
|
125
|
-
|
|
116
|
+
`\`reapi({ ${name} })\` names ${file.trim()}, which cannot be read (${(err as NodeJS.ErrnoException).code ?? String(err)})`,
|
|
126
117
|
)
|
|
127
118
|
}
|
|
128
119
|
}
|
|
129
|
-
const tlsCaPem = pem(
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
process.env['VX_REAPI_TLS_CERTIFICATE'],
|
|
133
|
-
'VX_REAPI_TLS_CERTIFICATE',
|
|
134
|
-
)
|
|
135
|
-
const tlsClientCertPem = pem(
|
|
136
|
-
options.tlsClientCertificate,
|
|
137
|
-
'tlsClientCertificate',
|
|
138
|
-
process.env['VX_REAPI_TLS_CLIENT_CERTIFICATE'],
|
|
139
|
-
'VX_REAPI_TLS_CLIENT_CERTIFICATE',
|
|
140
|
-
)
|
|
141
|
-
const tlsClientKeyPem = pem(
|
|
142
|
-
options.tlsClientKey,
|
|
143
|
-
'tlsClientKey',
|
|
144
|
-
process.env['VX_REAPI_TLS_CLIENT_KEY'],
|
|
145
|
-
'VX_REAPI_TLS_CLIENT_KEY',
|
|
146
|
-
)
|
|
120
|
+
const tlsCaPem = pem(options.tlsCertificate, 'tlsCertificate')
|
|
121
|
+
const tlsClientCertPem = pem(options.tlsClientCertificate, 'tlsClientCertificate')
|
|
122
|
+
const tlsClientKeyPem = pem(options.tlsClientKey, 'tlsClientKey')
|
|
147
123
|
// grpc-js refuses either one alone, with a message that names neither
|
|
148
124
|
// setting.
|
|
149
125
|
if ((tlsClientCertPem === undefined) !== (tlsClientKeyPem === undefined)) {
|
|
150
126
|
throw new UserError(
|
|
151
|
-
'
|
|
127
|
+
'reapi(): a client certificate and its key go together — set both tlsClientCertificate and tlsClientKey, or neither',
|
|
152
128
|
)
|
|
153
129
|
}
|
|
154
130
|
if (usesTls({ endpoint, tls: options.tls, tlsCaPem, tlsClientCertPem }) && ipTarget(endpoint)) {
|
|
155
131
|
throw new UserError(
|
|
156
|
-
|
|
132
|
+
`${from} is ${JSON.stringify(endpoint)}, TLS to an IP address, which Bun cannot open (it refuses an IP as the TLS server name) — name the server by host name, or use grpc:// for plaintext`,
|
|
157
133
|
)
|
|
158
134
|
}
|
|
135
|
+
// Never the option as written: a blank one spread here was sent as the
|
|
136
|
+
// instance name.
|
|
137
|
+
const { instanceName: _written, ...rest } = options
|
|
159
138
|
return {
|
|
160
|
-
...
|
|
139
|
+
...rest,
|
|
161
140
|
endpoint,
|
|
162
141
|
...(instanceName === undefined ? {} : { instanceName }),
|
|
163
142
|
...(tlsCaPem === undefined ? {} : { tlsCaPem }),
|
|
@@ -181,7 +160,7 @@ function assertEndpoint(endpoint: string, from: string): void {
|
|
|
181
160
|
const port = m?.[2] === undefined ? undefined : Number(m[2])
|
|
182
161
|
if (m === null || (port !== undefined && (port < 1 || port > 65_535))) {
|
|
183
162
|
throw new UserError(
|
|
184
|
-
|
|
163
|
+
`${from} is ${JSON.stringify(endpoint)}, which is not host[:port] (e.g. grpcs://cache.example.com:443)`,
|
|
185
164
|
)
|
|
186
165
|
}
|
|
187
166
|
}
|
|
@@ -222,15 +201,49 @@ const REAPI_PLUGIN_KEYS: PluginOptionKinds<ReapiPluginOptions> = {
|
|
|
222
201
|
chunkBytes: 'number',
|
|
223
202
|
}
|
|
224
203
|
|
|
204
|
+
/**
|
|
205
|
+
* The number options, checked at the call: the client and the executor
|
|
206
|
+
* refused a bad one only once an endpoint was set, so a laptop passed what
|
|
207
|
+
* failed the first CI run, and a NaN read as `null`.
|
|
208
|
+
*/
|
|
209
|
+
function refuseBadNumbers(options: ReapiPluginOptions): void {
|
|
210
|
+
const refuse = (name: string, v: unknown, want: string): never => {
|
|
211
|
+
throw new UserError(`reapi() option "${name}" must be ${want}, got ${String(v)}`)
|
|
212
|
+
}
|
|
213
|
+
for (const name of [
|
|
214
|
+
'callTimeoutMs',
|
|
215
|
+
'metaTimeoutMs',
|
|
216
|
+
'executeTimeoutMs',
|
|
217
|
+
'queueTimeoutMs',
|
|
218
|
+
] as const) {
|
|
219
|
+
const v = options[name]
|
|
220
|
+
if (v !== undefined && !(v > 0 && v <= MAX_TIMER_MS))
|
|
221
|
+
refuse(name, v, `a positive number of ms up to ${MAX_TIMER_MS}`)
|
|
222
|
+
}
|
|
223
|
+
for (const name of ['chunkBytes', 'capacity'] as const) {
|
|
224
|
+
const v = options[name]
|
|
225
|
+
if (v !== undefined && !(Number.isInteger(v) && v > 0))
|
|
226
|
+
refuse(name, v, 'a whole number above 0')
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
225
230
|
export function reapi(options: ReapiPluginOptions = {}): VxPlugin {
|
|
226
231
|
refuseUnknownOptions('reapi()', options, REAPI_PLUGIN_KEYS)
|
|
232
|
+
refuseBadNumbers(options)
|
|
233
|
+
// An unset variable as a value was sent as an empty one on every action.
|
|
234
|
+
for (const key of ['platform', 'headers'] as const)
|
|
235
|
+
for (const [name, value] of Object.entries(options[key] ?? {}))
|
|
236
|
+
if (typeof value !== 'string')
|
|
237
|
+
throw new UserError(
|
|
238
|
+
`reapi() option "${key}.${name}" must be a string, got ${key === 'headers' && value !== undefined ? typeof value : String(value)}`,
|
|
239
|
+
)
|
|
227
240
|
let executorClient: ReapiClient | undefined
|
|
228
241
|
let remoteCache: ReapiRemoteCache | undefined
|
|
229
242
|
return definePlugin(import.meta, {
|
|
230
243
|
async executor(ctx): Promise<TaskExecutor | undefined> {
|
|
231
244
|
// Before the connection: with execute off, an unreadable PEM is the
|
|
232
245
|
// cache hook's to refuse, never a failure "in executor".
|
|
233
|
-
if (options.execute !== true
|
|
246
|
+
if (options.execute !== true) return undefined
|
|
234
247
|
const conn = connection(options)
|
|
235
248
|
if (conn === undefined) return undefined
|
|
236
249
|
executorClient = new ReapiClient({ ...conn, onWarn: (m) => ctx.warn(m) })
|
|
@@ -241,8 +254,12 @@ export function reapi(options: ReapiPluginOptions = {}): VxPlugin {
|
|
|
241
254
|
// plugin's setting nor anything to do about it. Core turns a throwing
|
|
242
255
|
// factory into a UserError and ABORTS — deliberately, an executor is
|
|
243
256
|
// load-bearing — so this is the message a user acts on.
|
|
257
|
+
// Both asks: a failed second one escaped as the raw status, naming
|
|
258
|
+
// neither the server nor the setting.
|
|
259
|
+
let caps: Awaited<ReturnType<ReapiClient['capabilities']>>
|
|
244
260
|
try {
|
|
245
261
|
await executorClient.negotiate()
|
|
262
|
+
caps = await executorClient.capabilities()
|
|
246
263
|
} catch (err) {
|
|
247
264
|
executorClient.close()
|
|
248
265
|
executorClient = undefined
|
|
@@ -262,10 +279,9 @@ export function reapi(options: ReapiPluginOptions = {}): VxPlugin {
|
|
|
262
279
|
// A cache-only deployment (bazel-remote) advertises no execution
|
|
263
280
|
// capability. Offering it work would hang the run on a server that will
|
|
264
281
|
// never answer, so DECLINE loudly and let the local executor take over.
|
|
265
|
-
const caps = await executorClient.capabilities()
|
|
266
282
|
if (!caps.execEnabled) {
|
|
267
283
|
ctx.warn(
|
|
268
|
-
`vx
|
|
284
|
+
`[vx] vx-reapi: ${conn.endpoint} does not advertise remote execution (cache only) — tasks will run locally`,
|
|
269
285
|
)
|
|
270
286
|
executorClient.close()
|
|
271
287
|
executorClient = undefined
|
package/src/merkle.ts
CHANGED
|
@@ -115,13 +115,22 @@ function canonicaliseTree(graft: TreeGraft): { root: Digest; blobs: Blob[] } {
|
|
|
115
115
|
if (sent !== undefined) byOldDigest.set(sent, child)
|
|
116
116
|
})
|
|
117
117
|
const blobs: Blob[] = []
|
|
118
|
+
// Once per old digest: a subtree the Tree names under many names was
|
|
119
|
+
// rebuilt per name, 2^depth times.
|
|
120
|
+
const rebuilt = new Map<string, Digest>()
|
|
118
121
|
const rebuild = (dir: Directory): Digest => {
|
|
119
122
|
const directories: DirectoryNode[] = dir.directories.map((d) => {
|
|
120
123
|
const child = byOldDigest.get(d.digest.hash)
|
|
121
124
|
// A child we cannot resolve keeps its original digest — the blob may
|
|
122
125
|
// exist server-side under the worker's encoding even if we cannot
|
|
123
126
|
// re-derive it. Better a possibly-dangling reference than a wrong one.
|
|
124
|
-
|
|
127
|
+
if (child === undefined) return d
|
|
128
|
+
let digest = rebuilt.get(d.digest.hash)
|
|
129
|
+
if (digest === undefined) {
|
|
130
|
+
digest = rebuild(child)
|
|
131
|
+
rebuilt.set(d.digest.hash, digest)
|
|
132
|
+
}
|
|
133
|
+
return { name: d.name, digest }
|
|
125
134
|
})
|
|
126
135
|
const canonical: Directory = { files: dir.files, directories, symlinks: dir.symlinks }
|
|
127
136
|
const data = encodeDirectory(canonical)
|
|
@@ -200,7 +209,9 @@ export async function buildInputTree(args: {
|
|
|
200
209
|
// anyway so a warning naming several paths reads the same run to run.
|
|
201
210
|
// Read READ_CONCURRENCY at once, inserted in sorted order below: one at a
|
|
202
211
|
// time, 2 000 small inputs cost 304 ms of file-system round trips (F-36).
|
|
203
|
-
|
|
212
|
+
// Deduped: an upstream's output on disk that is also a declared input
|
|
213
|
+
// is named twice, and was read and counted twice.
|
|
214
|
+
const sorted = [...new Set(args.paths)].sort()
|
|
204
215
|
const reads = await mapBounded(sorted, READ_CONCURRENCY, async (rel) => {
|
|
205
216
|
const abs = path.join(args.workspaceRoot, rel)
|
|
206
217
|
// lstat, not stat: a symlinked input must be REPRESENTED as a symlink.
|
|
@@ -244,7 +255,11 @@ export async function buildInputTree(args: {
|
|
|
244
255
|
node.symlinks.set(parts[parts.length - 1]!, { name: parts[parts.length - 1]!, target })
|
|
245
256
|
continue
|
|
246
257
|
}
|
|
247
|
-
if (got.kind === 'other')
|
|
258
|
+
if (got.kind === 'other') {
|
|
259
|
+
// A keyed file that is now a directory (or a socket) is absent too.
|
|
260
|
+
if (args.expected?.has(rel) === true) moved.push(rel)
|
|
261
|
+
continue
|
|
262
|
+
}
|
|
248
263
|
const data = got.data
|
|
249
264
|
const want = args.expected?.get(rel)
|
|
250
265
|
if (want !== undefined && !carriesOid(data, want)) moved.push(rel)
|
|
@@ -331,6 +346,33 @@ export async function buildInputTree(args: {
|
|
|
331
346
|
return { root: rootDigest, blobs, fileCount, shadowed, moved }
|
|
332
347
|
}
|
|
333
348
|
|
|
349
|
+
/**
|
|
350
|
+
* REAPI's sort: names by UTF-8 bytes, which servers compare (Go's string
|
|
351
|
+
* order). JS's default sort is by UTF-16 units, which puts an astral name
|
|
352
|
+
* (an emoji) before one in U+E000-U+FFFF; a server refused the Directory,
|
|
353
|
+
* and another client hashed the same tree to another digest.
|
|
354
|
+
*/
|
|
355
|
+
function byUtf8(a: string, b: string): number {
|
|
356
|
+
const n = Math.min(a.length, b.length)
|
|
357
|
+
for (let i = 0; i < n; i++) {
|
|
358
|
+
let x = a.charCodeAt(i)
|
|
359
|
+
let y = b.charCodeAt(i)
|
|
360
|
+
if (x === y) continue
|
|
361
|
+
// Surrogates (D800-DFFF) are code points past FFFF: move them after E000-FFFF.
|
|
362
|
+
if (x >= 0xd800 && y >= 0xd800) {
|
|
363
|
+
x += x >= 0xe000 ? -0x800 : 0x2000
|
|
364
|
+
y += y >= 0xe000 ? -0x800 : 0x2000
|
|
365
|
+
}
|
|
366
|
+
return x - y
|
|
367
|
+
}
|
|
368
|
+
return a.length - b.length
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/** `xs` sorted by `byUtf8`; the default sort when no unit can differ. */
|
|
372
|
+
function utf8Sorted(xs: string[]): string[] {
|
|
373
|
+
return xs.some((x) => /[\ud800-\uffff]/.test(x)) ? xs.sort(byUtf8) : xs.sort()
|
|
374
|
+
}
|
|
375
|
+
|
|
334
376
|
/**
|
|
335
377
|
* Depth-first, children before parents: a Directory's digest covers its
|
|
336
378
|
* children's digests, so they must be finalised first. REAPI requires `files`
|
|
@@ -340,7 +382,7 @@ export async function buildInputTree(args: {
|
|
|
340
382
|
*/
|
|
341
383
|
function serialise(node: DirNode, blobs: Blob[], seen: Set<string>): Digest {
|
|
342
384
|
const directories: DirectoryNode[] = []
|
|
343
|
-
const dirNames = [...new Set([...node.dirs.keys(), ...node.rawDirs.keys()])]
|
|
385
|
+
const dirNames = utf8Sorted([...new Set([...node.dirs.keys(), ...node.rawDirs.keys()])])
|
|
344
386
|
for (const name of dirNames) {
|
|
345
387
|
const sub = node.dirs.get(name)
|
|
346
388
|
directories.push({
|
|
@@ -351,8 +393,8 @@ function serialise(node: DirNode, blobs: Blob[], seen: Set<string>): Digest {
|
|
|
351
393
|
digest: node.rawDirs.get(name) ?? serialise(sub!, blobs, seen),
|
|
352
394
|
})
|
|
353
395
|
}
|
|
354
|
-
const files = [...node.files.keys()]
|
|
355
|
-
const symlinks = [...node.symlinks.keys()]
|
|
396
|
+
const files = utf8Sorted([...node.files.keys()]).map((n) => node.files.get(n)!)
|
|
397
|
+
const symlinks = utf8Sorted([...node.symlinks.keys()]).map((n) => node.symlinks.get(n)!)
|
|
356
398
|
const dir: Directory = { files, directories, symlinks }
|
|
357
399
|
const data = encodeDirectory(dir)
|
|
358
400
|
const digest = sha256(data)
|
|
@@ -517,7 +559,7 @@ const HASH_ALGO: Partial<Record<DigestFunctionName, string>> = {
|
|
|
517
559
|
*/
|
|
518
560
|
export function digestWith(fn: DigestFunctionName, data: Uint8Array): Digest {
|
|
519
561
|
const algo = HASH_ALGO[fn]
|
|
520
|
-
if (algo === undefined) throw new Error(
|
|
562
|
+
if (algo === undefined) throw new Error(`unsupported digest function ${fn}`)
|
|
521
563
|
return { hash: createHash(algo).update(data).digest('hex'), size_bytes: data.length }
|
|
522
564
|
}
|
|
523
565
|
|
|
@@ -533,6 +575,7 @@ export function canDigest(fn: DigestFunctionName): boolean {
|
|
|
533
575
|
try {
|
|
534
576
|
createHash(algo)
|
|
535
577
|
return true
|
|
578
|
+
// quiet: a hash this runtime lacks is answered by false
|
|
536
579
|
} catch {
|
|
537
580
|
return false
|
|
538
581
|
}
|
|
@@ -584,15 +627,13 @@ interface CommandSpec {
|
|
|
584
627
|
* over these bytes and canonical encoders write ascending field numbers.
|
|
585
628
|
*/
|
|
586
629
|
export function encodeCommand(c: CommandSpec): Uint8Array {
|
|
587
|
-
const env = [...c.environmentVariables].sort((a, b) =>
|
|
588
|
-
|
|
589
|
-
)
|
|
590
|
-
const platform = [...c.platform].sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))
|
|
630
|
+
const env = [...c.environmentVariables].sort((a, b) => byUtf8(a.name, b.name))
|
|
631
|
+
const platform = [...c.platform].sort((a, b) => byUtf8(a.name, b.name))
|
|
591
632
|
return concat([
|
|
592
633
|
...c.arguments.map((a) => repStrField(1, a)),
|
|
593
634
|
...env.map((e) => lenField(2, concat([strField(1, e.name), strField(2, e.value)]))),
|
|
594
|
-
...[...(c.legacyOutputFiles ?? [])]
|
|
595
|
-
...[...(c.legacyOutputDirectories ?? [])]
|
|
635
|
+
...utf8Sorted([...(c.legacyOutputFiles ?? [])]).map((p) => repStrField(3, p)),
|
|
636
|
+
...utf8Sorted([...(c.legacyOutputDirectories ?? [])]).map((p) => repStrField(4, p)),
|
|
596
637
|
...(platform.length > 0
|
|
597
638
|
? [
|
|
598
639
|
lenField(
|
|
@@ -604,8 +645,8 @@ export function encodeCommand(c: CommandSpec): Uint8Array {
|
|
|
604
645
|
]
|
|
605
646
|
: []),
|
|
606
647
|
...(c.workingDirectory === '' ? [] : [strField(6, c.workingDirectory)]),
|
|
607
|
-
...[...c.outputPaths]
|
|
608
|
-
...[...(c.outputNodeProperties ?? [])]
|
|
648
|
+
...utf8Sorted([...c.outputPaths]).map((p) => repStrField(7, p)),
|
|
649
|
+
...utf8Sorted([...(c.outputNodeProperties ?? [])]).map((n) => repStrField(8, n)),
|
|
609
650
|
...(c.outputDirectoryFormat === undefined || c.outputDirectoryFormat === 0
|
|
610
651
|
? []
|
|
611
652
|
: [intField(9, c.outputDirectoryFormat)]),
|
|
@@ -627,9 +668,7 @@ export function encodeAction(a: {
|
|
|
627
668
|
/** `platform` (field 10) — REAPI v2.2 moved it here from `Command`. */
|
|
628
669
|
platform?: ReadonlyArray<{ name: string; value: string }>
|
|
629
670
|
}): Uint8Array {
|
|
630
|
-
const platform = [...(a.platform ?? [])].sort((x, y) =>
|
|
631
|
-
x.name < y.name ? -1 : x.name > y.name ? 1 : 0,
|
|
632
|
-
)
|
|
671
|
+
const platform = [...(a.platform ?? [])].sort((x, y) => byUtf8(x.name, y.name))
|
|
633
672
|
return concat([
|
|
634
673
|
lenField(1, encodeDigest(a.commandDigest)),
|
|
635
674
|
lenField(2, encodeDigest(a.inputRootDigest)),
|