@vzn/vx-history 0.0.651 → 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 CHANGED
@@ -36,7 +36,8 @@ A recorded p50 always wins over an assumption, and assumptions never feed the wo
36
36
  `url` (or `VX_HISTORY_URL`), its base URL without `/v1`, names a
37
37
  [`@vzn/vx-history-server`](https://github.com/vznjs/vx/tree/main/packages/vx-history-server),
38
38
  over `https://` (`http://` only to `127.0.0.1`, `::1` or `localhost`: every
39
- request carries the CI token).
39
+ request carries the CI token), with no `?query` or `#fragment` (each
40
+ route is appended to it).
40
41
  On a CI job with a token (GitHub's OIDC token, which needs
41
42
  `permissions: id-token: write` to set `ACTIONS_ID_TOKEN_REQUEST_URL`
42
43
  and `ACTIONS_ID_TOKEN_REQUEST_TOKEN`, or a GitLab `id_tokens` entry
@@ -55,10 +56,14 @@ timings of the tasks it ran, once: under DTE that is the orchestrator,
55
56
  with each worker's measurements. The upload starts as the last task
56
57
  ends, beside core's own end of run, and the plugin's teardown waits at
57
58
  most 2 s more for it (core bounds a teardown at 3 s); one not done by
58
- then is dropped and said, with how many timings it held. A task that shared a crowded worker's
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
59
61
  cores is sent as long as it would have taken alone, and its cores at
60
62
  most its machine's, as local history keeps them. A refusal is said with the server's
61
- words; a refused token (401) also names the repository and issuer the
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
62
67
  workspace declares and the audience it must carry, and the server is
63
68
  off for the rest of the run. A CI job with no token says so once (on GitHub Actions,
64
69
  `GITHUB_ACTIONS`, naming the permission).
@@ -77,6 +82,17 @@ unset value is left out, and a name the request sets itself
77
82
  redirect is not followed (it would carry them on): it fails the request,
78
83
  naming where it pointed.
79
84
 
85
+ The same upload carries the flaky record: each cached task the run
86
+ ran, by definition and cache key, with how it ended (`pass`, `fail`,
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
90
+ when one key failed and later passed, or a run saw it flake, on any
91
+ branch of the repository. The run's query answers which of its tasks
92
+ are flaky, and core gives such a task one retry when it fails, as it
93
+ does a task its local history knows as flaky. So a CI machine that
94
+ starts empty still retries a known flake.
95
+
80
96
  ```ts
81
97
  export default {
82
98
  plugins: [
@@ -127,9 +143,11 @@ The file is `{ "version": 1, "tasks": { "<task id>": <ms> } }`, keys
127
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
128
144
  `assume`; like `assume`, it never feeds the workspace median. A task the
129
145
  run did not see keeps its time, one another writer put there while the
130
- run ran included. A file that is not one warns and is read as empty, one
131
- it cannot write warns and the run goes on; `--dry` writes
132
- nothing.
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.
133
151
 
134
152
  ## Reservations learned from history
135
153
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vzn/vx-history",
3
- "version": "0.0.651",
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.651"
40
+ "@vzn/vx": "^0.0.653"
41
41
  },
42
42
  "repository": {
43
43
  "type": "git",
@@ -15,6 +15,35 @@ export interface ServerTiming {
15
15
  readonly rssBytes?: number
16
16
  }
17
17
 
18
+ /** One cached task's run, as the server's flaky record keeps it (PROTOCOL.md "Outcomes"). */
19
+ export interface ServerOutcome {
20
+ /** The task's definition hash. */
21
+ readonly hash: string
22
+ /** The run's cache key. */
23
+ readonly key: string
24
+ readonly outcome: 'pass' | 'fail' | 'flaky'
25
+ }
26
+
27
+ /** What a query answers: each held hash's numbers, and the hashes asked that are flaky. */
28
+ export interface Answer {
29
+ readonly timings: Map<string, ServerTiming>
30
+ readonly flaky: Set<string>
31
+ }
32
+
33
+ /**
34
+ * A cached task the run ran, as the flaky record takes it; else undefined.
35
+ * A hit ran nothing, and a task behind a failure ran on bytes its key never
36
+ * named: neither says anything of the key.
37
+ */
38
+ export function outcomeOf(o: TaskOutcome, def: string): ServerOutcome | 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
+ if (o.status === 'failed') return { hash: def, key: o.hash, outcome: 'fail' }
42
+ if (o.status !== 'success') return undefined
43
+ const flaky = (o.attempts ?? 1) > 1 || o.flaky !== undefined
44
+ return { hash: def, key: o.hash, outcome: flaky ? 'flaky' : 'pass' }
45
+ }
46
+
18
47
  /** Every request's deadline: a run never waits on history longer. */
19
48
  const DEADLINE_MS = 10_000
20
49
  /** Tries of a request, to the server or GitHub's OIDC endpoint, before it fails (`tried`). */
@@ -79,10 +108,56 @@ const printable = (s: string): string =>
79
108
  // oxlint-disable-next-line no-control-regex
80
109
  s.replace(/[\x00-\x1f\x7f-\x9f]/g, (c) => `\\x${c.charCodeAt(0).toString(16).padStart(2, '0')}`)
81
110
 
82
- /** A refusal, named by its status and the server's own `message`. */
83
- async function refusal(res: Response): Promise<Error> {
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> {
84
159
  // quiet: with the body unread, the status alone names the refusal
85
- const text = (await res.text().catch(() => '')).slice(0, 2048)
160
+ const { text } = await upTo(res, 2048).catch(() => ({ text: '' }))
86
161
  let said = text
87
162
  try {
88
163
  const m = (JSON.parse(text) as { message?: unknown }).message
@@ -90,13 +165,14 @@ async function refusal(res: Response): Promise<Error> {
90
165
  } catch {
91
166
  // quiet: not JSON, so the text is what it said
92
167
  }
93
- said = printable(said.replace(/\s+/g, ' ').trim().slice(0, 200))
168
+ said = printable(hide(said).replace(/\s+/g, ' ').trim().slice(0, 200))
94
169
  return new Error(`HTTP ${res.status}${said === '' ? '' : `: ${said}`}`)
95
170
  }
96
171
 
97
- /** A 200's JSON; one that is none (a website's page) names the likely fault, the url. */
98
- async function jsonOf(res: Response, path: string): Promise<unknown> {
99
- const text = await res.text()
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`)
100
176
  try {
101
177
  return JSON.parse(text)
102
178
  } catch {
@@ -113,9 +189,15 @@ async function jsonOf(res: Response, path: string): Promise<unknown> {
113
189
  */
114
190
  async function redirected(res: Response, from: string): Promise<Error> {
115
191
  await res.body?.cancel()
116
- const to = res.headers.get('location')
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
+ }
117
199
  return new Error(
118
- `HTTP ${res.status}${to === null ? '' : ` to ${new URL(to, from).href}`}: not followed, as it would carry the server's headers; name the server's own URL`,
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`,
119
201
  )
120
202
  }
121
203
 
@@ -138,17 +220,46 @@ function expiryOf(jwt: string): number {
138
220
  const isCount = (n: unknown): n is number =>
139
221
  typeof n === 'number' && Number.isSafeInteger(n) && n >= 0
140
222
 
141
- interface Limits {
142
- readonly maxUploadTimings: number
143
- readonly maxQueryHashes: number
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
144
237
  }
145
238
 
146
239
  export class HistoryServer {
147
240
  private token: Promise<string> | undefined
148
241
  private held: { jwt: string; exp: number } | undefined
149
- private limits: Promise<Limits> | undefined
150
242
  // A refused token is refused again: the run's later calls stay unasked and unsaid.
151
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
+ }
152
263
 
153
264
  constructor(
154
265
  readonly url: string,
@@ -180,9 +291,16 @@ export class HistoryServer {
180
291
  const u = new URL(ask)
181
292
  u.searchParams.set('audience', new URL(this.url).origin)
182
293
  const res = await this.askGithub(u, signal)
183
- if (!res.ok) throw new Error(`GitHub's OIDC endpoint: ${(await refusal(res)).message}`)
184
- // quiet: a body that is not JSON holds no token, said below
185
- const jwt = ((await res.json().catch(() => null)) as { value?: unknown } | null)?.value
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
+ }
186
304
  if (typeof jwt !== 'string' || jwt === '') throw new Error("GitHub's OIDC endpoint: no token")
187
305
  return { jwt, exp: expiryOf(jwt) }
188
306
  })()
@@ -224,6 +342,7 @@ export class HistoryServer {
224
342
 
225
343
  /** GitHub's OIDC endpoint: a 4xx is final. */
226
344
  private askGithub(ask: URL, signal?: AbortSignal): Promise<Response> {
345
+ this.sent.add(this.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN ?? '')
227
346
  return this.tried(
228
347
  () =>
229
348
  this.fetchImpl(ask, {
@@ -234,8 +353,20 @@ export class HistoryServer {
234
353
  )
235
354
  }
236
355
 
237
- private async call(path: string, body?: unknown, signal?: AbortSignal): Promise<Response> {
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> {
238
368
  const bearer = this.writes ? await this.bearer(signal) : ''
369
+ if (bearer !== '') this.sent.add(bearer)
239
370
  const json = body === undefined ? undefined : JSON.stringify(body)
240
371
  // A 500 is the server's own failure, maybe after the fold: sent again, a
241
372
  // run would count twice. The edge's 502, 503 and 504 and a 429 come
@@ -243,8 +374,9 @@ export class HistoryServer {
243
374
  // would add seconds to every run's end.
244
375
  const url = `${this.url}${path}`
245
376
  const res = await this.tried(
246
- () =>
247
- this.fetchImpl(url, {
377
+ () => {
378
+ answered?.(undefined)
379
+ return this.fetchImpl(url, {
248
380
  method: json === undefined ? 'GET' : 'POST',
249
381
  headers: {
250
382
  ...this.headers,
@@ -254,13 +386,17 @@ export class HistoryServer {
254
386
  ...(json === undefined ? {} : { body: json }),
255
387
  signal: deadline(signal),
256
388
  redirect: 'manual',
257
- }),
389
+ }).then((r) => {
390
+ answered?.(r.status)
391
+ return r
392
+ })
393
+ },
258
394
  (status) => status !== undefined && (status === 429 || (status >= 502 && status <= 504)),
259
395
  )
260
396
  if (res.status >= 300 && res.status < 400) throw await redirected(res, url)
261
397
  if (res.status === 401 && bearer === '') {
262
398
  // Off CI: the bare 401 said nothing of why or what to do (2026-10-26).
263
- const err = await refusal(res)
399
+ const err = await refusal(res, this.hidden)
264
400
  const fix =
265
401
  this.env.GITHUB_ACTIONS === 'true'
266
402
  ? 'give the job `permissions: id-token: write`'
@@ -272,65 +408,105 @@ export class HistoryServer {
272
408
  if (res.status === 401) {
273
409
  // The bare "HTTP 401: wrong audience" named neither what was sent nor what to check.
274
410
  this.refused = true
275
- const err = await refusal(res)
411
+ const err = await refusal(res, this.hidden)
276
412
  throw new Error(
277
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`,
278
414
  )
279
415
  }
280
- if (!res.ok) throw await refusal(res)
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
+ }
281
424
  return res
282
425
  }
283
426
 
284
- /** `/v1/capabilities`, asked once a run, and again after a failed ask: nothing is guessed. */
285
- private limitsNow(): Promise<Limits> {
286
- const asked = (this.limits ??= (async () => {
287
- const body = (await jsonOf(
288
- await this.call('/v1/capabilities'),
289
- '/v1/capabilities',
290
- )) as Partial<Limits> | null
291
- const n = (v: unknown) => (typeof v === 'number' && Number.isSafeInteger(v) && v > 0 ? v : 0)
292
- const limits = {
293
- maxUploadTimings: n(body?.maxUploadTimings),
294
- maxQueryHashes: n(body?.maxQueryHashes),
295
- }
296
- if (limits.maxUploadTimings === 0 || limits.maxQueryHashes === 0) {
297
- throw new Error('/v1/capabilities named no maxUploadTimings or maxQueryHashes')
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)
298
469
  }
299
- return limits
300
- })())
301
- // quiet: the awaited ask rethrows it; this only forgets it
302
- asked.catch(() => {
303
- if (this.limits === asked) this.limits = undefined
304
- })
305
- return asked
470
+ }
471
+ return out
306
472
  }
307
473
 
308
- /** Each held hash's numbers under the first of `refs` that holds them: one request a run. */
309
- async query(
310
- refs: readonly string[],
311
- hashes: readonly string[],
312
- ): Promise<Map<string, ServerTiming>> {
313
- if (hashes.length === 0 || this.refused) return new Map()
314
- const per = (await this.limitsNow()).maxQueryHashes
315
- const parts = await Promise.all(
316
- Array.from({ length: Math.ceil(hashes.length / per) }, async (_, i) => {
317
- const res = await this.call('/v1/timings/query', {
318
- ...this.named,
319
- refs,
320
- hashes: hashes.slice(i * per, (i + 1) * per),
321
- })
322
- const body = (await jsonOf(res, '/v1/timings/query')) as { timings?: unknown } | null
323
- if (typeof body?.timings !== 'object' || body.timings === null) {
324
- throw new Error('the query answered no `timings` object')
325
- }
326
- return Object.entries(body.timings as Record<string, ServerTiming | null>)
327
- }),
328
- )
474
+ /** The limits a refusal named this run, by header: later requests keep to them. */
475
+ private readonly limits = new Map<string, number>()
476
+
477
+ /** Each held hash's numbers under the first of `refs` that holds them, and the flaky ones: one request a run. */
478
+ async query(refs: readonly string[], hashes: readonly string[]): Promise<Answer> {
479
+ if (hashes.length === 0 || this.refused) return { timings: new Map(), flaky: new Set() }
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)
329
505
  // Only numbers a server folds (PROTOCOL.md): `1e309` parses to Infinity,
330
506
  // and kept as a forecast it made a NaN weight that failed every later
331
507
  // run of the cache in `schedule`.
332
508
  const out = new Map<string, ServerTiming>()
333
- for (const [hash, t] of parts.flat()) {
509
+ for (const [hash, t] of parts.flatMap((p) => p.timings)) {
334
510
  if (!isCount(t?.durationMs)) continue
335
511
  out.set(hash, {
336
512
  durationMs: t.durationMs,
@@ -340,16 +516,37 @@ export class HistoryServer {
340
516
  ...(isCount(t.rssBytes) ? { rssBytes: t.rssBytes } : {}),
341
517
  })
342
518
  }
343
- return out
519
+ return { timings: out, flaky: new Set(parts.flatMap((p) => p.flaky)) }
344
520
  }
345
521
 
346
- /** The run's timings, by task definition hash, in uploads of at most `maxUploadTimings`; `stop` abandons them. */
347
- async upload(timings: ReadonlyMap<string, ServerTiming>, stop?: AbortSignal): Promise<void> {
348
- if (timings.size === 0 || this.refused) return
349
- const per = (await this.limitsNow()).maxUploadTimings
522
+ /**
523
+ * The run's timings, by task definition hash, and its outcomes, in
524
+ * uploads the server takes; `stop` abandons them. `answered` hears each
525
+ * try's status, undefined as it is sent.
526
+ */
527
+ async upload(
528
+ timings: ReadonlyMap<string, ServerTiming>,
529
+ stop?: AbortSignal,
530
+ outcomes: readonly ServerOutcome[] = [],
531
+ answered?: (status: number | undefined) => void,
532
+ ): Promise<void> {
533
+ if ((timings.size === 0 && outcomes.length === 0) || this.refused) return
350
534
  const all = [...timings].map(([hash, t]) => ({ hash, ...t }))
351
- for (let i = 0; i < all.length; i += per) {
352
- await this.call('/v1/timings/upload', { timings: all.slice(i, i + per) }, stop)
353
- }
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
+ )
354
551
  }
355
552
  }
package/src/index.ts CHANGED
@@ -33,7 +33,7 @@ import {
33
33
  import type { CommandContext } from '@vzn/vx'
34
34
  import path from 'node:path'
35
35
  import type { HistoryRow } from './history-view.js'
36
- import type { HistoryServer, Named, ServerTiming } from './history-server.js'
36
+ import type { HistoryServer, Named, ServerOutcome, ServerTiming } from './history-server.js'
37
37
 
38
38
  export interface VxHistoryOptions {
39
39
  /** How many recent invocations to learn from. Default 20. */
@@ -189,15 +189,16 @@ const headroomOf = (options: VxHistoryOptions): number => {
189
189
  return usable(h) ? h : DEFAULT_HEADROOM
190
190
  }
191
191
 
192
- // The schedule reads no rates (`resourcesFor`); `vx history` shows runs.
193
- const readHistory = (
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, TaskResources>> =>
200
- localHistory(cache, { recent: windowOf(options), branch, defs }).resourcesFor(ids)
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',
@@ -290,10 +284,12 @@ function shown(url: string): string {
290
284
  * http:// to another host sent it in clear.
291
285
  */
292
286
  function secure(url: string): boolean {
293
- if (/^https:\/\/\S/.test(url)) return true
294
- if (!/^http:\/\/\S/.test(url)) return false
295
- const host = URL.parse(url)?.hostname
296
- return host === '127.0.0.1' || host === '[::1]' || host === 'localhost'
287
+ // Parsed as the request will be: a scheme in any case, no url it cannot carry.
288
+ const u = URL.parse(url)
289
+ if (u === null || u.host === '') return false
290
+ if (u.protocol === 'https:') return true
291
+ if (u.protocol !== 'http:') return false
292
+ return u.hostname === '127.0.0.1' || u.hostname === '[::1]' || u.hostname === 'localhost'
297
293
  }
298
294
 
299
295
  /** `VX_HISTORY_URL` under the option, trailing slashes dropped; undefined when neither names one. */
@@ -309,6 +305,12 @@ function serverUrlOf(
309
305
  throw new UserError(
310
306
  `vxHistory(): ${from} must be https (http only to 127.0.0.1, ::1 or localhost), got ${shown(url)}`,
311
307
  )
308
+ // Each route is appended to the url: past a `?` or `#` it was query or
309
+ // fragment, and every request asked the server's root.
310
+ if (/[?#]/.test(url))
311
+ throw new UserError(
312
+ `vxHistory(): ${from} takes no ?query or #fragment: each route is appended to it, got ${shown(url)}`,
313
+ )
312
314
  // Each route is the server's /v1/…: one that names /v1 asked /v1/v1/…, and every answer was a 404.
313
315
  if (url.endsWith('/v1'))
314
316
  throw new UserError(
@@ -403,7 +405,7 @@ export function vxHistory(options: VxHistoryOptions = {}): VxPlugin {
403
405
  const carried =
404
406
  file === undefined
405
407
  ? {}
406
- : carriedOf((await import('./timings-file.js')).readTimings(file, ctx.warn))
408
+ : ((await import('./timings-file.js')).readTimings(file, ctx.warn) ?? {})
407
409
  const defs = taskDefinitions(nodes.values())
408
410
  if (file !== undefined)
409
411
  scheduled = {
@@ -463,7 +465,8 @@ export function vxHistory(options: VxHistoryOptions = {}): VxPlugin {
463
465
  stopImport = () => (late = true)
464
466
  const weights = (async () => {
465
467
  const hashes = [...new Set(ids.flatMap((id) => defs.get(id) ?? []))]
466
- const got = await server.query(refs, hashes)
468
+ const { timings: got, flaky } = await server.query(refs, hashes)
469
+ if (flaky.size > 0 && !late) ctx.flaky(ids.filter((id) => flaky.has(defs.get(id) ?? '')))
467
470
  const timings = new Map<string, number>()
468
471
  const needs = new Map<string, TaskResources>()
469
472
  for (const id of ids) {
@@ -486,7 +489,7 @@ export function vxHistory(options: VxHistoryOptions = {}): VxPlugin {
486
489
  })().catch((err: unknown) => {
487
490
  if (!late) {
488
491
  ctx.warn(
489
- `[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)}`,
490
493
  )
491
494
  }
492
495
  return undefined
@@ -528,25 +531,39 @@ export function vxHistory(options: VxHistoryOptions = {}): VxPlugin {
528
531
  defs: ReadonlyMap<string, string>
529
532
  }
530
533
  | undefined
531
- let inRun = false
532
- // A run that executed nothing and restored only tasks the file already
533
- // times leaves it as it was: an all-cached CI run re-reads no history.
534
- let changed = false
535
- let fileWarn: { root: string; warn: (m: string) => void } | undefined
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
536
548
  hooks.setup = async (ctx) => {
537
549
  remote ??= await serverOf()
538
550
  if (remote?.writes === true) {
539
- const { timingOf: timing } = await import('./history-server.js')
551
+ const { timingOf: timing, outcomeOf } = await import('./history-server.js')
540
552
  // Only the run that ran the tasks hears them: under DTE, the
541
553
  // orchestrator, with each worker's measurements.
542
554
  const timings = new Map<string, ServerTiming>()
543
- const sent = (reported = { timings, warn: ctx.warn })
555
+ // Each task and key once, as the server takes them.
556
+ const outcomes = new Map<string, ServerOutcome>()
557
+ const sent = (reported = { timings, outcomes, warn: ctx.warn })
544
558
  const server = remote
545
559
  ctx.on('onTaskComplete', (node, outcome) => {
546
560
  if (node.config.exec?.command === undefined) return
547
561
  const def = taskDefinitions([node]).get(node.id)
562
+ if (def === undefined) return
548
563
  const t = timing(outcome)
549
- if (def !== undefined && t !== undefined) timings.set(def, t)
564
+ if (t !== undefined) timings.set(def, t)
565
+ const o = outcomeOf(outcome, def)
566
+ if (o !== undefined) outcomes.set(`${o.hash}\0${o.key}`, o)
550
567
  })
551
568
  // Every outcome is known here, and core's own end of run (history
552
569
  // rows, cache uploads, telemetry) is still ahead: sent from teardown,
@@ -567,16 +584,22 @@ export function vxHistory(options: VxHistoryOptions = {}): VxPlugin {
567
584
  )
568
585
  }
569
586
  if (options.file === undefined) return
570
- fileWarn = { root: ctx.workspaceRoot, warn: ctx.warn }
571
- inRun = true
572
- changed = false
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
573
597
  ctx.on('onTaskComplete', (node, outcome) => {
574
598
  // A group runs nothing and is never timed: counting it rewrote the
575
599
  // file on every all-cached run that went through one.
576
600
  if (node.config.exec?.command === undefined) return
577
- if (outcome.status === 'success' || !(node.id in (scheduled?.carried ?? {}))) {
578
- changed = true
579
- }
601
+ run.nodes.set(node.id, node)
602
+ if (outcome.status === 'success') run.ran = true
580
603
  })
581
604
  }
582
605
  // After the run's history is recorded and before its cache closes.
@@ -591,33 +614,56 @@ export function vxHistory(options: VxHistoryOptions = {}): VxPlugin {
591
614
  uploading = undefined
592
615
  // Counted from here: the file's write below shares the bound.
593
616
  const waited = server !== undefined && sent !== undefined ? waitFor(UPLOAD_WAIT_MS) : undefined
594
- const done = scheduled
595
- const wrote = inRun && changed
617
+ const sched = scheduled
618
+ const done = fileRun
596
619
  scheduled = undefined
597
- inRun = false
620
+ fileRun = undefined
598
621
  // The file first: core bounds a teardown (3 s), and an upload ahead of
599
622
  // it held the write hostage to the server's network. A failed write
600
623
  // still sends.
601
624
  try {
602
- if (wrote && done !== undefined) {
603
- const table = await readHistory(done.cache, done.ids, options, done.branch, done.defs)
625
+ if (done !== undefined && done.nodes.size > 0) {
604
626
  const timings = await import('./timings-file.js')
605
- // Over the file as it is now: a time another writer (a run in another
606
- // worktree sharing it) put there since `schedule` read it is kept. Its
607
- // fault, if any, was said when the run read it.
608
- const now = timings.readTimings(done.file, () => {})
609
- let written = true
610
- try {
611
- timings.writeTimings(done.file, { ...done.carried, ...now }, table)
612
- } catch (err) {
613
- // Only the file is lost: said as the read is, not as a plugin failure.
614
- written = false
615
- fileWarn?.warn(
616
- `[vx] vx-history: timings file ${done.file} not written: ${err instanceof Error ? err.message : String(err)}`,
617
- )
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
+ }
618
666
  }
619
- if (written && fileWarn !== undefined)
620
- timings.warnUnignored(done.file, fileWarn.root, fileWarn.warn)
621
667
  }
622
668
  } finally {
623
669
  if (server !== undefined && sent !== undefined && waited !== undefined) {
@@ -627,8 +673,9 @@ export function vxHistory(options: VxHistoryOptions = {}): VxPlugin {
627
673
  if (late) {
628
674
  u.stop.abort()
629
675
  const n = sent.timings.size
676
+ const status = u.answered()
630
677
  sent.warn(
631
- `[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`}`,
632
679
  )
633
680
  }
634
681
  }
@@ -659,6 +706,7 @@ export function vxHistory(options: VxHistoryOptions = {}): VxPlugin {
659
706
 
660
707
  interface Reported {
661
708
  readonly timings: Map<string, ServerTiming>
709
+ readonly outcomes: ReadonlyMap<string, ServerOutcome>
662
710
  readonly warn: (m: string) => void
663
711
  }
664
712
 
@@ -666,6 +714,8 @@ interface Upload {
666
714
  /** Settles when sent, or once a failure is said; never rejects. */
667
715
  readonly done: Promise<void>
668
716
  readonly stop: AbortController
717
+ /** The status the upload's latest try answered; undefined while it waits for one. */
718
+ readonly answered: () => number | undefined
669
719
  }
670
720
 
671
721
  /**
@@ -677,14 +727,17 @@ const UPLOAD_WAIT_MS = 2_000
677
727
 
678
728
  function sendTimings(server: HistoryServer, sent: Reported): Upload {
679
729
  const stop = new AbortController()
680
- const done = server.upload(sent.timings, stop.signal).catch((err: unknown) => {
681
- // Abandoned by teardown, which says so itself.
682
- if (stop.signal.aborted) return
683
- sent.warn(
684
- `[vx] vx-history: the timings were not sent to the history server at ${server.url}: ${err instanceof Error ? err.message : String(err)}`,
685
- )
686
- })
687
- return { done, stop }
730
+ let answered: number | undefined
731
+ const done = server
732
+ .upload(sent.timings, stop.signal, [...sent.outcomes.values()], (s) => (answered = s))
733
+ .catch((err: unknown) => {
734
+ // Abandoned by teardown, which says so itself.
735
+ if (stop.signal.aborted) return
736
+ sent.warn(
737
+ `[vx] vx-history: the timings were not sent to the history server at ${shown(server.url)}: ${err instanceof Error ? err.message : String(err)}`,
738
+ )
739
+ })
740
+ return { done, stop, answered: () => answered }
688
741
  }
689
742
 
690
743
  /** Resolves true after `ms`, unless cancelled first. */
@@ -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
- /** The file's durations; none when it is absent, and none, with a warning, when it is not one. */
12
- export function readTimings(file: string, warn: (m: string) => void): Record<string, number> {
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
- const out: Record<string, number> = {}
41
- for (const [id, ms] of Object.entries(tasks)) {
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] = ms
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 them, written whole:
51
- * a task this run did not see keeps its carried time. Through a temp and a
52
- * rename, so a reader never sees half a file. Its directory is made: a CI
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
- table: ReadonlyMap<string, TaskResources>,
68
+ p50s: ReadonlyMap<string, number>,
59
69
  ): void {
60
70
  const merged: Record<string, number> = { ...carried }
61
- for (const [id, h] of table) {
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
- * Says so when git does not ignore the file it rewrote: a task whose
79
- * inputs cover it (a root project's `**`) keyed each run's new timings,
80
- * and missed the run after every write. Asked after the write, off the run.
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 warnUnignored(file: string, root: string, warn: (m: string) => void): void {
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)) return
86
- const p = Bun.spawnSync({
87
- cmd: ['git', 'check-ignore', '-q', '--', rel],
88
- cwd: root,
89
- stdout: 'ignore',
90
- stderr: 'ignore',
91
- })
92
- // 0: ignored; 128: no repository or git refused, which says nothing of it.
93
- if (p.exitCode !== 1) return
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 ${rel} is not git-ignored, so a task whose inputs cover it re-keys on every rewrite — add it to .gitignore`,
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
  }