@vzn/vx-history 0.0.0 → 0.0.648
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +208 -1
- package/index.ts +4 -0
- package/package.json +45 -4
- package/schemas/history.json +55 -0
- package/src/history-server.ts +354 -0
- package/src/history-view.ts +110 -0
- package/src/index.ts +887 -0
- package/src/timings-file.ts +97 -0
|
@@ -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
|
+
}
|