@vzn/vx-history 0.0.0-stage → 0.0.648

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,354 @@
1
+ // The client of a vx-history server (`@vzn/vx-history-server`, its
2
+ // PROTOCOL.md): each task's numbers asked once a run, and the run's own
3
+ // timings sent once at its end. A query names the issuer, repository and
4
+ // refs the workspace reads, plainly; a CI job's request carries its OIDC
5
+ // token, which writes its own ref, and with no token a run only reads (a
6
+ // server with `READ_ACCESS=public`). A refusal is never swallowed: it is said with the
7
+ // server's status and words.
8
+
9
+ import type { TaskOutcome } from '@vzn/vx'
10
+
11
+ /** One task's numbers, as the server folds and answers them. */
12
+ export interface ServerTiming {
13
+ readonly durationMs: number
14
+ readonly cores?: number
15
+ readonly rssBytes?: number
16
+ }
17
+
18
+ /** Every request's deadline: a run never waits on history longer. */
19
+ const DEADLINE_MS = 10_000
20
+ /** Tries of a request, to the server or GitHub's OIDC endpoint, before it fails (`tried`). */
21
+ const ATTEMPTS = 3
22
+
23
+ /** The issuer and repository a workspace names, as a key holds them: the issuer's URL with no trailing slash, `org/name` lowercased. */
24
+ export interface Named {
25
+ readonly issuer: string
26
+ readonly repository: string
27
+ }
28
+
29
+ /** Whether this job has a token: GitHub's OIDC endpoint, or GitLab's `VX_HISTORY_TOKEN`. */
30
+ const hasToken = (env: Record<string, string | undefined>): boolean =>
31
+ (!!env.ACTIONS_ID_TOKEN_REQUEST_URL && !!env.ACTIONS_ID_TOKEN_REQUEST_TOKEN) ||
32
+ !!env.VX_HISTORY_TOKEN
33
+
34
+ /** A task the run ran and that succeeded, as the server takes its timing; else undefined. */
35
+ export function timingOf(o: TaskOutcome): ServerTiming | undefined {
36
+ if (o.status !== 'success') return undefined
37
+ // Core's need (history.ts NEED_SQL): at most the machine's cores, and no
38
+ // reading where the share is 0. Uncapped, a crowded 4-core worker sent 8
39
+ // and a DTE plan booked that; an Infinity went as null and the server
40
+ // refused the whole upload.
41
+ const need =
42
+ o.cpuMs === undefined || o.durationMs <= 0
43
+ ? undefined
44
+ : Math.min(o.cpuMs / (o.durationMs * (o.cpuShare ?? 1)), o.whereCores ?? Infinity)
45
+ // A share rounded to 0 divides by it, and the server refuses the upload.
46
+ const cores = need !== undefined && Number.isFinite(need) ? need : undefined
47
+ return {
48
+ durationMs: Math.max(0, aloneMs(o)),
49
+ ...(cores === undefined ? {} : { cores }),
50
+ ...(o.peakRssBytes === undefined ? {} : { rssBytes: o.peakRssBytes }),
51
+ }
52
+ }
53
+
54
+ /**
55
+ * How long it would have taken alone, as core's local history stores it
56
+ * (`run-history.ts` aloneMs): on a crowded DTE worker (`cpuShare` < 1) each
57
+ * thread waited for a core. Sent as it ran, every crowded run raised the
58
+ * shared number a forecast reads (2026-10-26).
59
+ */
60
+ function aloneMs(o: TaskOutcome): number {
61
+ const { durationMs: d, cpuMs: c, cpuShare: s } = o
62
+ if (c === undefined || s === undefined || !(s > 0 && s < 1) || !(d > 0)) return Math.round(d)
63
+ return Math.round(d - (c / s - c) / Math.max(1, c / (d * s)))
64
+ }
65
+
66
+ /** {@link DEADLINE_MS}, or `stop` first. */
67
+ const deadline = (stop?: AbortSignal): AbortSignal => {
68
+ const c = new AbortController()
69
+ setTimeout(() => c.abort(new Error(`no answer within ${DEADLINE_MS} ms`)), DEADLINE_MS).unref()
70
+ return stop === undefined ? c.signal : AbortSignal.any([c.signal, stop])
71
+ }
72
+
73
+ /**
74
+ * `s` with each C0/C1 control and DEL written as `\xNN`: a server's words
75
+ * reach the terminal, and an `ESC ]` or `\r` in them retitled it or
76
+ * overwrote the line before (core's `printable`, L-31).
77
+ */
78
+ const printable = (s: string): string =>
79
+ // oxlint-disable-next-line no-control-regex
80
+ s.replace(/[\x00-\x1f\x7f-\x9f]/g, (c) => `\\x${c.charCodeAt(0).toString(16).padStart(2, '0')}`)
81
+
82
+ /** A refusal, named by its status and the server's own `message`. */
83
+ async function refusal(res: Response): Promise<Error> {
84
+ // quiet: with the body unread, the status alone names the refusal
85
+ const text = (await res.text().catch(() => '')).slice(0, 2048)
86
+ let said = text
87
+ try {
88
+ const m = (JSON.parse(text) as { message?: unknown }).message
89
+ if (typeof m === 'string') said = m
90
+ } catch {
91
+ // quiet: not JSON, so the text is what it said
92
+ }
93
+ said = printable(said.replace(/\s+/g, ' ').trim().slice(0, 200))
94
+ return new Error(`HTTP ${res.status}${said === '' ? '' : `: ${said}`}`)
95
+ }
96
+
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()
100
+ try {
101
+ return JSON.parse(text)
102
+ } catch {
103
+ const type = res.headers.get('content-type') ?? 'no content type'
104
+ throw new Error(
105
+ `${path} answered ${res.status} with ${type}, not JSON: is the url a vx-history server?`,
106
+ )
107
+ }
108
+ }
109
+
110
+ /**
111
+ * A redirect, never followed: fetch drops only `Authorization` on one to
112
+ * another origin, so `headers` (a proxy's secret) went where a 307 named.
113
+ */
114
+ async function redirected(res: Response, from: string): Promise<Error> {
115
+ await res.body?.cancel()
116
+ const to = res.headers.get('location')
117
+ 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`,
119
+ )
120
+ }
121
+
122
+ /** A JWT's `exp` in ms; 0 when it names none. */
123
+ function expiryOf(jwt: string): number {
124
+ try {
125
+ const exp = (
126
+ JSON.parse(Buffer.from(jwt.split('.')[1] ?? '', 'base64url').toString()) as {
127
+ exp?: unknown
128
+ }
129
+ ).exp
130
+ return typeof exp === 'number' ? exp * 1000 : 0
131
+ } catch {
132
+ // quiet: an unreadable exp is no known expiry; the server refuses the token
133
+ return 0
134
+ }
135
+ }
136
+
137
+ /** A whole count a server keeps (its own check): a sum of thousands stays finite. */
138
+ const isCount = (n: unknown): n is number =>
139
+ typeof n === 'number' && Number.isSafeInteger(n) && n >= 0
140
+
141
+ interface Limits {
142
+ readonly maxUploadTimings: number
143
+ readonly maxQueryHashes: number
144
+ }
145
+
146
+ export class HistoryServer {
147
+ private token: Promise<string> | undefined
148
+ private held: { jwt: string; exp: number } | undefined
149
+ private limits: Promise<Limits> | undefined
150
+ // A refused token is refused again: the run's later calls stay unasked and unsaid.
151
+ private refused = false
152
+
153
+ constructor(
154
+ readonly url: string,
155
+ private readonly named: Named,
156
+ private readonly env: Record<string, string | undefined> = Bun.env,
157
+ private readonly fetchImpl: typeof fetch = fetch,
158
+ private readonly headers: Readonly<Record<string, string>> = {},
159
+ private readonly wait: (ms: number) => Promise<void> = Bun.sleep,
160
+ ) {}
161
+
162
+ /** Whether the run has a token to send: with none it reads only. */
163
+ get writes(): boolean {
164
+ return hasToken(this.env)
165
+ }
166
+
167
+ /**
168
+ * The job's token: GitHub's OIDC token for the server's origin, asked
169
+ * again a minute before it ends (it lives minutes, a run can outlive it),
170
+ * else `VX_HISTORY_TOKEN`.
171
+ */
172
+ private bearer(signal?: AbortSignal): Promise<string> {
173
+ const held = this.held
174
+ if (held !== undefined && (held.exp === 0 || Date.now() < held.exp - 60_000)) {
175
+ return Promise.resolve(held.jwt)
176
+ }
177
+ this.token ??= (async () => {
178
+ const ask = this.env.ACTIONS_ID_TOKEN_REQUEST_URL
179
+ if (ask === undefined || ask === '') return { jwt: this.env.VX_HISTORY_TOKEN ?? '', exp: 0 }
180
+ const u = new URL(ask)
181
+ u.searchParams.set('audience', new URL(this.url).origin)
182
+ const res = await this.askGithub(u, signal)
183
+ if (!res.ok) throw new Error(`GitHub's OIDC endpoint: ${(await refusal(res)).message}`)
184
+ const jwt = ((await res.json()) as { value?: unknown }).value
185
+ if (typeof jwt !== 'string' || jwt === '') throw new Error("GitHub's OIDC endpoint: no token")
186
+ return { jwt, exp: expiryOf(jwt) }
187
+ })()
188
+ .then((t) => (this.held = t).jwt)
189
+ .finally(() => (this.token = undefined))
190
+ return this.token
191
+ }
192
+
193
+ /**
194
+ * One answer, asked again while `again(status)` (`undefined`: no answer)
195
+ * holds, up to three tries, 1 s then 2 s apart: the upload is sent once, at the run's end,
196
+ * and one 503 there, or from GitHub's OIDC endpoint (as vx-cache asks
197
+ * it, #4571), lost the run's timings.
198
+ */
199
+ private async tried(
200
+ send: () => Promise<Response>,
201
+ again: (status: number | undefined) => boolean,
202
+ ): Promise<Response> {
203
+ for (let attempt = 1; ; attempt++) {
204
+ let res: Response | undefined
205
+ let failure: unknown
206
+ try {
207
+ res = await send()
208
+ } catch (err) {
209
+ failure = err
210
+ }
211
+ if (!again(res?.status)) {
212
+ if (res === undefined) throw failure
213
+ return res
214
+ }
215
+ if (attempt === ATTEMPTS) {
216
+ if (res === undefined) throw failure
217
+ return res
218
+ }
219
+ await res?.body?.cancel()
220
+ await this.wait(1000 * attempt)
221
+ }
222
+ }
223
+
224
+ /** GitHub's OIDC endpoint: a 4xx is final. */
225
+ private askGithub(ask: URL, signal?: AbortSignal): Promise<Response> {
226
+ return this.tried(
227
+ () =>
228
+ this.fetchImpl(ask, {
229
+ headers: { authorization: `bearer ${this.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN ?? ''}` },
230
+ signal: deadline(signal),
231
+ }),
232
+ (status) => status === undefined || status === 429 || status >= 500,
233
+ )
234
+ }
235
+
236
+ private async call(path: string, body?: unknown, signal?: AbortSignal): Promise<Response> {
237
+ const bearer = this.writes ? await this.bearer(signal) : ''
238
+ const json = body === undefined ? undefined : JSON.stringify(body)
239
+ // A 500 is the server's own failure, maybe after the fold: sent again, a
240
+ // run would count twice. The edge's 502, 503 and 504 and a 429 come
241
+ // before it. No answer is not asked again: a server down or misnamed
242
+ // would add seconds to every run's end.
243
+ const url = `${this.url}${path}`
244
+ const res = await this.tried(
245
+ () =>
246
+ this.fetchImpl(url, {
247
+ method: json === undefined ? 'GET' : 'POST',
248
+ headers: {
249
+ ...this.headers,
250
+ ...(bearer === '' ? {} : { authorization: `Bearer ${bearer}` }),
251
+ ...(json === undefined ? {} : { 'content-type': 'application/json' }),
252
+ },
253
+ ...(json === undefined ? {} : { body: json }),
254
+ signal: deadline(signal),
255
+ redirect: 'manual',
256
+ }),
257
+ (status) => status !== undefined && (status === 429 || (status >= 502 && status <= 504)),
258
+ )
259
+ if (res.status >= 300 && res.status < 400) throw await redirected(res, url)
260
+ if (res.status === 401 && bearer === '') {
261
+ // Off CI: the bare 401 said nothing of why or what to do (2026-10-26).
262
+ const err = await refusal(res)
263
+ const fix =
264
+ this.env.GITHUB_ACTIONS === 'true'
265
+ ? 'give the job `permissions: id-token: write`'
266
+ : this.env.GITLAB_CI === 'true'
267
+ ? 'name an id_tokens entry VX_HISTORY_TOKEN'
268
+ : 'off CI, a server reads only with READ_ACCESS=public'
269
+ throw new Error(`${err.message}: a read with no CI token was refused (${fix})`)
270
+ }
271
+ if (res.status === 401) {
272
+ // The bare "HTTP 401: wrong audience" named neither what was sent nor what to check.
273
+ this.refused = true
274
+ const err = await refusal(res)
275
+ throw new Error(
276
+ `${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`,
277
+ )
278
+ }
279
+ if (!res.ok) throw await refusal(res)
280
+ return res
281
+ }
282
+
283
+ /** `/v1/capabilities`, asked once a run, and again after a failed ask: nothing is guessed. */
284
+ private limitsNow(): Promise<Limits> {
285
+ const asked = (this.limits ??= (async () => {
286
+ const body = (await jsonOf(
287
+ await this.call('/v1/capabilities'),
288
+ '/v1/capabilities',
289
+ )) as Partial<Limits> | null
290
+ const n = (v: unknown) => (typeof v === 'number' && Number.isSafeInteger(v) && v > 0 ? v : 0)
291
+ const limits = {
292
+ maxUploadTimings: n(body?.maxUploadTimings),
293
+ maxQueryHashes: n(body?.maxQueryHashes),
294
+ }
295
+ if (limits.maxUploadTimings === 0 || limits.maxQueryHashes === 0) {
296
+ throw new Error('/v1/capabilities named no maxUploadTimings or maxQueryHashes')
297
+ }
298
+ return limits
299
+ })())
300
+ // quiet: the awaited ask rethrows it; this only forgets it
301
+ asked.catch(() => {
302
+ if (this.limits === asked) this.limits = undefined
303
+ })
304
+ return asked
305
+ }
306
+
307
+ /** Each held hash's numbers under the first of `refs` that holds them: one request a run. */
308
+ async query(
309
+ refs: readonly string[],
310
+ hashes: readonly string[],
311
+ ): Promise<Map<string, ServerTiming>> {
312
+ if (hashes.length === 0 || this.refused) return new Map()
313
+ const per = (await this.limitsNow()).maxQueryHashes
314
+ const parts = await Promise.all(
315
+ Array.from({ length: Math.ceil(hashes.length / per) }, async (_, i) => {
316
+ const res = await this.call('/v1/timings/query', {
317
+ ...this.named,
318
+ refs,
319
+ hashes: hashes.slice(i * per, (i + 1) * per),
320
+ })
321
+ const body = (await jsonOf(res, '/v1/timings/query')) as { timings?: unknown } | null
322
+ if (typeof body?.timings !== 'object' || body.timings === null) {
323
+ throw new Error('the query answered no `timings` object')
324
+ }
325
+ return Object.entries(body.timings as Record<string, ServerTiming | null>)
326
+ }),
327
+ )
328
+ // Only numbers a server folds (PROTOCOL.md): `1e309` parses to Infinity,
329
+ // and kept as a forecast it made a NaN weight that failed every later
330
+ // run of the cache in `schedule`.
331
+ const out = new Map<string, ServerTiming>()
332
+ for (const [hash, t] of parts.flat()) {
333
+ if (!isCount(t?.durationMs)) continue
334
+ out.set(hash, {
335
+ durationMs: t.durationMs,
336
+ ...(typeof t.cores === 'number' && Number.isFinite(t.cores) && t.cores >= 0
337
+ ? { cores: t.cores }
338
+ : {}),
339
+ ...(isCount(t.rssBytes) ? { rssBytes: t.rssBytes } : {}),
340
+ })
341
+ }
342
+ return out
343
+ }
344
+
345
+ /** The run's timings, by task definition hash, in uploads of at most `maxUploadTimings`; `stop` abandons them. */
346
+ async upload(timings: ReadonlyMap<string, ServerTiming>, stop?: AbortSignal): Promise<void> {
347
+ if (timings.size === 0 || this.refused) return
348
+ const per = (await this.limitsNow()).maxUploadTimings
349
+ const all = [...timings].map(([hash, t]) => ({ hash, ...t }))
350
+ for (let i = 0; i < all.length; i += per) {
351
+ await this.call('/v1/timings/upload', { timings: all.slice(i, i + per) }, stop)
352
+ }
353
+ }
354
+ }
@@ -0,0 +1,110 @@
1
+ // `vx history`'s pretty table, apart from the verb that reads the history:
2
+ // a pure function of the rows, so its thresholds and its unit breaks are
3
+ // pinned without a run.
4
+
5
+ import { ui } from '@vzn/vx'
6
+ import type { Budgets, ResourceEstimate } from './index.js'
7
+
8
+ export interface HistoryRow {
9
+ id: string
10
+ runs: number
11
+ p50DurationMs: number | null
12
+ maxPeakRssBytes: number | null
13
+ maxCpuParallelism: number | null
14
+ reservation: ResourceEstimate | null
15
+ declared: boolean
16
+ }
17
+
18
+ const MB = 1024 * 1024
19
+
20
+ /**
21
+ * The table: every task with an execution in the window or a reservation,
22
+ * then a count of the ones with neither. `memoryDeclared` says where the
23
+ * memory budget came from (the option, or what the process may use).
24
+ * Painted as core's tables are (a dim header, each id in its hue).
25
+ */
26
+ export function historyBlock(
27
+ rows: readonly HistoryRow[],
28
+ window: number,
29
+ budgets: Budgets,
30
+ memoryDeclared: boolean,
31
+ ): ui.Block {
32
+ const seen = rows.filter((r) => r.runs > 0 || r.reservation !== null)
33
+ const lines: ui.Inline[] = [
34
+ [
35
+ ui.cmd('history:'),
36
+ ui.dim(
37
+ ` last ${window} runs · budgets ${budgets.cpus} core${budgets.cpus === 1 ? '' : 's'} (the default worker count; --concurrency changes it per run) · ${budgets.memory} MB` +
38
+ `${memoryDeclared ? ' (the memory option)' : ' (what this process may use)'}`,
39
+ ),
40
+ ],
41
+ ]
42
+ if (seen.length === 0) {
43
+ lines.push(ui.dim('no task has an execution in the window — run something first'))
44
+ } else {
45
+ const idW = Math.max(...seen.map((r) => Bun.stringWidth(r.id)), 4)
46
+ lines.push(
47
+ ui.dim(
48
+ ` ${'task'.padEnd(idW)} ${'runs'.padStart(4)} ${'p50'.padStart(7)} ${'peak rss'.padStart(8)} ${'cpu'.padStart(5)} reserves`,
49
+ ),
50
+ )
51
+ for (const r of seen) {
52
+ const amounts =
53
+ r.reservation === null
54
+ ? []
55
+ : [
56
+ ...(r.reservation.memory !== undefined ? [`${r.reservation.memory} MB`] : []),
57
+ ...(r.reservation.cpus !== undefined
58
+ ? [`${r.reservation.cpus} core${r.reservation.cpus === 1 ? '' : 's'}`]
59
+ : []),
60
+ ]
61
+ // A declaration of nothing (`{ memory: 0 }`) still names itself.
62
+ const reserve =
63
+ (amounts.length === 0 ? '—' : amounts.join(' · ')) +
64
+ (r.reservation !== null && r.declared ? ' (declared)' : '')
65
+ lines.push([
66
+ ' ',
67
+ ui.id(r.id),
68
+ ' '.repeat(idW - Bun.stringWidth(r.id)),
69
+ ` ${String(r.runs).padStart(4)} ${fmtMs(r.p50DurationMs).padStart(7)} ` +
70
+ `${fmtBytes(r.maxPeakRssBytes).padStart(8)} ${(r.maxCpuParallelism === null ? '—' : `${r.maxCpuParallelism.toFixed(1)}×`).padStart(5)} ${reserve}`,
71
+ ])
72
+ }
73
+ const silent = rows.length - seen.length
74
+ if (silent > 0) {
75
+ lines.push(
76
+ ui.dim(
77
+ ` ${silent} task${silent === 1 ? '' : 's'} with no execution in the window reserve${silent === 1 ? 's' : ''} nothing`,
78
+ ),
79
+ )
80
+ }
81
+ }
82
+ return { lines: (c) => lines.map((l) => ui.inline(l, c)) }
83
+ }
84
+
85
+ /** The table as plain text, as a pipe gets it. */
86
+ export function renderHistory(
87
+ rows: readonly HistoryRow[],
88
+ window: number,
89
+ budgets: Budgets,
90
+ memoryDeclared: boolean,
91
+ ): string {
92
+ return ui.render([historyBlock(rows, window, budgets, memoryDeclared)], { enabled: false })
93
+ }
94
+
95
+ // Each unit is chosen by the rounded value: 59 999 ms printed `60.00s`,
96
+ // 119.7 s `1m 60s`, and a byte under 1 MiB `1024 KB`.
97
+ function fmtMs(ms: number | null): string {
98
+ if (ms === null) return '—'
99
+ if (Math.round(ms) < 1000) return `${Math.round(ms)}ms`
100
+ if (Math.round(ms / 10) < 6000) return `${(ms / 1000).toFixed(2)}s`
101
+ const s = Math.round(ms / 1000)
102
+ return `${Math.floor(s / 60)}m ${s % 60}s`
103
+ }
104
+
105
+ function fmtBytes(n: number | null): string {
106
+ if (n === null) return '—'
107
+ if (Math.round(n / 1024) < 1024) return `${Math.round(n / 1024)} KB`
108
+ if (Math.round(n / MB) < 1024) return `${(n / MB).toFixed(0)} MB`
109
+ return `${(n / (1024 * MB)).toFixed(1)} GB`
110
+ }