@vzn/vx-history 0.0.652 → 0.0.653
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 +14 -6
- package/package.json +2 -2
- package/src/history-server.ts +230 -81
- package/src/index.ts +84 -48
- package/src/timings-file.ts +60 -30
package/README.md
CHANGED
|
@@ -56,10 +56,14 @@ timings of the tasks it ran, once: under DTE that is the orchestrator,
|
|
|
56
56
|
with each worker's measurements. The upload starts as the last task
|
|
57
57
|
ends, beside core's own end of run, and the plugin's teardown waits at
|
|
58
58
|
most 2 s more for it (core bounds a teardown at 3 s); one not done by
|
|
59
|
-
then is dropped and said, with how many timings it held
|
|
59
|
+
then is dropped and said, with how many timings it held and the status
|
|
60
|
+
the server last answered, if one came. A task that shared a crowded worker's
|
|
60
61
|
cores is sent as long as it would have taken alone, and its cores at
|
|
61
62
|
most its machine's, as local history keeps them. A refusal is said with the server's
|
|
62
|
-
words
|
|
63
|
+
words, each token the request sent written `***`. Every warning shows the server's url with a `user:pass@` as
|
|
64
|
+
`***@`. An answer is read no further than the protocol's largest (64 KiB,
|
|
65
|
+
and 2 KiB more per hash a query asks): a longer one, a server's endless
|
|
66
|
+
stream, is refused as `answered over N bytes`. A refused token (401) also names the repository and issuer the
|
|
63
67
|
workspace declares and the audience it must carry, and the server is
|
|
64
68
|
off for the rest of the run. A CI job with no token says so once (on GitHub Actions,
|
|
65
69
|
`GITHUB_ACTIONS`, naming the permission).
|
|
@@ -80,7 +84,9 @@ naming where it pointed.
|
|
|
80
84
|
|
|
81
85
|
The same upload carries the flaky record: each cached task the run
|
|
82
86
|
ran, by definition and cache key, with how it ended (`pass`, `fail`,
|
|
83
|
-
or `flaky` when it passed on a retry).
|
|
87
|
+
or `flaky` when it passed on a retry). A task that ran behind a failed
|
|
88
|
+
dependency (`--continue=always`) is left out: its result is not its
|
|
89
|
+
key's. The server names a task flaky
|
|
84
90
|
when one key failed and later passed, or a run saw it flake, on any
|
|
85
91
|
branch of the repository. The run's query answers which of its tasks
|
|
86
92
|
are flaky, and core gives such a task one retry when it fails, as it
|
|
@@ -137,9 +143,11 @@ The file is `{ "version": 1, "tasks": { "<task id>": <ms> } }`, keys
|
|
|
137
143
|
sorted; an entry that is not a number of ms from 0 to 2^53 is dropped. A p50 recorded on this machine wins over it, and it wins over
|
|
138
144
|
`assume`; like `assume`, it never feeds the workspace median. A task the
|
|
139
145
|
run did not see keeps its time, one another writer put there while the
|
|
140
|
-
run ran included. A
|
|
141
|
-
|
|
142
|
-
|
|
146
|
+
run ran included. A run that executed nothing and restored only tasks
|
|
147
|
+
the file already times leaves it as it was; an all-cached run writes a
|
|
148
|
+
missing one. A file that is not one warns, is read as empty and is never
|
|
149
|
+
written over; one it cannot write warns and the run goes on; `--dry`
|
|
150
|
+
writes nothing.
|
|
143
151
|
|
|
144
152
|
## Reservations learned from history
|
|
145
153
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vzn/vx-history",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.653",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"description": "History-based scheduling for vx — a `schedule` plugin that orders ready tasks by their expected remaining critical path, learned from the local run history.",
|
|
6
6
|
"keywords": [
|
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
"access": "public"
|
|
38
38
|
},
|
|
39
39
|
"peerDependencies": {
|
|
40
|
-
"@vzn/vx": "^0.0.
|
|
40
|
+
"@vzn/vx": "^0.0.653"
|
|
41
41
|
},
|
|
42
42
|
"repository": {
|
|
43
43
|
"type": "git",
|
package/src/history-server.ts
CHANGED
|
@@ -36,8 +36,8 @@ export interface Answer {
|
|
|
36
36
|
* named: neither says anything of the key.
|
|
37
37
|
*/
|
|
38
38
|
export function outcomeOf(o: TaskOutcome, def: string): ServerOutcome | undefined {
|
|
39
|
-
if (o.node.config.cache
|
|
40
|
-
if (o.blockedBy !== undefined) return undefined
|
|
39
|
+
if (o.node.config.cache !== true || o.hash === undefined || o.hash === '') return undefined
|
|
40
|
+
if (o.blockedBy !== undefined || o.taintedBy !== undefined) return undefined
|
|
41
41
|
if (o.status === 'failed') return { hash: def, key: o.hash, outcome: 'fail' }
|
|
42
42
|
if (o.status !== 'success') return undefined
|
|
43
43
|
const flaky = (o.attempts ?? 1) > 1 || o.flaky !== undefined
|
|
@@ -108,10 +108,56 @@ const printable = (s: string): string =>
|
|
|
108
108
|
// oxlint-disable-next-line no-control-regex
|
|
109
109
|
s.replace(/[\x00-\x1f\x7f-\x9f]/g, (c) => `\\x${c.charCodeAt(0).toString(16).padStart(2, '0')}`)
|
|
110
110
|
|
|
111
|
-
/**
|
|
112
|
-
|
|
111
|
+
/**
|
|
112
|
+
* A body's first `max` bytes as text, the rest cancelled unread, and
|
|
113
|
+
* whether there was more: a server's endless answer grew vx to 3 GB in
|
|
114
|
+
* 10 s (vx-cache's `upTo`).
|
|
115
|
+
*/
|
|
116
|
+
async function upTo(res: Response, max: number): Promise<{ text: string; over: boolean }> {
|
|
117
|
+
const reader = res.body?.getReader()
|
|
118
|
+
if (reader === undefined) return { text: '', over: false }
|
|
119
|
+
const parts: Uint8Array[] = []
|
|
120
|
+
let n = 0
|
|
121
|
+
for (;;) {
|
|
122
|
+
const { done, value } = await reader.read()
|
|
123
|
+
if (done) break
|
|
124
|
+
parts.push(value)
|
|
125
|
+
n += value.byteLength
|
|
126
|
+
if (n > max) {
|
|
127
|
+
// quiet: the answer is refused for its size; its cancel adds nothing
|
|
128
|
+
await reader.cancel().catch(() => {})
|
|
129
|
+
return { text: Buffer.concat(parts).subarray(0, max).toString('utf8'), over: true }
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return { text: Buffer.concat(parts).toString('utf8'), over: false }
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** A CI token is a few hundred bytes: room to spare. */
|
|
136
|
+
const SMALL_ANSWER_BYTES = 64 * 1024
|
|
137
|
+
/**
|
|
138
|
+
* A query's answer per hash asked: its row (the hash, up to 512 bytes, and
|
|
139
|
+
* four numbers) and its place in `flaky` are under 1 200 bytes in JSON.
|
|
140
|
+
*/
|
|
141
|
+
const QUERY_BYTES_PER_HASH = 2048
|
|
142
|
+
|
|
143
|
+
/** Shorter values are not hidden, as core masks none under 6 characters: each would blank common words. */
|
|
144
|
+
const MIN_TOKEN_CHARS = 6
|
|
145
|
+
|
|
146
|
+
/** `text` with each of `tokens` written `***`. */
|
|
147
|
+
function hideTokens(text: string, tokens: Iterable<string>): string {
|
|
148
|
+
let out = text
|
|
149
|
+
for (const token of tokens)
|
|
150
|
+
if (token.length >= MIN_TOKEN_CHARS) out = out.replaceAll(token, '***')
|
|
151
|
+
return out
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* A refusal, named by its status and the server's own `message`, each
|
|
156
|
+
* token `hide` knows written `***` before the cut, so none is left in part.
|
|
157
|
+
*/
|
|
158
|
+
async function refusal(res: Response, hide: (text: string) => string): Promise<Error> {
|
|
113
159
|
// quiet: with the body unread, the status alone names the refusal
|
|
114
|
-
const text =
|
|
160
|
+
const { text } = await upTo(res, 2048).catch(() => ({ text: '' }))
|
|
115
161
|
let said = text
|
|
116
162
|
try {
|
|
117
163
|
const m = (JSON.parse(text) as { message?: unknown }).message
|
|
@@ -119,13 +165,14 @@ async function refusal(res: Response): Promise<Error> {
|
|
|
119
165
|
} catch {
|
|
120
166
|
// quiet: not JSON, so the text is what it said
|
|
121
167
|
}
|
|
122
|
-
said = printable(said.replace(/\s+/g, ' ').trim().slice(0, 200))
|
|
168
|
+
said = printable(hide(said).replace(/\s+/g, ' ').trim().slice(0, 200))
|
|
123
169
|
return new Error(`HTTP ${res.status}${said === '' ? '' : `: ${said}`}`)
|
|
124
170
|
}
|
|
125
171
|
|
|
126
|
-
/** A 200's JSON; one that is none (a website's page) names the likely fault, the url. */
|
|
127
|
-
async function jsonOf(res: Response, path: string): Promise<unknown> {
|
|
128
|
-
const text = await res
|
|
172
|
+
/** A 200's JSON of at most `max` bytes; one that is none (a website's page) names the likely fault, the url. */
|
|
173
|
+
async function jsonOf(res: Response, path: string, max: number): Promise<unknown> {
|
|
174
|
+
const { text, over } = await upTo(res, max)
|
|
175
|
+
if (over) throw new Error(`${path} answered over ${max} bytes`)
|
|
129
176
|
try {
|
|
130
177
|
return JSON.parse(text)
|
|
131
178
|
} catch {
|
|
@@ -142,9 +189,15 @@ async function jsonOf(res: Response, path: string): Promise<unknown> {
|
|
|
142
189
|
*/
|
|
143
190
|
async function redirected(res: Response, from: string): Promise<Error> {
|
|
144
191
|
await res.body?.cancel()
|
|
145
|
-
const
|
|
192
|
+
const location = res.headers.get('location')
|
|
193
|
+
const to = location === null ? null : new URL(location, from)
|
|
194
|
+
// A relative Location keeps the url's `user:pass@`, and the error reaches the CI log.
|
|
195
|
+
if (to !== null && to.username + to.password !== '') {
|
|
196
|
+
to.username = '***'
|
|
197
|
+
to.password = ''
|
|
198
|
+
}
|
|
146
199
|
return new Error(
|
|
147
|
-
`HTTP ${res.status}${to === null ? '' : ` to ${
|
|
200
|
+
`HTTP ${res.status}${to === null ? '' : ` to ${to.href}`}: not followed, as it would carry the server's headers; name the server's own URL`,
|
|
148
201
|
)
|
|
149
202
|
}
|
|
150
203
|
|
|
@@ -167,17 +220,46 @@ function expiryOf(jwt: string): number {
|
|
|
167
220
|
const isCount = (n: unknown): n is number =>
|
|
168
221
|
typeof n === 'number' && Number.isSafeInteger(n) && n >= 0
|
|
169
222
|
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
223
|
+
/**
|
|
224
|
+
* A request refused for its size, its refusal naming a smaller limit
|
|
225
|
+
* (`vx-max-*`): sent again in parts of at most `max`.
|
|
226
|
+
*/
|
|
227
|
+
class TooMany extends Error {
|
|
228
|
+
constructor(readonly max: number) {
|
|
229
|
+
super(`at most ${max} a request`)
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** The limit `header` names, when it is a whole number above 0. */
|
|
234
|
+
function limitOf(res: Response, header: string): number | undefined {
|
|
235
|
+
const n = Number(res.headers.get(header) ?? NaN)
|
|
236
|
+
return Number.isSafeInteger(n) && n > 0 ? n : undefined
|
|
173
237
|
}
|
|
174
238
|
|
|
175
239
|
export class HistoryServer {
|
|
176
240
|
private token: Promise<string> | undefined
|
|
177
241
|
private held: { jwt: string; exp: number } | undefined
|
|
178
|
-
private limits: Promise<Limits> | undefined
|
|
179
242
|
// A refused token is refused again: the run's later calls stay unasked and unsaid.
|
|
180
243
|
private refused = false
|
|
244
|
+
/**
|
|
245
|
+
* Every token this client sent: a server that quoted a request's
|
|
246
|
+
* Authorization header in its refusal printed it to the CI log, as core
|
|
247
|
+
* masks only secret-named variables.
|
|
248
|
+
*/
|
|
249
|
+
private readonly sent = new Set<string>()
|
|
250
|
+
private readonly hidden = (text: string): string => hideTokens(text, this.sent)
|
|
251
|
+
|
|
252
|
+
/** `work`, each error it throws with every token sent hidden: the one way an error leaves the client. */
|
|
253
|
+
private async guard<T>(work: Promise<T>): Promise<T> {
|
|
254
|
+
try {
|
|
255
|
+
return await work
|
|
256
|
+
} catch (err) {
|
|
257
|
+
const message = err instanceof Error ? err.message : String(err)
|
|
258
|
+
const hidden = this.hidden(message)
|
|
259
|
+
if (hidden === message) throw err
|
|
260
|
+
throw new Error(hidden)
|
|
261
|
+
}
|
|
262
|
+
}
|
|
181
263
|
|
|
182
264
|
constructor(
|
|
183
265
|
readonly url: string,
|
|
@@ -209,9 +291,16 @@ export class HistoryServer {
|
|
|
209
291
|
const u = new URL(ask)
|
|
210
292
|
u.searchParams.set('audience', new URL(this.url).origin)
|
|
211
293
|
const res = await this.askGithub(u, signal)
|
|
212
|
-
if (!res.ok)
|
|
213
|
-
|
|
214
|
-
const
|
|
294
|
+
if (!res.ok)
|
|
295
|
+
throw new Error(`GitHub's OIDC endpoint: ${(await refusal(res, this.hidden)).message}`)
|
|
296
|
+
const { text, over } = await upTo(res, SMALL_ANSWER_BYTES)
|
|
297
|
+
if (over) throw new Error(`GitHub's OIDC endpoint: answered over ${SMALL_ANSWER_BYTES} bytes`)
|
|
298
|
+
let jwt: unknown
|
|
299
|
+
try {
|
|
300
|
+
jwt = (JSON.parse(text) as { value?: unknown } | null)?.value
|
|
301
|
+
} catch {
|
|
302
|
+
// quiet: a body that is not JSON holds no token, said below
|
|
303
|
+
}
|
|
215
304
|
if (typeof jwt !== 'string' || jwt === '') throw new Error("GitHub's OIDC endpoint: no token")
|
|
216
305
|
return { jwt, exp: expiryOf(jwt) }
|
|
217
306
|
})()
|
|
@@ -253,6 +342,7 @@ export class HistoryServer {
|
|
|
253
342
|
|
|
254
343
|
/** GitHub's OIDC endpoint: a 4xx is final. */
|
|
255
344
|
private askGithub(ask: URL, signal?: AbortSignal): Promise<Response> {
|
|
345
|
+
this.sent.add(this.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN ?? '')
|
|
256
346
|
return this.tried(
|
|
257
347
|
() =>
|
|
258
348
|
this.fetchImpl(ask, {
|
|
@@ -263,8 +353,20 @@ export class HistoryServer {
|
|
|
263
353
|
)
|
|
264
354
|
}
|
|
265
355
|
|
|
266
|
-
|
|
356
|
+
/**
|
|
357
|
+
* One request. `sized`: its body's count of items and the `vx-max-*`
|
|
358
|
+
* header that limits them; a refusal naming a smaller limit throws
|
|
359
|
+
* `TooMany`.
|
|
360
|
+
*/
|
|
361
|
+
private async call(
|
|
362
|
+
path: string,
|
|
363
|
+
body?: unknown,
|
|
364
|
+
signal?: AbortSignal,
|
|
365
|
+
sized?: { count: number; header: string },
|
|
366
|
+
answered?: (status: number | undefined) => void,
|
|
367
|
+
): Promise<Response> {
|
|
267
368
|
const bearer = this.writes ? await this.bearer(signal) : ''
|
|
369
|
+
if (bearer !== '') this.sent.add(bearer)
|
|
268
370
|
const json = body === undefined ? undefined : JSON.stringify(body)
|
|
269
371
|
// A 500 is the server's own failure, maybe after the fold: sent again, a
|
|
270
372
|
// run would count twice. The edge's 502, 503 and 504 and a 429 come
|
|
@@ -272,8 +374,9 @@ export class HistoryServer {
|
|
|
272
374
|
// would add seconds to every run's end.
|
|
273
375
|
const url = `${this.url}${path}`
|
|
274
376
|
const res = await this.tried(
|
|
275
|
-
() =>
|
|
276
|
-
|
|
377
|
+
() => {
|
|
378
|
+
answered?.(undefined)
|
|
379
|
+
return this.fetchImpl(url, {
|
|
277
380
|
method: json === undefined ? 'GET' : 'POST',
|
|
278
381
|
headers: {
|
|
279
382
|
...this.headers,
|
|
@@ -283,13 +386,17 @@ export class HistoryServer {
|
|
|
283
386
|
...(json === undefined ? {} : { body: json }),
|
|
284
387
|
signal: deadline(signal),
|
|
285
388
|
redirect: 'manual',
|
|
286
|
-
})
|
|
389
|
+
}).then((r) => {
|
|
390
|
+
answered?.(r.status)
|
|
391
|
+
return r
|
|
392
|
+
})
|
|
393
|
+
},
|
|
287
394
|
(status) => status !== undefined && (status === 429 || (status >= 502 && status <= 504)),
|
|
288
395
|
)
|
|
289
396
|
if (res.status >= 300 && res.status < 400) throw await redirected(res, url)
|
|
290
397
|
if (res.status === 401 && bearer === '') {
|
|
291
398
|
// Off CI: the bare 401 said nothing of why or what to do (2026-10-26).
|
|
292
|
-
const err = await refusal(res)
|
|
399
|
+
const err = await refusal(res, this.hidden)
|
|
293
400
|
const fix =
|
|
294
401
|
this.env.GITHUB_ACTIONS === 'true'
|
|
295
402
|
? 'give the job `permissions: id-token: write`'
|
|
@@ -301,67 +408,100 @@ export class HistoryServer {
|
|
|
301
408
|
if (res.status === 401) {
|
|
302
409
|
// The bare "HTTP 401: wrong audience" named neither what was sent nor what to check.
|
|
303
410
|
this.refused = true
|
|
304
|
-
const err = await refusal(res)
|
|
411
|
+
const err = await refusal(res, this.hidden)
|
|
305
412
|
throw new Error(
|
|
306
413
|
`${err.message}: the token was refused (this workspace names ${this.named.repository} at ${this.named.issuer}; the server's ALLOW must name it, and the token's audience be ${new URL(this.url).origin}); the history server is off for this run`,
|
|
307
414
|
)
|
|
308
415
|
}
|
|
309
|
-
if (!res.ok)
|
|
416
|
+
if (!res.ok) {
|
|
417
|
+
const max = sized === undefined ? undefined : limitOf(res, sized.header)
|
|
418
|
+
if (max !== undefined && sized!.count > max) {
|
|
419
|
+
await res.body?.cancel()
|
|
420
|
+
throw new TooMany(max)
|
|
421
|
+
}
|
|
422
|
+
throw await refusal(res, this.hidden)
|
|
423
|
+
}
|
|
310
424
|
return res
|
|
311
425
|
}
|
|
312
426
|
|
|
313
|
-
/**
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
427
|
+
/**
|
|
428
|
+
* `items` in requests of `send`, whole until an answer names the limit
|
|
429
|
+
* (`vx-max-*`): one refused for its size is sent again in parts of the
|
|
430
|
+
* limit it names, each a smaller one than the last. Nothing is asked apart: `/v1/capabilities` cost
|
|
431
|
+
* every run a request, and the query a round-trip (2026-11-06). A query
|
|
432
|
+
* sends its parts at once; an upload one after another, so a refused
|
|
433
|
+
* part resends no part the server folded.
|
|
434
|
+
*/
|
|
435
|
+
private async parts<T, R>(
|
|
436
|
+
items: readonly T[],
|
|
437
|
+
header: string,
|
|
438
|
+
atOnce: boolean,
|
|
439
|
+
send: (part: readonly T[], sized?: { count: number; header: string }) => Promise<R>,
|
|
440
|
+
): Promise<R[]> {
|
|
441
|
+
let per = this.limits.get(header) ?? items.length
|
|
442
|
+
const sized = (part: readonly T[]) => ({ count: part.length, header })
|
|
443
|
+
const learn = (err: unknown): void => {
|
|
444
|
+
if (!(err instanceof TooMany) || this.limits.get(header) === err.max) throw err
|
|
445
|
+
this.limits.set(header, err.max)
|
|
446
|
+
per = err.max
|
|
447
|
+
}
|
|
448
|
+
const split = (from: readonly T[]) =>
|
|
449
|
+
Array.from({ length: Math.ceil(from.length / per) }, (_, i) =>
|
|
450
|
+
from.slice(i * per, (i + 1) * per),
|
|
451
|
+
)
|
|
452
|
+
if (atOnce) {
|
|
453
|
+
const first = await Promise.allSettled(split(items).map((p) => send(p, sized(p))))
|
|
454
|
+
const refused = first.flatMap((r) => (r.status === 'rejected' ? [r.reason as unknown] : []))
|
|
455
|
+
const failed = refused.find((err) => !(err instanceof TooMany))
|
|
456
|
+
if (failed !== undefined) throw failed
|
|
457
|
+
if (refused.length === 0) return first.map((r) => (r as PromiseFulfilledResult<R>).value)
|
|
458
|
+
learn(refused[0])
|
|
459
|
+
return Promise.all(split(items).map((p) => send(p)))
|
|
460
|
+
}
|
|
461
|
+
const out: R[] = []
|
|
462
|
+
for (let at = 0; at < items.length;) {
|
|
463
|
+
const part = items.slice(at, at + per)
|
|
464
|
+
try {
|
|
465
|
+
out.push(await send(part, sized(part)))
|
|
466
|
+
at += part.length
|
|
467
|
+
} catch (err) {
|
|
468
|
+
learn(err)
|
|
327
469
|
}
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
// quiet: the awaited ask rethrows it; this only forgets it
|
|
331
|
-
asked.catch(() => {
|
|
332
|
-
if (this.limits === asked) this.limits = undefined
|
|
333
|
-
})
|
|
334
|
-
return asked
|
|
470
|
+
}
|
|
471
|
+
return out
|
|
335
472
|
}
|
|
336
473
|
|
|
474
|
+
/** The limits a refusal named this run, by header: later requests keep to them. */
|
|
475
|
+
private readonly limits = new Map<string, number>()
|
|
476
|
+
|
|
337
477
|
/** Each held hash's numbers under the first of `refs` that holds them, and the flaky ones: one request a run. */
|
|
338
478
|
async query(refs: readonly string[], hashes: readonly string[]): Promise<Answer> {
|
|
339
479
|
if (hashes.length === 0 || this.refused) return { timings: new Map(), flaky: new Set() }
|
|
340
|
-
const
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
)
|
|
480
|
+
const asked = this.parts(hashes, 'vx-max-query-hashes', true, async (part, sized) => {
|
|
481
|
+
const res = await this.call(
|
|
482
|
+
'/v1/timings/query',
|
|
483
|
+
{ ...this.named, refs, hashes: part },
|
|
484
|
+
undefined,
|
|
485
|
+
sized,
|
|
486
|
+
)
|
|
487
|
+
const max = SMALL_ANSWER_BYTES + part.length * QUERY_BYTES_PER_HASH
|
|
488
|
+
const body = (await jsonOf(res, '/v1/timings/query', max)) as {
|
|
489
|
+
timings?: unknown
|
|
490
|
+
flaky?: unknown
|
|
491
|
+
} | null
|
|
492
|
+
if (typeof body?.timings !== 'object' || body.timings === null) {
|
|
493
|
+
throw new Error('the query answered no `timings` object')
|
|
494
|
+
}
|
|
495
|
+
// A server before the flaky record answers none: nothing is flaky.
|
|
496
|
+
const flaky = Array.isArray(body.flaky)
|
|
497
|
+
? body.flaky.filter((h): h is string => typeof h === 'string')
|
|
498
|
+
: []
|
|
499
|
+
return {
|
|
500
|
+
timings: Object.entries(body.timings as Record<string, ServerTiming | null>),
|
|
501
|
+
flaky,
|
|
502
|
+
}
|
|
503
|
+
})
|
|
504
|
+
const parts = await this.guard(asked)
|
|
365
505
|
// Only numbers a server folds (PROTOCOL.md): `1e309` parses to Infinity,
|
|
366
506
|
// and kept as a forecast it made a NaN weight that failed every later
|
|
367
507
|
// run of the cache in `schedule`.
|
|
@@ -381,23 +521,32 @@ export class HistoryServer {
|
|
|
381
521
|
|
|
382
522
|
/**
|
|
383
523
|
* The run's timings, by task definition hash, and its outcomes, in
|
|
384
|
-
* uploads
|
|
524
|
+
* uploads the server takes; `stop` abandons them. `answered` hears each
|
|
525
|
+
* try's status, undefined as it is sent.
|
|
385
526
|
*/
|
|
386
527
|
async upload(
|
|
387
528
|
timings: ReadonlyMap<string, ServerTiming>,
|
|
388
529
|
stop?: AbortSignal,
|
|
389
530
|
outcomes: readonly ServerOutcome[] = [],
|
|
531
|
+
answered?: (status: number | undefined) => void,
|
|
390
532
|
): Promise<void> {
|
|
391
533
|
if ((timings.size === 0 && outcomes.length === 0) || this.refused) return
|
|
392
|
-
const per = (await this.limitsNow()).maxUploadTimings
|
|
393
534
|
const all = [...timings].map(([hash, t]) => ({ hash, ...t }))
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
535
|
+
const at = Array.from({ length: Math.max(all.length, outcomes.length) }, (_, i) => i)
|
|
536
|
+
// A refused upload folded nothing: sent again in parts, none counts twice.
|
|
537
|
+
await this.guard(
|
|
538
|
+
this.parts(at, 'vx-max-upload-timings', false, (part, sized) => {
|
|
539
|
+
const from = part[0] ?? 0
|
|
540
|
+
const to = from + part.length
|
|
541
|
+
const some = outcomes.slice(from, to)
|
|
542
|
+
return this.call(
|
|
543
|
+
'/v1/timings/upload',
|
|
544
|
+
{ timings: all.slice(from, to), ...(some.length > 0 ? { outcomes: some } : {}) },
|
|
545
|
+
stop,
|
|
546
|
+
sized,
|
|
547
|
+
answered,
|
|
548
|
+
)
|
|
549
|
+
}),
|
|
550
|
+
)
|
|
402
551
|
}
|
|
403
552
|
}
|
package/src/index.ts
CHANGED
|
@@ -189,15 +189,16 @@ const headroomOf = (options: VxHistoryOptions): number => {
|
|
|
189
189
|
return usable(h) ? h : DEFAULT_HEADROOM
|
|
190
190
|
}
|
|
191
191
|
|
|
192
|
-
// The
|
|
193
|
-
|
|
192
|
+
// The file takes each task's p50 alone (`durationsFor`): `resourcesFor`'s
|
|
193
|
+
// grouped reads of the window were 40 of its 55 ms at 8,002 tasks.
|
|
194
|
+
const readDurations = (
|
|
194
195
|
cache: Cache,
|
|
195
196
|
ids: readonly string[],
|
|
196
197
|
options: VxHistoryOptions,
|
|
197
198
|
branch: string | null,
|
|
198
199
|
defs: ReadonlyMap<string, string>,
|
|
199
|
-
): Promise<ReadonlyMap<string,
|
|
200
|
-
localHistory(cache, { recent: windowOf(options), branch, defs }).
|
|
200
|
+
): Promise<ReadonlyMap<string, number>> =>
|
|
201
|
+
localHistory(cache, { recent: windowOf(options), branch, defs }).durationsFor(ids)
|
|
201
202
|
|
|
202
203
|
/** What each task reserves: learned from the history unless `resources: false`, a declared reservation over either. */
|
|
203
204
|
const reservationsFor = (
|
|
@@ -248,13 +249,6 @@ function assumptions(
|
|
|
248
249
|
const isDuration = (ms: unknown): ms is number =>
|
|
249
250
|
typeof ms === 'number' && ms >= 0 && ms <= Number.MAX_SAFE_INTEGER
|
|
250
251
|
|
|
251
|
-
/** The timings file's durations a run can take; the rest are not carried. */
|
|
252
|
-
function carriedOf(read: Readonly<Record<string, number>>): Record<string, number> {
|
|
253
|
-
const out: Record<string, number> = {}
|
|
254
|
-
for (const [id, ms] of Object.entries(read)) if (isDuration(ms)) out[id] = ms
|
|
255
|
-
return out
|
|
256
|
-
}
|
|
257
|
-
|
|
258
252
|
/** Each option `VxHistoryOptions` names, with its kind: derived from the type, so the two cannot drift. */
|
|
259
253
|
const VX_HISTORY_KEYS: PluginOptionKinds<VxHistoryOptions> = {
|
|
260
254
|
window: 'number',
|
|
@@ -411,7 +405,7 @@ export function vxHistory(options: VxHistoryOptions = {}): VxPlugin {
|
|
|
411
405
|
const carried =
|
|
412
406
|
file === undefined
|
|
413
407
|
? {}
|
|
414
|
-
:
|
|
408
|
+
: ((await import('./timings-file.js')).readTimings(file, ctx.warn) ?? {})
|
|
415
409
|
const defs = taskDefinitions(nodes.values())
|
|
416
410
|
if (file !== undefined)
|
|
417
411
|
scheduled = {
|
|
@@ -495,7 +489,7 @@ export function vxHistory(options: VxHistoryOptions = {}): VxPlugin {
|
|
|
495
489
|
})().catch((err: unknown) => {
|
|
496
490
|
if (!late) {
|
|
497
491
|
ctx.warn(
|
|
498
|
-
`[vx] vx-history: the timings of the history server at ${server.url} were not applied: ${err instanceof Error ? err.message : String(err)}`,
|
|
492
|
+
`[vx] vx-history: the timings of the history server at ${shown(server.url)} were not applied: ${err instanceof Error ? err.message : String(err)}`,
|
|
499
493
|
)
|
|
500
494
|
}
|
|
501
495
|
return undefined
|
|
@@ -537,11 +531,20 @@ export function vxHistory(options: VxHistoryOptions = {}): VxPlugin {
|
|
|
537
531
|
defs: ReadonlyMap<string, string>
|
|
538
532
|
}
|
|
539
533
|
| undefined
|
|
540
|
-
|
|
541
|
-
//
|
|
542
|
-
//
|
|
543
|
-
let
|
|
544
|
-
|
|
534
|
+
// The run's side of the file, from `setup`: the tasks it decided, and
|
|
535
|
+
// what reads the history when core asked no `schedule` (every task a
|
|
536
|
+
// confirmed local hit), so a missing file is written then too.
|
|
537
|
+
let fileRun:
|
|
538
|
+
| {
|
|
539
|
+
file: string
|
|
540
|
+
root: string
|
|
541
|
+
warn: (m: string) => void
|
|
542
|
+
cacheDir: string
|
|
543
|
+
branch: string | null
|
|
544
|
+
nodes: Map<string, TaskNode>
|
|
545
|
+
ran: boolean
|
|
546
|
+
}
|
|
547
|
+
| undefined
|
|
545
548
|
hooks.setup = async (ctx) => {
|
|
546
549
|
remote ??= await serverOf()
|
|
547
550
|
if (remote?.writes === true) {
|
|
@@ -581,16 +584,22 @@ export function vxHistory(options: VxHistoryOptions = {}): VxPlugin {
|
|
|
581
584
|
)
|
|
582
585
|
}
|
|
583
586
|
if (options.file === undefined) return
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
+
const run: NonNullable<typeof fileRun> = {
|
|
588
|
+
file: path.resolve(ctx.workspaceRoot, options.file),
|
|
589
|
+
root: ctx.workspaceRoot,
|
|
590
|
+
warn: ctx.warn,
|
|
591
|
+
cacheDir: ctx.cacheDir,
|
|
592
|
+
branch: ctx.branch,
|
|
593
|
+
nodes: new Map(),
|
|
594
|
+
ran: false,
|
|
595
|
+
}
|
|
596
|
+
fileRun = run
|
|
587
597
|
ctx.on('onTaskComplete', (node, outcome) => {
|
|
588
598
|
// A group runs nothing and is never timed: counting it rewrote the
|
|
589
599
|
// file on every all-cached run that went through one.
|
|
590
600
|
if (node.config.exec?.command === undefined) return
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
}
|
|
601
|
+
run.nodes.set(node.id, node)
|
|
602
|
+
if (outcome.status === 'success') run.ran = true
|
|
594
603
|
})
|
|
595
604
|
}
|
|
596
605
|
// After the run's history is recorded and before its cache closes.
|
|
@@ -605,33 +614,56 @@ export function vxHistory(options: VxHistoryOptions = {}): VxPlugin {
|
|
|
605
614
|
uploading = undefined
|
|
606
615
|
// Counted from here: the file's write below shares the bound.
|
|
607
616
|
const waited = server !== undefined && sent !== undefined ? waitFor(UPLOAD_WAIT_MS) : undefined
|
|
608
|
-
const
|
|
609
|
-
const
|
|
617
|
+
const sched = scheduled
|
|
618
|
+
const done = fileRun
|
|
610
619
|
scheduled = undefined
|
|
611
|
-
|
|
620
|
+
fileRun = undefined
|
|
612
621
|
// The file first: core bounds a teardown (3 s), and an upload ahead of
|
|
613
622
|
// it held the write hostage to the server's network. A failed write
|
|
614
623
|
// still sends.
|
|
615
624
|
try {
|
|
616
|
-
if (
|
|
617
|
-
const table = await readHistory(done.cache, done.ids, options, done.branch, done.defs)
|
|
625
|
+
if (done !== undefined && done.nodes.size > 0) {
|
|
618
626
|
const timings = await import('./timings-file.js')
|
|
619
|
-
//
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
//
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
627
|
+
// With no `schedule`, the file is first read here, and its fault said here.
|
|
628
|
+
let now = sched === undefined ? timings.readTimings(done.file, done.warn) : undefined
|
|
629
|
+
const carried = sched?.carried ?? now ?? {}
|
|
630
|
+
// A run that executed nothing and restored only tasks the file already
|
|
631
|
+
// times leaves it as it was: an all-cached CI run re-reads no history.
|
|
632
|
+
if (done.ran || [...done.nodes.keys()].some((id) => !(id in carried))) {
|
|
633
|
+
// Over the file as it is now: a time another writer (a run in another
|
|
634
|
+
// worktree sharing it) put there since `schedule` read it is kept. Its
|
|
635
|
+
// fault, if any, was said when the run read it.
|
|
636
|
+
if (sched !== undefined) now = timings.readTimings(done.file, () => {})
|
|
637
|
+
// Not a timings file (a mistyped `file`): never written over.
|
|
638
|
+
if (now !== undefined) {
|
|
639
|
+
// Asked now, answered beside the read and the write.
|
|
640
|
+
const ignored = timings.askIgnored(done.file, done.root)
|
|
641
|
+
const cache = sched?.cache ?? new Cache(done.cacheDir)
|
|
642
|
+
let table: ReadonlyMap<string, number>
|
|
643
|
+
try {
|
|
644
|
+
table = await readDurations(
|
|
645
|
+
cache,
|
|
646
|
+
sched?.ids ?? [...done.nodes.keys()],
|
|
647
|
+
options,
|
|
648
|
+
sched?.branch ?? done.branch,
|
|
649
|
+
sched?.defs ?? taskDefinitions(done.nodes.values()),
|
|
650
|
+
)
|
|
651
|
+
} finally {
|
|
652
|
+
if (sched === undefined) cache.close()
|
|
653
|
+
}
|
|
654
|
+
let written = true
|
|
655
|
+
try {
|
|
656
|
+
timings.writeTimings(done.file, { ...carried, ...now }, table)
|
|
657
|
+
} catch (err) {
|
|
658
|
+
// Only the file is lost: said as the read is, not as a plugin failure.
|
|
659
|
+
written = false
|
|
660
|
+
done.warn(
|
|
661
|
+
`[vx] vx-history: timings file ${done.file} not written: ${err instanceof Error ? err.message : String(err)}`,
|
|
662
|
+
)
|
|
663
|
+
}
|
|
664
|
+
if (written) await timings.warnUnignored(done.file, done.root, done.warn, ignored)
|
|
665
|
+
}
|
|
632
666
|
}
|
|
633
|
-
if (written && fileWarn !== undefined)
|
|
634
|
-
timings.warnUnignored(done.file, fileWarn.root, fileWarn.warn)
|
|
635
667
|
}
|
|
636
668
|
} finally {
|
|
637
669
|
if (server !== undefined && sent !== undefined && waited !== undefined) {
|
|
@@ -641,8 +673,9 @@ export function vxHistory(options: VxHistoryOptions = {}): VxPlugin {
|
|
|
641
673
|
if (late) {
|
|
642
674
|
u.stop.abort()
|
|
643
675
|
const n = sent.timings.size
|
|
676
|
+
const status = u.answered()
|
|
644
677
|
sent.warn(
|
|
645
|
-
`[vx] vx-history: the timings of ${n} task${n === 1 ? '' : 's'} were not sent to the history server at ${server.url}: no answer in the ${UPLOAD_WAIT_MS} ms a teardown waits`,
|
|
678
|
+
`[vx] vx-history: the timings of ${n} task${n === 1 ? '' : 's'} were not sent to the history server at ${shown(server.url)}: ${status === undefined ? `no answer in the ${UPLOAD_WAIT_MS} ms a teardown waits` : `it answered HTTP ${status}, and the ${UPLOAD_WAIT_MS} ms a teardown waits ran out before it was asked again`}`,
|
|
646
679
|
)
|
|
647
680
|
}
|
|
648
681
|
}
|
|
@@ -681,6 +714,8 @@ interface Upload {
|
|
|
681
714
|
/** Settles when sent, or once a failure is said; never rejects. */
|
|
682
715
|
readonly done: Promise<void>
|
|
683
716
|
readonly stop: AbortController
|
|
717
|
+
/** The status the upload's latest try answered; undefined while it waits for one. */
|
|
718
|
+
readonly answered: () => number | undefined
|
|
684
719
|
}
|
|
685
720
|
|
|
686
721
|
/**
|
|
@@ -692,16 +727,17 @@ const UPLOAD_WAIT_MS = 2_000
|
|
|
692
727
|
|
|
693
728
|
function sendTimings(server: HistoryServer, sent: Reported): Upload {
|
|
694
729
|
const stop = new AbortController()
|
|
730
|
+
let answered: number | undefined
|
|
695
731
|
const done = server
|
|
696
|
-
.upload(sent.timings, stop.signal, [...sent.outcomes.values()])
|
|
732
|
+
.upload(sent.timings, stop.signal, [...sent.outcomes.values()], (s) => (answered = s))
|
|
697
733
|
.catch((err: unknown) => {
|
|
698
734
|
// Abandoned by teardown, which says so itself.
|
|
699
735
|
if (stop.signal.aborted) return
|
|
700
736
|
sent.warn(
|
|
701
|
-
`[vx] vx-history: the timings were not sent to the history server at ${server.url}: ${err instanceof Error ? err.message : String(err)}`,
|
|
737
|
+
`[vx] vx-history: the timings were not sent to the history server at ${shown(server.url)}: ${err instanceof Error ? err.message : String(err)}`,
|
|
702
738
|
)
|
|
703
739
|
})
|
|
704
|
-
return { done, stop }
|
|
740
|
+
return { done, stop, answered: () => answered }
|
|
705
741
|
}
|
|
706
742
|
|
|
707
743
|
/** Resolves true after `ms`, unless cancelled first. */
|
package/src/timings-file.ts
CHANGED
|
@@ -4,12 +4,18 @@
|
|
|
4
4
|
|
|
5
5
|
import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'
|
|
6
6
|
import path from 'node:path'
|
|
7
|
-
import type { TaskResources } from '@vzn/vx'
|
|
8
7
|
|
|
9
8
|
const VERSION = 1
|
|
10
9
|
|
|
11
|
-
/**
|
|
12
|
-
|
|
10
|
+
/**
|
|
11
|
+
* The file's durations; none when it is absent or unread (with a warning),
|
|
12
|
+
* and undefined, with a warning, when it is a file but not a timings file:
|
|
13
|
+
* not one to write over.
|
|
14
|
+
*/
|
|
15
|
+
export function readTimings(
|
|
16
|
+
file: string,
|
|
17
|
+
warn: (m: string) => void,
|
|
18
|
+
): Record<string, number> | undefined {
|
|
13
19
|
let text: string
|
|
14
20
|
try {
|
|
15
21
|
text = readFileSync(file, 'utf8')
|
|
@@ -35,32 +41,34 @@ export function readTimings(file: string, warn: (m: string) => void): Record<str
|
|
|
35
41
|
warn(
|
|
36
42
|
`[vx] vx-history: timings file ${file} ignored: not { "version": ${VERSION}, "tasks": { id: ms } }`,
|
|
37
43
|
)
|
|
38
|
-
return
|
|
44
|
+
return undefined
|
|
39
45
|
}
|
|
40
|
-
|
|
41
|
-
|
|
46
|
+
// Filtered in place: the parse is this read's own, and a copy of an
|
|
47
|
+
// 8,002-task file, filtered again by the caller, was ~6 ms of each
|
|
48
|
+
// all-cached run's teardown (2026-11-07).
|
|
49
|
+
const out = tasks as Record<string, unknown>
|
|
50
|
+
for (const id in out) {
|
|
51
|
+
const ms = out[id]
|
|
42
52
|
// Bounded so no chain of them sums past a number: core refuses a
|
|
43
53
|
// weight that is not finite, and the run failed on a hint.
|
|
44
|
-
if (typeof ms === 'number' && ms >= 0 && ms <= Number.MAX_SAFE_INTEGER) out[id]
|
|
54
|
+
if (!(typeof ms === 'number' && ms >= 0 && ms <= Number.MAX_SAFE_INTEGER)) delete out[id]
|
|
45
55
|
}
|
|
46
|
-
return out
|
|
56
|
+
return out as Record<string, number>
|
|
47
57
|
}
|
|
48
58
|
|
|
49
59
|
/**
|
|
50
|
-
* The carried durations with this machine's p50s over
|
|
51
|
-
* a task this run did not see keeps its carried time.
|
|
52
|
-
* rename, so a reader never sees half a file. Its
|
|
53
|
-
* cache that missed restores none.
|
|
60
|
+
* The carried durations with this machine's p50s (`durationsFor`) over
|
|
61
|
+
* them, written whole: a task this run did not see keeps its carried time.
|
|
62
|
+
* Through a temp and a rename, so a reader never sees half a file. Its
|
|
63
|
+
* directory is made: a CI cache that missed restores none.
|
|
54
64
|
*/
|
|
55
65
|
export function writeTimings(
|
|
56
66
|
file: string,
|
|
57
67
|
carried: Readonly<Record<string, number>>,
|
|
58
|
-
|
|
68
|
+
p50s: ReadonlyMap<string, number>,
|
|
59
69
|
): void {
|
|
60
70
|
const merged: Record<string, number> = { ...carried }
|
|
61
|
-
for (const [id,
|
|
62
|
-
if (h.p50DurationMs !== undefined) merged[id] = Math.round(h.p50DurationMs)
|
|
63
|
-
}
|
|
71
|
+
for (const [id, ms] of p50s) merged[id] = Math.round(ms)
|
|
64
72
|
const tasks: Record<string, number> = {}
|
|
65
73
|
for (const id of Object.keys(merged).sort()) tasks[id] = merged[id]!
|
|
66
74
|
const tmp = `${file}.${process.pid}.tmp`
|
|
@@ -75,23 +83,45 @@ export function writeTimings(
|
|
|
75
83
|
}
|
|
76
84
|
|
|
77
85
|
/**
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
*
|
|
86
|
+
* Whether git ignores `file`, asked now and answered when git exits: true
|
|
87
|
+
* for a file outside the root, no repository, or a git that cannot run,
|
|
88
|
+
* which say nothing of it. Started before the history read, the check runs
|
|
89
|
+
* beside it: asked after the write, it held the run's end 7-10 ms.
|
|
81
90
|
*/
|
|
82
|
-
export function
|
|
91
|
+
export function askIgnored(file: string, root: string): Promise<boolean> {
|
|
83
92
|
const rel = path.relative(root, file)
|
|
84
93
|
// `..x.json` is a name inside the root, not a step out of it.
|
|
85
|
-
if (rel === '..' || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel))
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
+
if (rel === '..' || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel))
|
|
95
|
+
return Promise.resolve(true)
|
|
96
|
+
try {
|
|
97
|
+
const p = Bun.spawn({
|
|
98
|
+
cmd: ['git', 'check-ignore', '-q', '--', rel],
|
|
99
|
+
cwd: root,
|
|
100
|
+
stdin: 'ignore',
|
|
101
|
+
stdout: 'ignore',
|
|
102
|
+
stderr: 'ignore',
|
|
103
|
+
})
|
|
104
|
+
// 0: ignored; 128: no repository or git refused.
|
|
105
|
+
return p.exited.then((code) => code !== 1)
|
|
106
|
+
} catch {
|
|
107
|
+
// quiet: no git to run is no workspace git, which ignores nothing to warn of.
|
|
108
|
+
return Promise.resolve(true)
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Says so when git does not ignore the file it rewrote: a task whose
|
|
114
|
+
* inputs cover it (a root project's `**`) keyed each run's new timings,
|
|
115
|
+
* and missed the run after every write.
|
|
116
|
+
*/
|
|
117
|
+
export async function warnUnignored(
|
|
118
|
+
file: string,
|
|
119
|
+
root: string,
|
|
120
|
+
warn: (m: string) => void,
|
|
121
|
+
ignored: Promise<boolean> = askIgnored(file, root),
|
|
122
|
+
): Promise<void> {
|
|
123
|
+
if (await ignored) return
|
|
94
124
|
warn(
|
|
95
|
-
`[vx] vx-history: timings file ${
|
|
125
|
+
`[vx] vx-history: timings file ${path.relative(root, file)} is not git-ignored, so a task whose inputs cover it re-keys on every rewrite — add it to .gitignore`,
|
|
96
126
|
)
|
|
97
127
|
}
|