@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.
- package/LICENSE +21 -0
- package/README.md +209 -2
- package/index.ts +4 -0
- package/package.json +47 -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
package/src/index.ts
ADDED
|
@@ -0,0 +1,887 @@
|
|
|
1
|
+
// History-based scheduling as a plugin: order ready tasks by their expected
|
|
2
|
+
// REMAINING critical-path duration (own p50 + the longest chain of
|
|
3
|
+
// dependents), learned from the local run history. This was core's opt-in
|
|
4
|
+
// `predictive` mode until 2026-09-02 and core's one bundled plugin until
|
|
5
|
+
// 2026-09-10; it is its own package now — declared like any other, paying
|
|
6
|
+
// its history read only in workspaces that ask for it, and core ships no
|
|
7
|
+
// plugin at all.
|
|
8
|
+
//
|
|
9
|
+
// Cache hits are NOT modelled as zero-cost: predicting cache state needs
|
|
10
|
+
// the key and a probe, which the scheduler handles at run time (a confirmed
|
|
11
|
+
// hit is backfill, never a critical-path task). The estimate here is "what
|
|
12
|
+
// if everything ran", which is exactly the case where order matters.
|
|
13
|
+
|
|
14
|
+
import {
|
|
15
|
+
currentBranch,
|
|
16
|
+
definePlugin,
|
|
17
|
+
LocalHistoryProvider,
|
|
18
|
+
machineMemoryBytes,
|
|
19
|
+
Cache,
|
|
20
|
+
type HistoryTable,
|
|
21
|
+
type TaskNode,
|
|
22
|
+
type TaskResources,
|
|
23
|
+
type VxPlugin,
|
|
24
|
+
loadResolvedProjects,
|
|
25
|
+
criticalPath,
|
|
26
|
+
taskDefinitions,
|
|
27
|
+
UserError,
|
|
28
|
+
refuseUnknownOptions,
|
|
29
|
+
ui,
|
|
30
|
+
type PluginOptionKinds,
|
|
31
|
+
} from '@vzn/vx'
|
|
32
|
+
import type { CommandContext } from '@vzn/vx'
|
|
33
|
+
import path from 'node:path'
|
|
34
|
+
import type { HistoryRow } from './history-view.js'
|
|
35
|
+
import type { HistoryServer, Named, ServerTiming } from './history-server.js'
|
|
36
|
+
|
|
37
|
+
export interface VxHistoryOptions {
|
|
38
|
+
/** How many recent invocations to learn from. Default 20. */
|
|
39
|
+
readonly window?: number
|
|
40
|
+
/**
|
|
41
|
+
* Pack tasks by what their last `window` executions actually used
|
|
42
|
+
* (`admit` stage): a task's reservation is the largest peak RSS seen
|
|
43
|
+
* times `headroom`, rounded up to 64 MB. Cores are never learned (see
|
|
44
|
+
* `resourceEstimates`), only declared in `reservations`. A task is
|
|
45
|
+
* admitted while the reservations of everything running beside it fit
|
|
46
|
+
* the budgets, and a task over a whole budget runs alone. A task with no execution in the window
|
|
47
|
+
* reserves nothing and runs freely. `false` turns packing off.
|
|
48
|
+
* Default `{ headroom: 1.25 }`.
|
|
49
|
+
*/
|
|
50
|
+
readonly resources?: false | { readonly headroom?: number }
|
|
51
|
+
/**
|
|
52
|
+
* Memory budget in megabytes the reservations pack against. Default:
|
|
53
|
+
* what this process may use — the machine's total, capped by the
|
|
54
|
+
* cgroup limit a container runs under (`os.totalmem()` alone reports
|
|
55
|
+
* the HOST's RAM there). Pass it to budget below either.
|
|
56
|
+
*/
|
|
57
|
+
readonly memory?: number
|
|
58
|
+
/**
|
|
59
|
+
* Reservations declared by hand (task id → cores, megabytes) for the
|
|
60
|
+
* task history cannot size: a first run, or a task whose peak the
|
|
61
|
+
* runner cannot see. A declared axis wins over the learned one; an axis
|
|
62
|
+
* left out keeps it.
|
|
63
|
+
*/
|
|
64
|
+
readonly reservations?: Readonly<Record<string, ResourceEstimate>>
|
|
65
|
+
/**
|
|
66
|
+
* Durations (task id → ms) to assume for tasks the history has not seen
|
|
67
|
+
* yet. A fresh CI runner has no history at all, and there the baseline
|
|
68
|
+
* order starts a long leaf task last — the one place the scheduler's
|
|
69
|
+
* structural tie-break is worst. A recorded p50 always wins over an
|
|
70
|
+
* assumption, and assumptions never feed the workspace median: they are
|
|
71
|
+
* a hint for the cold run, not evidence.
|
|
72
|
+
*/
|
|
73
|
+
readonly assume?: Readonly<Record<string, number>>
|
|
74
|
+
/**
|
|
75
|
+
* A timings file, from the workspace root, that carries the learned
|
|
76
|
+
* durations between machines: read before ordering, rewritten after the
|
|
77
|
+
* run with each task's p50 (task id → ms), so CI caches one file. A
|
|
78
|
+
* recorded p50 here wins over it, and it wins over `assume`; like
|
|
79
|
+
* `assume`, it never feeds the workspace median.
|
|
80
|
+
*/
|
|
81
|
+
readonly file?: string
|
|
82
|
+
/**
|
|
83
|
+
* A vx-history server's https URL (`@vzn/vx-history-server`; http only to
|
|
84
|
+
* this machine), or `VX_HISTORY_URL`. On a CI job with a token (GitHub's
|
|
85
|
+
* OIDC, or GitLab's
|
|
86
|
+
* `VX_HISTORY_TOKEN`) each run asks it for its tasks' numbers, without
|
|
87
|
+
* waiting, and sends its own timings once at its end.
|
|
88
|
+
*/
|
|
89
|
+
readonly url?: string
|
|
90
|
+
/**
|
|
91
|
+
* Headers every request to `url` carries, for a proxy in front of it
|
|
92
|
+
* (`{ 'CF-Access-Client-Id': process.env.CF_ACCESS_CLIENT_ID }`, an
|
|
93
|
+
* Access service token, 2026-10-25). An unset value is left out;
|
|
94
|
+
* `authorization` stays the CI token's.
|
|
95
|
+
*/
|
|
96
|
+
readonly headers?: Record<string, string | undefined>
|
|
97
|
+
/**
|
|
98
|
+
* The CI issuer whose tokens `url` takes, as they name it
|
|
99
|
+
* (`https://token.actions.githubusercontent.com`), and the repository at
|
|
100
|
+
* it, `org/name`: required with a server. The run's query names them
|
|
101
|
+
* (2026-10-25: the user names them, nothing guesses).
|
|
102
|
+
*/
|
|
103
|
+
readonly issuer?: string
|
|
104
|
+
readonly repository?: string
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// The provider's own default (50) serves `--dry` predictions; an ordering
|
|
108
|
+
// hint needs less and reads a 2.5× smaller slice of the run history.
|
|
109
|
+
const DEFAULT_WINDOW = 20
|
|
110
|
+
|
|
111
|
+
// Each option is read in ONE place that a run's hooks and `vx history`
|
|
112
|
+
// share: two copies of each let either drift unseen (item 805).
|
|
113
|
+
const windowOf = (options: VxHistoryOptions): number =>
|
|
114
|
+
usable(options.window, true) ? options.window : DEFAULT_WINDOW
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* A number option's value when it is one: finite and above zero (a whole
|
|
118
|
+
* number where `integer`). `memory: Number(process.env.X)` with X unset
|
|
119
|
+
* is NaN, and against a NaN budget no reservation ever fits, so every
|
|
120
|
+
* task that reserved memory waited for an idle machine: the run went
|
|
121
|
+
* serial and said nothing. A NaN `headroom` dropped every learned
|
|
122
|
+
* reservation. Each bad value is the default instead, named by
|
|
123
|
+
* `numberWarnings`, as `assume` does (item 930).
|
|
124
|
+
*/
|
|
125
|
+
function usable(v: unknown, integer = false): v is number {
|
|
126
|
+
return typeof v === 'number' && Number.isFinite(v) && v > 0 && (!integer || Number.isInteger(v))
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** One warning naming every number option that is not one, and the default it runs on. */
|
|
130
|
+
function numberWarnings(options: VxHistoryOptions, warn: (m: string) => void): void {
|
|
131
|
+
const bad: string[] = []
|
|
132
|
+
const check = (name: string, v: unknown, fallback: string, integer = false): void => {
|
|
133
|
+
if (v !== undefined && !usable(v, integer)) {
|
|
134
|
+
bad.push(
|
|
135
|
+
`${name} ${typeof v === 'number' ? String(v) : JSON.stringify(v)} (using ${fallback})`,
|
|
136
|
+
)
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
check('window', options.window, String(DEFAULT_WINDOW), true)
|
|
140
|
+
check('memory', options.memory, 'what this process may use')
|
|
141
|
+
if (options.resources !== false && options.resources !== undefined) {
|
|
142
|
+
check('resources.headroom', options.resources.headroom, String(DEFAULT_HEADROOM))
|
|
143
|
+
}
|
|
144
|
+
for (const [id, r] of Object.entries(options.reservations ?? {})) {
|
|
145
|
+
if (r === null || typeof r !== 'object' || Array.isArray(r)) {
|
|
146
|
+
bad.push(
|
|
147
|
+
`reservations[${JSON.stringify(id)}] ${JSON.stringify(r) ?? String(r)} (using what history learned; must be { cpus?, memory? })`,
|
|
148
|
+
)
|
|
149
|
+
continue
|
|
150
|
+
}
|
|
151
|
+
for (const axis of ['cpus', 'memory'] as const) {
|
|
152
|
+
const v = r[axis]
|
|
153
|
+
if (v !== 0) check(`reservations[${JSON.stringify(id)}].${axis}`, v, 'what history learned')
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
if (bad.length > 0) {
|
|
157
|
+
warn(`[vx] vx-history: ignores ${bad.join(', ')} — each must be a finite number above 0`)
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* The declared reservations, each axis only where it is a number above 0,
|
|
163
|
+
* or 0: a declared nothing, over what history learned. A NaN
|
|
164
|
+
* (`Number(process.env.X)`, X unset) held a task alone and every
|
|
165
|
+
* reserving task beside it waited — the NaN summed into what runs — so the
|
|
166
|
+
* run went serial and said nothing (G-4's class). A bad axis is not
|
|
167
|
+
* declared, named by `numberWarnings`.
|
|
168
|
+
*/
|
|
169
|
+
function declaredOf(
|
|
170
|
+
options: VxHistoryOptions,
|
|
171
|
+
): Readonly<Record<string, ResourceEstimate>> | undefined {
|
|
172
|
+
if (options.reservations === undefined) return undefined
|
|
173
|
+
const out: Record<string, ResourceEstimate> = {}
|
|
174
|
+
for (const [id, r] of Object.entries(options.reservations)) {
|
|
175
|
+
const e: { cpus?: number; memory?: number } = {}
|
|
176
|
+
if (r?.cpus === 0 || usable(r?.cpus)) e.cpus = r.cpus
|
|
177
|
+
if (r?.memory === 0 || usable(r?.memory)) e.memory = r.memory
|
|
178
|
+
out[id] = e
|
|
179
|
+
}
|
|
180
|
+
return out
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const headroomOf = (options: VxHistoryOptions): number => {
|
|
184
|
+
const h = options.resources === false ? undefined : options.resources?.headroom
|
|
185
|
+
return usable(h) ? h : DEFAULT_HEADROOM
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// The schedule reads no rates (`resourcesFor`); `vx history` shows runs.
|
|
189
|
+
const readHistory = (
|
|
190
|
+
cache: Cache,
|
|
191
|
+
ids: readonly string[],
|
|
192
|
+
options: VxHistoryOptions,
|
|
193
|
+
branch: string | null,
|
|
194
|
+
defs: ReadonlyMap<string, string>,
|
|
195
|
+
): Promise<ReadonlyMap<string, TaskResources>> =>
|
|
196
|
+
new LocalHistoryProvider(cache.dbHandle(), windowOf(options), branch, defs).resourcesFor(ids)
|
|
197
|
+
|
|
198
|
+
/** What each task reserves: learned from the history unless `resources: false`, a declared reservation over either. */
|
|
199
|
+
const reservationsFor = (
|
|
200
|
+
ids: Iterable<string>,
|
|
201
|
+
table: ReadonlyMap<string, TaskResources>,
|
|
202
|
+
options: VxHistoryOptions,
|
|
203
|
+
): ReadonlyMap<string, ResourceEstimate> =>
|
|
204
|
+
withDeclared(
|
|
205
|
+
options.resources !== false ? estimatesFor(ids, table, headroomOf(options)) : new Map(),
|
|
206
|
+
declaredOf(options),
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
const memoryBudgetMb = (options: VxHistoryOptions): number =>
|
|
210
|
+
usable(options.memory) ? options.memory : Math.floor(machineMemoryBytes() / MB)
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* The `assume` durations that are numbers from 0 to 2^53: a larger one
|
|
214
|
+
* summed along a chain to Infinity, which core refuses too. A NaN
|
|
215
|
+
* (`Number(process.env.X)` with X unset) became a NaN weight and a string
|
|
216
|
+
* (an untyped `.mjs` config) string weights, and core refused both as a
|
|
217
|
+
* UserError: an ordering hint failed the run (item 930). The rest are
|
|
218
|
+
* dropped by name, as a broken history read costs only the ordering.
|
|
219
|
+
*/
|
|
220
|
+
function assumptions(
|
|
221
|
+
options: VxHistoryOptions,
|
|
222
|
+
warn: (m: string) => void,
|
|
223
|
+
): Readonly<Record<string, number>> {
|
|
224
|
+
const ok: Record<string, number> = {}
|
|
225
|
+
const bad: string[] = []
|
|
226
|
+
for (const [id, ms] of Object.entries(options.assume ?? {})) {
|
|
227
|
+
if (isDuration(ms)) ok[id] = ms
|
|
228
|
+
else bad.push(id)
|
|
229
|
+
}
|
|
230
|
+
if (bad.length > 0) {
|
|
231
|
+
warn(
|
|
232
|
+
`[vx] vx-history: assume ignores ${bad.map((id) => JSON.stringify(id)).join(', ')} — each must be a number of ms from 0 to 2^53`,
|
|
233
|
+
)
|
|
234
|
+
}
|
|
235
|
+
return ok
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* A duration a task can take, in ms. A chain's weight is its durations
|
|
240
|
+
* summed, and two `1e308`s (a timings file a CI cache handed over, or
|
|
241
|
+
* `assume`) summed to Infinity, which core refuses: the run failed in
|
|
242
|
+
* `schedule`. Under 2^53 ms, any chain's sum stays finite.
|
|
243
|
+
*/
|
|
244
|
+
const isDuration = (ms: unknown): ms is number =>
|
|
245
|
+
typeof ms === 'number' && ms >= 0 && ms <= Number.MAX_SAFE_INTEGER
|
|
246
|
+
|
|
247
|
+
/** The timings file's durations a run can take; the rest are not carried. */
|
|
248
|
+
function carriedOf(read: Readonly<Record<string, number>>): Record<string, number> {
|
|
249
|
+
const out: Record<string, number> = {}
|
|
250
|
+
for (const [id, ms] of Object.entries(read)) if (isDuration(ms)) out[id] = ms
|
|
251
|
+
return out
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** Each option `VxHistoryOptions` names, with its kind: derived from the type, so the two cannot drift. */
|
|
255
|
+
const VX_HISTORY_KEYS: PluginOptionKinds<VxHistoryOptions> = {
|
|
256
|
+
window: 'number',
|
|
257
|
+
resources: 'any',
|
|
258
|
+
memory: 'number',
|
|
259
|
+
reservations: 'object',
|
|
260
|
+
assume: 'object',
|
|
261
|
+
file: 'string',
|
|
262
|
+
url: 'string',
|
|
263
|
+
headers: 'object',
|
|
264
|
+
issuer: 'string',
|
|
265
|
+
repository: 'string',
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** `vxHistory({ issuer, repository })`, checked; the server's requests name them. */
|
|
269
|
+
function namedOf(issuer: unknown, repository: unknown): Named {
|
|
270
|
+
if (typeof issuer !== 'string' || !/^https?:\/\/\S+$/.test(issuer))
|
|
271
|
+
throw new UserError(
|
|
272
|
+
`vxHistory(): issuer must be the CI issuer's URL (https://token.actions.githubusercontent.com), got ${String(issuer)}`,
|
|
273
|
+
)
|
|
274
|
+
if (typeof repository !== 'string' || !/^[^/\s]+(\/[^/\s]+)+$/.test(repository))
|
|
275
|
+
throw new UserError(`vxHistory(): repository must be org/name, got ${String(repository)}`)
|
|
276
|
+
return { issuer: issuer.replace(/\/$/, ''), repository: repository.toLowerCase() }
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* https, or http to this machine: every request carries the CI token, and
|
|
281
|
+
* http:// to another host sent it in clear.
|
|
282
|
+
*/
|
|
283
|
+
function secure(url: string): boolean {
|
|
284
|
+
if (/^https:\/\/\S/.test(url)) return true
|
|
285
|
+
if (!/^http:\/\/\S/.test(url)) return false
|
|
286
|
+
const host = URL.parse(url)?.hostname
|
|
287
|
+
return host === '127.0.0.1' || host === '[::1]' || host === 'localhost'
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** `VX_HISTORY_URL` under the option, trailing slashes dropped; undefined when neither names one. */
|
|
291
|
+
function serverUrlOf(
|
|
292
|
+
option: string | undefined,
|
|
293
|
+
env: Record<string, string | undefined> = Bun.env,
|
|
294
|
+
): string | undefined {
|
|
295
|
+
const url = (option ?? env.VX_HISTORY_URL)?.replace(/\/+$/, '')
|
|
296
|
+
if (url === undefined || url === '') return undefined
|
|
297
|
+
// Named as the workspace set it: a bad VX_HISTORY_URL was refused as `url`.
|
|
298
|
+
const from = option === undefined ? 'VX_HISTORY_URL' : 'url'
|
|
299
|
+
if (!secure(url))
|
|
300
|
+
throw new UserError(
|
|
301
|
+
`vxHistory(): ${from} must be https (http only to 127.0.0.1, ::1 or localhost), got ${url}`,
|
|
302
|
+
)
|
|
303
|
+
// Each route is the server's /v1/…: one that names /v1 asked /v1/v1/…, and every answer was a 404.
|
|
304
|
+
if (url.endsWith('/v1'))
|
|
305
|
+
throw new UserError(
|
|
306
|
+
`vxHistory(): ${from} is the server's base, without /v1: ${url.slice(0, -3)}`,
|
|
307
|
+
)
|
|
308
|
+
return url
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* The names a request sets itself: one of them in `headers`, in another
|
|
313
|
+
* case, was joined to the request's own value, and the server refused it.
|
|
314
|
+
*/
|
|
315
|
+
const WIRE_HEADERS = new Set(['authorization', 'content-type', 'content-length'])
|
|
316
|
+
|
|
317
|
+
/** `vxHistory({ headers })` with unset values left out; a name or value no header can carry, or `authorization`, refused, never printing a value. */
|
|
318
|
+
function proxyHeaders(
|
|
319
|
+
headers: Record<string, string | undefined> | undefined,
|
|
320
|
+
): Record<string, string> {
|
|
321
|
+
const out: Record<string, string> = {}
|
|
322
|
+
for (const [name, value] of Object.entries(headers ?? {})) {
|
|
323
|
+
if (value === undefined) continue
|
|
324
|
+
if (!/^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/.test(name) || WIRE_HEADERS.has(name.toLowerCase()))
|
|
325
|
+
throw new UserError(`vxHistory() option "headers" cannot set "${name}"`)
|
|
326
|
+
if (typeof value !== 'string' || /[\r\n\0]/.test(value))
|
|
327
|
+
throw new UserError(
|
|
328
|
+
`vxHistory() option "headers.${name}" must be one line of text (its value is not printed)`,
|
|
329
|
+
)
|
|
330
|
+
out[name] = value
|
|
331
|
+
}
|
|
332
|
+
return out
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
export function vxHistory(options: VxHistoryOptions = {}): VxPlugin {
|
|
336
|
+
refuseUnknownOptions('vxHistory()', options, VX_HISTORY_KEYS)
|
|
337
|
+
const named =
|
|
338
|
+
options.url === undefined && options.issuer === undefined && options.repository === undefined
|
|
339
|
+
? undefined
|
|
340
|
+
: namedOf(options.issuer, options.repository)
|
|
341
|
+
// At the call: refused once a server was named, a laptop passed what
|
|
342
|
+
// failed the first CI run.
|
|
343
|
+
const headers = proxyHeaders(options.headers)
|
|
344
|
+
// `'any'` above lets `null` through, and every run then failed in
|
|
345
|
+
// `schedule` reading `null.headroom`.
|
|
346
|
+
const { resources } = options
|
|
347
|
+
if (
|
|
348
|
+
resources !== undefined &&
|
|
349
|
+
resources !== false &&
|
|
350
|
+
(resources === null || typeof resources !== 'object' || Array.isArray(resources))
|
|
351
|
+
) {
|
|
352
|
+
throw new UserError(
|
|
353
|
+
`vxHistory() option "resources" must be false or an object, got ${typeof resources === 'number' ? String(resources) : (JSON.stringify(resources) ?? String(resources))}`,
|
|
354
|
+
)
|
|
355
|
+
}
|
|
356
|
+
// A misspelt key inside was read as unset, unsaid. Values stay `any`:
|
|
357
|
+
// a bad number warns per run (`numberWarnings`), as at the top level.
|
|
358
|
+
if (resources !== undefined && resources !== false) {
|
|
359
|
+
refuseUnknownOptions('vxHistory() option "resources"', resources, { headroom: 'any' })
|
|
360
|
+
}
|
|
361
|
+
for (const [id, r] of Object.entries(options.reservations ?? {})) {
|
|
362
|
+
if (r !== null && typeof r === 'object' && !Array.isArray(r)) {
|
|
363
|
+
refuseUnknownOptions(`vxHistory() option "reservations[${JSON.stringify(id)}]"`, r, {
|
|
364
|
+
cpus: 'any',
|
|
365
|
+
memory: 'any',
|
|
366
|
+
})
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
// Checked here, not in a run's `setup`: `vx show` and a `--dry` plan
|
|
370
|
+
// took a url no request can carry, and every run failed on it.
|
|
371
|
+
if (options.url !== undefined) serverUrlOf(options.url)
|
|
372
|
+
// `''` resolved to the workspace root: each run warned it could not read
|
|
373
|
+
// a directory and failed in teardown renaming its timings over it.
|
|
374
|
+
if (options.file !== undefined && options.file.trim() === '') {
|
|
375
|
+
throw new UserError(
|
|
376
|
+
`vxHistory() option "file" must be a path, got ${JSON.stringify(options.file)}`,
|
|
377
|
+
)
|
|
378
|
+
}
|
|
379
|
+
const hooks: Parameters<typeof definePlugin>[1] = {
|
|
380
|
+
commands: {
|
|
381
|
+
history: {
|
|
382
|
+
description:
|
|
383
|
+
'What vx-history learned per task (p50, peak RSS, CPU parallelism) and the reservation it packs.',
|
|
384
|
+
run: (argv, ctx) => historyCmd(argv, ctx, options),
|
|
385
|
+
},
|
|
386
|
+
},
|
|
387
|
+
// Core calls it once per run, so the history is read once per run, and
|
|
388
|
+
// a second run in the same process (`vx watch`) reads its own.
|
|
389
|
+
async schedule(nodes, ctx) {
|
|
390
|
+
numberWarnings(options, ctx.warn)
|
|
391
|
+
const file =
|
|
392
|
+
options.file === undefined ? undefined : path.resolve(ctx.workspaceRoot, options.file)
|
|
393
|
+
// Loaded with the option: every run evaluates the workspace config.
|
|
394
|
+
const carried =
|
|
395
|
+
file === undefined
|
|
396
|
+
? {}
|
|
397
|
+
: carriedOf((await import('./timings-file.js')).readTimings(file, ctx.warn))
|
|
398
|
+
const defs = taskDefinitions(nodes.values())
|
|
399
|
+
if (file !== undefined)
|
|
400
|
+
scheduled = {
|
|
401
|
+
file,
|
|
402
|
+
carried,
|
|
403
|
+
cache: ctx.localCache,
|
|
404
|
+
ids: [...nodes.keys()],
|
|
405
|
+
branch: ctx.branch,
|
|
406
|
+
defs,
|
|
407
|
+
}
|
|
408
|
+
// Only a task that runs a command has a timing: a group asked for
|
|
409
|
+
// read as a task with no history on this branch, so every run
|
|
410
|
+
// tried a default-branch import (and paid a query per group).
|
|
411
|
+
const ids: string[] = []
|
|
412
|
+
for (const n of nodes.values()) if (n.config.exec?.command !== undefined) ids.push(n.id)
|
|
413
|
+
let table: ReadonlyMap<string, TaskResources>
|
|
414
|
+
let provider: LocalHistoryProvider
|
|
415
|
+
try {
|
|
416
|
+
provider = new LocalHistoryProvider(
|
|
417
|
+
ctx.localCache.dbHandle(),
|
|
418
|
+
windowOf(options),
|
|
419
|
+
ctx.branch,
|
|
420
|
+
defs,
|
|
421
|
+
)
|
|
422
|
+
table = await provider.resourcesFor(ids)
|
|
423
|
+
} catch (err) {
|
|
424
|
+
// Failing open: a broken history read costs the ordering and the
|
|
425
|
+
// learned reservations, never the run.
|
|
426
|
+
ctx.warn(
|
|
427
|
+
`[vx] vx-history: ordering falls back to the baseline: ${err instanceof Error ? err.message : String(err)}`,
|
|
428
|
+
)
|
|
429
|
+
return undefined
|
|
430
|
+
}
|
|
431
|
+
reservations = reservationsFor(nodes.keys(), table, options)
|
|
432
|
+
// Once a run: the server's numbers order it a second time.
|
|
433
|
+
const assumed = { ...assumptions(options, ctx.warn), ...carried }
|
|
434
|
+
const order = (t: ReadonlyMap<string, TaskResources>): ReadonlyMap<string, number> =>
|
|
435
|
+
criticalPath([...nodes.values()], t, assumed)
|
|
436
|
+
// With a history server, its numbers re-rank what has not started
|
|
437
|
+
// when they land; the run never waits on them.
|
|
438
|
+
remote ??= await serverOf()
|
|
439
|
+
// The run's refs, as a remote cache reads them: its branch, the
|
|
440
|
+
// branch it merges into, the default branch (`baseBranch`). `main`
|
|
441
|
+
// was assumed, so a `master` or `baseBranch: 'trunk'` repository
|
|
442
|
+
// read no trunk's numbers; a run with none asks nothing.
|
|
443
|
+
const refs = ctx.refs
|
|
444
|
+
if (remote !== undefined && refs.length > 0) {
|
|
445
|
+
const server = remote
|
|
446
|
+
let late = false
|
|
447
|
+
stopImport = () => (late = true)
|
|
448
|
+
const weights = (async () => {
|
|
449
|
+
const hashes = [...new Set(ids.flatMap((id) => defs.get(id) ?? []))]
|
|
450
|
+
const got = await server.query([...new Set(refs)], hashes)
|
|
451
|
+
const timings = new Map<string, number>()
|
|
452
|
+
const needs = new Map<string, TaskResources>()
|
|
453
|
+
for (const id of ids) {
|
|
454
|
+
const t = got.get(defs.get(id) ?? '')
|
|
455
|
+
if (t === undefined) continue
|
|
456
|
+
timings.set(id, t.durationMs)
|
|
457
|
+
needs.set(id, {
|
|
458
|
+
p50DurationMs: t.durationMs,
|
|
459
|
+
...(t.cores === undefined ? {} : { maxCpuParallelism: t.cores }),
|
|
460
|
+
...(t.rssBytes === undefined ? {} : { maxPeakRssBytes: t.rssBytes }),
|
|
461
|
+
})
|
|
462
|
+
}
|
|
463
|
+
// A run that ended first writes nothing: its cache is closing.
|
|
464
|
+
if (timings.size === 0 || late) return undefined
|
|
465
|
+
const sha = `vx-history-server:${Bun.hash(JSON.stringify([...got])).toString(16)}`
|
|
466
|
+
if (!provider.hasImported(sha)) provider.importTimings(sha, timings, needs, defs)
|
|
467
|
+
const fresh = await provider.resourcesFor(ids)
|
|
468
|
+
reservations = reservationsFor(nodes.keys(), fresh, options)
|
|
469
|
+
return order(fresh)
|
|
470
|
+
})().catch((err: unknown) => {
|
|
471
|
+
if (!late) {
|
|
472
|
+
ctx.warn(
|
|
473
|
+
`[vx] vx-history: the timings of the history server at ${server.url} were not applied: ${err instanceof Error ? err.message : String(err)}`,
|
|
474
|
+
)
|
|
475
|
+
}
|
|
476
|
+
return undefined
|
|
477
|
+
})
|
|
478
|
+
ctx.refine(weights)
|
|
479
|
+
}
|
|
480
|
+
return order(table)
|
|
481
|
+
},
|
|
482
|
+
}
|
|
483
|
+
// The run's history server, when one is named and the job has a token.
|
|
484
|
+
let remote: HistoryServer | undefined
|
|
485
|
+
// Loaded only when a server is named: every run evaluates the config.
|
|
486
|
+
const serverOf = async (): Promise<HistoryServer | undefined> => {
|
|
487
|
+
if ((options.url ?? Bun.env.VX_HISTORY_URL ?? '') === '') return undefined
|
|
488
|
+
// VX_HISTORY_URL alone: the workspace names no repository to ask for,
|
|
489
|
+
// so the URL is never read; parsed first, a malformed one failed the run.
|
|
490
|
+
if (named === undefined) return undefined
|
|
491
|
+
const m = await import('./history-server.js')
|
|
492
|
+
const url = serverUrlOf(options.url)
|
|
493
|
+
if (url === undefined) return undefined
|
|
494
|
+
return new m.HistoryServer(url, named, Bun.env, fetch, headers)
|
|
495
|
+
}
|
|
496
|
+
// What the run sends the history server at its end, by task definition.
|
|
497
|
+
let reported: Reported | undefined
|
|
498
|
+
// The upload, once the run has ended.
|
|
499
|
+
let uploading: Upload | undefined
|
|
500
|
+
// Stops the server's answer from landing once the run is over: no row
|
|
501
|
+
// lands after the cache closes.
|
|
502
|
+
let stopImport: (() => void) | undefined
|
|
503
|
+
// The timings file is written for a run only: `schedule` also orders a
|
|
504
|
+
// `--dry` plan, which has no `setup` and records nothing.
|
|
505
|
+
let scheduled:
|
|
506
|
+
| {
|
|
507
|
+
file: string
|
|
508
|
+
carried: Readonly<Record<string, number>>
|
|
509
|
+
cache: Cache
|
|
510
|
+
ids: string[]
|
|
511
|
+
branch: string | null
|
|
512
|
+
defs: ReadonlyMap<string, string>
|
|
513
|
+
}
|
|
514
|
+
| undefined
|
|
515
|
+
let inRun = false
|
|
516
|
+
// A run that executed nothing and restored only tasks the file already
|
|
517
|
+
// times leaves it as it was: an all-cached CI run re-reads no history.
|
|
518
|
+
let changed = false
|
|
519
|
+
let fileWarn: { root: string; warn: (m: string) => void } | undefined
|
|
520
|
+
hooks.setup = async (ctx) => {
|
|
521
|
+
remote ??= await serverOf()
|
|
522
|
+
if (remote?.writes === true) {
|
|
523
|
+
const { timingOf: timing } = await import('./history-server.js')
|
|
524
|
+
// Only the run that ran the tasks hears them: under DTE, the
|
|
525
|
+
// orchestrator, with each worker's measurements.
|
|
526
|
+
const timings = new Map<string, ServerTiming>()
|
|
527
|
+
const sent = (reported = { timings, warn: ctx.warn })
|
|
528
|
+
const server = remote
|
|
529
|
+
ctx.on('onTaskComplete', (node, outcome) => {
|
|
530
|
+
if (node.config.exec?.command === undefined) return
|
|
531
|
+
const def = taskDefinitions([node]).get(node.id)
|
|
532
|
+
const t = timing(outcome)
|
|
533
|
+
if (def !== undefined && t !== undefined) timings.set(def, t)
|
|
534
|
+
})
|
|
535
|
+
// Every outcome is known here, and core's own end of run (history
|
|
536
|
+
// rows, cache uploads, telemetry) is still ahead: sent from teardown,
|
|
537
|
+
// a new OIDC token and the upload shared its 3 s bound.
|
|
538
|
+
ctx.on('onRunEnd', () => {
|
|
539
|
+
if (reported === sent) uploading ??= sendTimings(server, sent)
|
|
540
|
+
})
|
|
541
|
+
} else if (remote !== undefined) {
|
|
542
|
+
if (ctx.ci)
|
|
543
|
+
ctx.warn(
|
|
544
|
+
Bun.env.GITHUB_ACTIONS === 'true'
|
|
545
|
+
? '[vx] vx-history: this job gets no OIDC token for the history server (give it `permissions: id-token: write`): it reads only'
|
|
546
|
+
: '[vx] vx-history: no CI token for the history server (name an id_tokens entry VX_HISTORY_TOKEN): it reads only',
|
|
547
|
+
)
|
|
548
|
+
} else if ((options.url ?? Bun.env.VX_HISTORY_URL ?? '') !== '') {
|
|
549
|
+
ctx.warn(
|
|
550
|
+
'[vx] vx-history: VX_HISTORY_URL names a server, but vxHistory() names no issuer and repository: the server is not asked',
|
|
551
|
+
)
|
|
552
|
+
}
|
|
553
|
+
if (options.file === undefined) return
|
|
554
|
+
fileWarn = { root: ctx.workspaceRoot, warn: ctx.warn }
|
|
555
|
+
inRun = true
|
|
556
|
+
changed = false
|
|
557
|
+
ctx.on('onTaskComplete', (node, outcome) => {
|
|
558
|
+
// A group runs nothing and is never timed: counting it rewrote the
|
|
559
|
+
// file on every all-cached run that went through one.
|
|
560
|
+
if (node.config.exec?.command === undefined) return
|
|
561
|
+
if (outcome.status === 'success' || !(node.id in (scheduled?.carried ?? {}))) {
|
|
562
|
+
changed = true
|
|
563
|
+
}
|
|
564
|
+
})
|
|
565
|
+
}
|
|
566
|
+
// After the run's history is recorded and before its cache closes.
|
|
567
|
+
hooks.teardown = async () => {
|
|
568
|
+
stopImport?.()
|
|
569
|
+
stopImport = undefined
|
|
570
|
+
const server = remote
|
|
571
|
+
const sent = reported
|
|
572
|
+
const up = uploading
|
|
573
|
+
remote = undefined
|
|
574
|
+
reported = undefined
|
|
575
|
+
uploading = undefined
|
|
576
|
+
// Counted from here: the file's write below shares the bound.
|
|
577
|
+
const waited = server !== undefined && sent !== undefined ? waitFor(UPLOAD_WAIT_MS) : undefined
|
|
578
|
+
const done = scheduled
|
|
579
|
+
const wrote = inRun && changed
|
|
580
|
+
scheduled = undefined
|
|
581
|
+
inRun = false
|
|
582
|
+
// The file first: core bounds a teardown (3 s), and an upload ahead of
|
|
583
|
+
// it held the write hostage to the server's network. A failed write
|
|
584
|
+
// still sends.
|
|
585
|
+
try {
|
|
586
|
+
if (wrote && done !== undefined) {
|
|
587
|
+
const table = await readHistory(done.cache, done.ids, options, done.branch, done.defs)
|
|
588
|
+
const timings = await import('./timings-file.js')
|
|
589
|
+
// Over the file as it is now: a time another writer (a run in another
|
|
590
|
+
// worktree sharing it) put there since `schedule` read it is kept. Its
|
|
591
|
+
// fault, if any, was said when the run read it.
|
|
592
|
+
const now = timings.readTimings(done.file, () => {})
|
|
593
|
+
let written = true
|
|
594
|
+
try {
|
|
595
|
+
timings.writeTimings(done.file, { ...done.carried, ...now }, table)
|
|
596
|
+
} catch (err) {
|
|
597
|
+
// Only the file is lost: said as the read is, not as a plugin failure.
|
|
598
|
+
written = false
|
|
599
|
+
fileWarn?.warn(
|
|
600
|
+
`[vx] vx-history: timings file ${done.file} not written: ${err instanceof Error ? err.message : String(err)}`,
|
|
601
|
+
)
|
|
602
|
+
}
|
|
603
|
+
if (written && fileWarn !== undefined)
|
|
604
|
+
timings.warnUnignored(done.file, fileWarn.root, fileWarn.warn)
|
|
605
|
+
}
|
|
606
|
+
} finally {
|
|
607
|
+
if (server !== undefined && sent !== undefined && waited !== undefined) {
|
|
608
|
+
const u = up ?? sendTimings(server, sent)
|
|
609
|
+
const late = await Promise.race([u.done.then(() => false), waited.done])
|
|
610
|
+
waited.cancel()
|
|
611
|
+
if (late) {
|
|
612
|
+
u.stop.abort()
|
|
613
|
+
const n = sent.timings.size
|
|
614
|
+
sent.warn(
|
|
615
|
+
`[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`,
|
|
616
|
+
)
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
// What the run's tasks reserve, learned in `schedule` (one history read
|
|
622
|
+
// serves both) and declared in the options; asked at every dispatch.
|
|
623
|
+
let reservations: ReadonlyMap<string, ResourceEstimate> = new Map(
|
|
624
|
+
Object.entries(declaredOf(options) ?? {}),
|
|
625
|
+
)
|
|
626
|
+
if (options.resources !== false || options.reservations !== undefined) {
|
|
627
|
+
// Read on the first admit: every config evaluation runs this factory,
|
|
628
|
+
// and the default budget is cgroup reads on Linux.
|
|
629
|
+
let memoryMb: number | undefined
|
|
630
|
+
hooks.admit = (task, ctx) =>
|
|
631
|
+
admits(
|
|
632
|
+
task.id,
|
|
633
|
+
ctx.running.map((r) => r.id),
|
|
634
|
+
reservations,
|
|
635
|
+
{
|
|
636
|
+
cpus: ctx.concurrency,
|
|
637
|
+
memory: (memoryMb ??= memoryBudgetMb(options)),
|
|
638
|
+
},
|
|
639
|
+
)
|
|
640
|
+
}
|
|
641
|
+
return definePlugin(import.meta, hooks)
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
interface Reported {
|
|
645
|
+
readonly timings: Map<string, ServerTiming>
|
|
646
|
+
readonly warn: (m: string) => void
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
interface Upload {
|
|
650
|
+
/** Settles when sent, or once a failure is said; never rejects. */
|
|
651
|
+
readonly done: Promise<void>
|
|
652
|
+
readonly stop: AbortController
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
/**
|
|
656
|
+
* How long a teardown waits for the upload: under core's bound on one
|
|
657
|
+
* teardown (3 s, `teardownTimeoutMs`), so a dropped upload is said here,
|
|
658
|
+
* with what it dropped, and not as core's line that names no cause.
|
|
659
|
+
*/
|
|
660
|
+
const UPLOAD_WAIT_MS = 2_000
|
|
661
|
+
|
|
662
|
+
function sendTimings(server: HistoryServer, sent: Reported): Upload {
|
|
663
|
+
const stop = new AbortController()
|
|
664
|
+
const done = server.upload(sent.timings, stop.signal).catch((err: unknown) => {
|
|
665
|
+
// Abandoned by teardown, which says so itself.
|
|
666
|
+
if (stop.signal.aborted) return
|
|
667
|
+
sent.warn(
|
|
668
|
+
`[vx] vx-history: the timings were not sent to the history server at ${server.url}: ${err instanceof Error ? err.message : String(err)}`,
|
|
669
|
+
)
|
|
670
|
+
})
|
|
671
|
+
return { done, stop }
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
/** Resolves true after `ms`, unless cancelled first. */
|
|
675
|
+
function waitFor(ms: number): { done: Promise<true>; cancel: () => void } {
|
|
676
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
677
|
+
const done = new Promise<true>((resolve) => {
|
|
678
|
+
timer = setTimeout(() => resolve(true), ms)
|
|
679
|
+
})
|
|
680
|
+
return { done, cancel: () => clearTimeout(timer) }
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
/** Default multiplier over the largest peak RSS seen — the asymmetry: over-reserving costs some parallelism, under-reserving meets the OOM killer. */
|
|
684
|
+
const DEFAULT_HEADROOM = 1.25
|
|
685
|
+
const MB = 1024 * 1024
|
|
686
|
+
/** Reservations round up to this so a jittery RSS does not re-key the packing every run. */
|
|
687
|
+
const MEMORY_STEP_MB = 64
|
|
688
|
+
|
|
689
|
+
export interface ResourceEstimate {
|
|
690
|
+
readonly cpus?: number
|
|
691
|
+
readonly memory?: number
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
/**
|
|
695
|
+
* What each task should reserve, from its history: memory is the largest
|
|
696
|
+
* peak RSS in the window times `headroom`, rounded UP to 64 MB and omitted
|
|
697
|
+
* under one step (nothing worth packing). Cores are never learned: the
|
|
698
|
+
* parallelism a build shows is what the machine let it have (1.0× beside
|
|
699
|
+
* three others, 2.0× alone), reserving the solo reading packs a four-core
|
|
700
|
+
* box two wide, and that idles cores through every single-threaded phase —
|
|
701
|
+
* 160 s against 132 s on 92 real builds (2026-09-15). `reservations` may
|
|
702
|
+
* still declare `cpus`. Tasks with no execution in the window are absent.
|
|
703
|
+
*/
|
|
704
|
+
export function resourceEstimates(
|
|
705
|
+
nodes: ReadonlyMap<string, TaskNode>,
|
|
706
|
+
history: HistoryTable,
|
|
707
|
+
headroom = DEFAULT_HEADROOM,
|
|
708
|
+
): ReadonlyMap<string, ResourceEstimate> {
|
|
709
|
+
return estimatesFor(nodes.keys(), history, headroom)
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
function estimatesFor(
|
|
713
|
+
ids: Iterable<string>,
|
|
714
|
+
history: ReadonlyMap<string, TaskResources>,
|
|
715
|
+
headroom: number,
|
|
716
|
+
): ReadonlyMap<string, ResourceEstimate> {
|
|
717
|
+
const out = new Map<string, ResourceEstimate>()
|
|
718
|
+
for (const id of ids) {
|
|
719
|
+
const rss = history.get(id)?.maxPeakRssBytes
|
|
720
|
+
if (rss === undefined) continue
|
|
721
|
+
const mb = (rss * headroom) / MB
|
|
722
|
+
if (mb >= MEMORY_STEP_MB)
|
|
723
|
+
out.set(id, { memory: Math.ceil(mb / MEMORY_STEP_MB) * MEMORY_STEP_MB })
|
|
724
|
+
}
|
|
725
|
+
return out
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
/**
|
|
729
|
+
* A declared axis wins over the learned one, and an axis the declaration
|
|
730
|
+
* leaves out keeps it: cores are only ever declared, and declaring them
|
|
731
|
+
* dropped the task's learned memory. A task with neither reserves nothing.
|
|
732
|
+
*/
|
|
733
|
+
export function withDeclared(
|
|
734
|
+
learned: ReadonlyMap<string, ResourceEstimate>,
|
|
735
|
+
declared: Readonly<Record<string, ResourceEstimate>> | undefined,
|
|
736
|
+
): ReadonlyMap<string, ResourceEstimate> {
|
|
737
|
+
if (declared === undefined) return learned
|
|
738
|
+
const out = new Map(learned)
|
|
739
|
+
for (const [id, r] of Object.entries(declared)) {
|
|
740
|
+
const merged: { cpus?: number; memory?: number } = { ...learned.get(id) }
|
|
741
|
+
if (r.cpus !== undefined) merged.cpus = r.cpus
|
|
742
|
+
if (r.memory !== undefined) merged.memory = r.memory
|
|
743
|
+
if (merged.cpus !== undefined || merged.memory !== undefined) out.set(id, merged)
|
|
744
|
+
}
|
|
745
|
+
return out
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
export interface Budgets {
|
|
749
|
+
/** Cores: the run's worker count, which the count gate already applies. */
|
|
750
|
+
readonly cpus: number
|
|
751
|
+
/** Megabytes. */
|
|
752
|
+
readonly memory: number
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
/**
|
|
756
|
+
* The packing rule, per axis: a task reserving nothing is admitted; one
|
|
757
|
+
* within the budget needs the headroom left by what runs; one over the
|
|
758
|
+
* whole budget can never have it, so it runs alone — admitted only when
|
|
759
|
+
* nothing else runs, which an idle machine always reaches, so it never
|
|
760
|
+
* starves. Whole megabytes and cores, so sums are exact.
|
|
761
|
+
*/
|
|
762
|
+
export function admits(
|
|
763
|
+
id: string,
|
|
764
|
+
running: readonly string[],
|
|
765
|
+
reservations: ReadonlyMap<string, ResourceEstimate>,
|
|
766
|
+
budgets: Budgets,
|
|
767
|
+
): boolean {
|
|
768
|
+
const mine = reservations.get(id)
|
|
769
|
+
if (mine === undefined) return true
|
|
770
|
+
let cpus = 0
|
|
771
|
+
let memory = 0
|
|
772
|
+
for (const r of running) {
|
|
773
|
+
const theirs = reservations.get(r)
|
|
774
|
+
if (theirs === undefined) continue
|
|
775
|
+
cpus += theirs.cpus ?? 0
|
|
776
|
+
memory += theirs.memory ?? 0
|
|
777
|
+
}
|
|
778
|
+
return (
|
|
779
|
+
fitsAxis(mine.cpus ?? 0, cpus, running.length, budgets.cpus) &&
|
|
780
|
+
fitsAxis(mine.memory ?? 0, memory, running.length, budgets.memory)
|
|
781
|
+
)
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
function fitsAxis(cost: number, reserved: number, holders: number, budget: number): boolean {
|
|
785
|
+
if (cost === 0) return true
|
|
786
|
+
return cost <= budget ? reserved + cost <= budget : holders === 0
|
|
787
|
+
}
|
|
788
|
+
// `vx history` — the plugin's own surface for what it learned. The
|
|
789
|
+
// reservations are decided at dispatch and shown nowhere by core (core
|
|
790
|
+
// holds no notion of them), so without this a developer could not tell
|
|
791
|
+
// what the plugin will pack, or why two tasks stopped overlapping.
|
|
792
|
+
async function historyCmd(
|
|
793
|
+
argv: readonly string[],
|
|
794
|
+
ctx: CommandContext,
|
|
795
|
+
options: VxHistoryOptions,
|
|
796
|
+
): Promise<number> {
|
|
797
|
+
let format: 'pretty' | 'json' = 'pretty'
|
|
798
|
+
for (let i = 0; i < argv.length; i++) {
|
|
799
|
+
const a = argv[i]!
|
|
800
|
+
// A plugin verb owns its help; completions offer `--help` for every verb.
|
|
801
|
+
if (a === '--help' || a === '-h') {
|
|
802
|
+
ui.say('info', 'Usage: vx history [--format pretty|json]', 'out')
|
|
803
|
+
process.stdout.write('\n')
|
|
804
|
+
ui.say(
|
|
805
|
+
'info',
|
|
806
|
+
'What vx-history learned per task (p50, peak RSS, CPU parallelism) and the reservation it packs.',
|
|
807
|
+
'out',
|
|
808
|
+
)
|
|
809
|
+
return 0
|
|
810
|
+
}
|
|
811
|
+
// Worded as core's verbs word theirs, help pointer included.
|
|
812
|
+
const see = ' (see `vx history --help`)'
|
|
813
|
+
if (a === '--format' || a.startsWith('--format=')) {
|
|
814
|
+
const v = a === '--format' ? argv[++i] : a.slice(9)
|
|
815
|
+
if (v === undefined || v === '')
|
|
816
|
+
throw new UserError(
|
|
817
|
+
`vx history: --format requires a value: pretty or json${see}`,
|
|
818
|
+
'VX_E_USAGE',
|
|
819
|
+
)
|
|
820
|
+
if (v !== 'pretty' && v !== 'json')
|
|
821
|
+
throw new UserError(
|
|
822
|
+
`vx history: --format must be pretty or json (got ${v})${see}`,
|
|
823
|
+
'VX_E_USAGE',
|
|
824
|
+
)
|
|
825
|
+
format = v
|
|
826
|
+
continue
|
|
827
|
+
}
|
|
828
|
+
throw new UserError(
|
|
829
|
+
`vx history: ${a.startsWith('-') ? 'unknown flag' : 'unexpected argument'}: ${a}${see}`,
|
|
830
|
+
'VX_E_USAGE',
|
|
831
|
+
)
|
|
832
|
+
}
|
|
833
|
+
// The tasks a run would see, plugin stages included — the same ids the
|
|
834
|
+
// `schedule` hook is handed.
|
|
835
|
+
const projects = await loadResolvedProjects(ctx.workspaceRoot, { scope: 'all', warn: ctx.warn })
|
|
836
|
+
const ids: string[] = []
|
|
837
|
+
const tasks: Pick<TaskNode, 'id' | 'config'>[] = []
|
|
838
|
+
for (const p of projects.values()) {
|
|
839
|
+
for (const [t, config] of Object.entries(p.config.tasks ?? {})) {
|
|
840
|
+
ids.push(`${p.name}#${t}`)
|
|
841
|
+
tasks.push({ id: `${p.name}#${t}`, config })
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
numberWarnings(options, ctx.warn)
|
|
845
|
+
const window = windowOf(options)
|
|
846
|
+
const cache = new Cache(ctx.cacheDir)
|
|
847
|
+
let table: HistoryTable
|
|
848
|
+
try {
|
|
849
|
+
// Each task's rows of its definition as it is now, as `schedule` reads
|
|
850
|
+
// them: read with every definition's, a task whose command changed
|
|
851
|
+
// showed the old command's peak and a reservation no run packs.
|
|
852
|
+
table = await new LocalHistoryProvider(
|
|
853
|
+
cache.dbHandle(),
|
|
854
|
+
window,
|
|
855
|
+
currentBranch(ctx.workspaceRoot),
|
|
856
|
+
// As a run reads them: a task whose command changed has no rows yet.
|
|
857
|
+
taskDefinitions(tasks as TaskNode[]),
|
|
858
|
+
).loadFor(ids)
|
|
859
|
+
} finally {
|
|
860
|
+
cache.close()
|
|
861
|
+
}
|
|
862
|
+
const reservations = reservationsFor(ids, table, options)
|
|
863
|
+
const budgets: Budgets = {
|
|
864
|
+
cpus: ctx.concurrency,
|
|
865
|
+
memory: memoryBudgetMb(options),
|
|
866
|
+
}
|
|
867
|
+
const rows: HistoryRow[] = ids.map((id) => {
|
|
868
|
+
const h = table.get(id)
|
|
869
|
+
return {
|
|
870
|
+
id,
|
|
871
|
+
runs: h?.runs ?? 0,
|
|
872
|
+
p50DurationMs: h?.p50DurationMs ?? null,
|
|
873
|
+
maxPeakRssBytes: h?.maxPeakRssBytes ?? null,
|
|
874
|
+
maxCpuParallelism: h?.maxCpuParallelism ?? null,
|
|
875
|
+
reservation: reservations.get(id) ?? null,
|
|
876
|
+
declared: options.reservations?.[id] !== undefined,
|
|
877
|
+
}
|
|
878
|
+
})
|
|
879
|
+
if (format === 'json') {
|
|
880
|
+
process.stdout.write(`${JSON.stringify({ window, budgets, tasks: rows })}\n`)
|
|
881
|
+
return 0
|
|
882
|
+
}
|
|
883
|
+
// The table's module loads with the verb, off every run's path.
|
|
884
|
+
const { historyBlock } = await import('./history-view.js')
|
|
885
|
+
ui.print('out', historyBlock(rows, window, budgets, usable(options.memory)))
|
|
886
|
+
return 0
|
|
887
|
+
}
|