@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 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. 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
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; 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
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). The server names a task flaky
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 file that is not one warns and is read as empty, one
141
- it cannot write warns and the run goes on; `--dry` writes
142
- 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.
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.652",
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.652"
40
+ "@vzn/vx": "^0.0.653"
41
41
  },
42
42
  "repository": {
43
43
  "type": "git",
@@ -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 === undefined || o.hash === undefined || o.hash === '') return undefined
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
- /** A refusal, named by its status and the server's own `message`. */
112
- 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> {
113
159
  // quiet: with the body unread, the status alone names the refusal
114
- const text = (await res.text().catch(() => '')).slice(0, 2048)
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.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`)
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 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
+ }
146
199
  return new Error(
147
- `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`,
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
- interface Limits {
171
- readonly maxUploadTimings: number
172
- 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
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) throw new Error(`GitHub's OIDC endpoint: ${(await refusal(res)).message}`)
213
- // quiet: a body that is not JSON holds no token, said below
214
- 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
+ }
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
- 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> {
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
- this.fetchImpl(url, {
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) 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
+ }
310
424
  return res
311
425
  }
312
426
 
313
- /** `/v1/capabilities`, asked once a run, and again after a failed ask: nothing is guessed. */
314
- private limitsNow(): Promise<Limits> {
315
- const asked = (this.limits ??= (async () => {
316
- const body = (await jsonOf(
317
- await this.call('/v1/capabilities'),
318
- '/v1/capabilities',
319
- )) as Partial<Limits> | null
320
- const n = (v: unknown) => (typeof v === 'number' && Number.isSafeInteger(v) && v > 0 ? v : 0)
321
- const limits = {
322
- maxUploadTimings: n(body?.maxUploadTimings),
323
- maxQueryHashes: n(body?.maxQueryHashes),
324
- }
325
- if (limits.maxUploadTimings === 0 || limits.maxQueryHashes === 0) {
326
- 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)
327
469
  }
328
- return limits
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 per = (await this.limitsNow()).maxQueryHashes
341
- const parts = await Promise.all(
342
- Array.from({ length: Math.ceil(hashes.length / per) }, async (_, i) => {
343
- const res = await this.call('/v1/timings/query', {
344
- ...this.named,
345
- refs,
346
- hashes: hashes.slice(i * per, (i + 1) * per),
347
- })
348
- const body = (await jsonOf(res, '/v1/timings/query')) as {
349
- timings?: unknown
350
- flaky?: unknown
351
- } | null
352
- if (typeof body?.timings !== 'object' || body.timings === null) {
353
- throw new Error('the query answered no `timings` object')
354
- }
355
- // A server before the flaky record answers none: nothing is flaky.
356
- const flaky = Array.isArray(body.flaky)
357
- ? body.flaky.filter((h): h is string => typeof h === 'string')
358
- : []
359
- return {
360
- timings: Object.entries(body.timings as Record<string, ServerTiming | null>),
361
- flaky,
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 of at most `maxUploadTimings` of each; `stop` abandons them.
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
- for (let i = 0; i < Math.max(all.length, outcomes.length); i += per) {
395
- const part = outcomes.slice(i, i + per)
396
- await this.call(
397
- '/v1/timings/upload',
398
- { timings: all.slice(i, i + per), ...(part.length > 0 ? { outcomes: part } : {}) },
399
- stop,
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 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',
@@ -411,7 +405,7 @@ export function vxHistory(options: VxHistoryOptions = {}): VxPlugin {
411
405
  const carried =
412
406
  file === undefined
413
407
  ? {}
414
- : carriedOf((await import('./timings-file.js')).readTimings(file, ctx.warn))
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
- let inRun = false
541
- // A run that executed nothing and restored only tasks the file already
542
- // times leaves it as it was: an all-cached CI run re-reads no history.
543
- let changed = false
544
- 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
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
- fileWarn = { root: ctx.workspaceRoot, warn: ctx.warn }
585
- inRun = true
586
- 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
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
- if (outcome.status === 'success' || !(node.id in (scheduled?.carried ?? {}))) {
592
- changed = true
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 done = scheduled
609
- const wrote = inRun && changed
617
+ const sched = scheduled
618
+ const done = fileRun
610
619
  scheduled = undefined
611
- inRun = false
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 (wrote && done !== undefined) {
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
- // Over the file as it is now: a time another writer (a run in another
620
- // worktree sharing it) put there since `schedule` read it is kept. Its
621
- // fault, if any, was said when the run read it.
622
- const now = timings.readTimings(done.file, () => {})
623
- let written = true
624
- try {
625
- timings.writeTimings(done.file, { ...done.carried, ...now }, table)
626
- } catch (err) {
627
- // Only the file is lost: said as the read is, not as a plugin failure.
628
- written = false
629
- fileWarn?.warn(
630
- `[vx] vx-history: timings file ${done.file} not written: ${err instanceof Error ? err.message : String(err)}`,
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. */
@@ -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
  }