@vzn/vx-dte 0.0.0 → 0.0.650
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 +923 -1
- package/index.ts +4 -0
- package/package.json +40 -4
- package/src/cpu-account.ts +134 -0
- package/src/dispatch.ts +268 -0
- package/src/docker.ts +156 -0
- package/src/forecast.ts +68 -0
- package/src/github.ts +523 -0
- package/src/gitlab.ts +130 -0
- package/src/index.ts +12 -0
- package/src/init.ts +522 -0
- package/src/local.ts +256 -0
- package/src/messages.ts +219 -0
- package/src/noise.ts +248 -0
- package/src/orchestrator.ts +2359 -0
- package/src/outputs.ts +117 -0
- package/src/planner.ts +988 -0
- package/src/plugin.ts +1018 -0
- package/src/printable.ts +11 -0
- package/src/seal.ts +509 -0
- package/src/strategy.ts +50 -0
- package/src/trace.ts +288 -0
- package/src/tree.ts +78 -0
- package/src/usage.ts +135 -0
- package/src/warm-ups.ts +85 -0
- package/src/wire.ts +1060 -0
- package/src/worker.ts +1038 -0
package/src/plugin.ts
ADDED
|
@@ -0,0 +1,1018 @@
|
|
|
1
|
+
import path from 'node:path'
|
|
2
|
+
import {
|
|
3
|
+
definePlugin,
|
|
4
|
+
hostPlatform,
|
|
5
|
+
localHistory,
|
|
6
|
+
PLATFORMS,
|
|
7
|
+
refuseUnknownOptions,
|
|
8
|
+
taskDefinitions,
|
|
9
|
+
ui,
|
|
10
|
+
UserError,
|
|
11
|
+
type CommandContext,
|
|
12
|
+
type Platform,
|
|
13
|
+
type PluginCache,
|
|
14
|
+
type PluginOptionKinds,
|
|
15
|
+
type TaskNode,
|
|
16
|
+
type TaskResources,
|
|
17
|
+
} from '@vzn/vx'
|
|
18
|
+
import type { DteInit, DteSetup } from './init.js'
|
|
19
|
+
import type { Need } from './dispatch.js'
|
|
20
|
+
import { booked, DEFAULT_STRATEGY, STRATEGIES, type Strategy } from './strategy.js'
|
|
21
|
+
import type { Orchestrator } from './orchestrator.js'
|
|
22
|
+
import type { Proof } from './seal.js'
|
|
23
|
+
import type { Size } from './planner.js'
|
|
24
|
+
|
|
25
|
+
/** One kind of worker the adapter can start. */
|
|
26
|
+
export interface WorkerSize {
|
|
27
|
+
/** How many of it one run may start; absent, as many as the work needs. */
|
|
28
|
+
readonly max?: number
|
|
29
|
+
/** Its cores: what starting one adds, until it reports what its machine has. */
|
|
30
|
+
readonly cpu: number
|
|
31
|
+
/** Its memory, in bytes or as `'8GB'`, `'512MiB'`: what starting one adds, until it reports what its machine has. */
|
|
32
|
+
readonly memory: number | string
|
|
33
|
+
/** Its machines' platform, which a joining worker must report; absent, planned as this machine's and not checked. A task naming `exec.platforms` runs only on a worker of one of them. */
|
|
34
|
+
readonly platform?: Platform
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** What an adapter is asked to start: one worker of `size`. */
|
|
38
|
+
export interface WorkerSpawn {
|
|
39
|
+
readonly run: string
|
|
40
|
+
readonly relay: string
|
|
41
|
+
readonly size: string
|
|
42
|
+
/** Its size's `max`: `Infinity` when uncapped. */
|
|
43
|
+
readonly max: number
|
|
44
|
+
/** Its size's cores and memory (bytes), for an adapter that sets a machine's limits. */
|
|
45
|
+
readonly cpu: number
|
|
46
|
+
readonly memory: number
|
|
47
|
+
/** 1 for the first worker this run starts, counting every size: unique where `id` is per size. */
|
|
48
|
+
readonly index: number
|
|
49
|
+
/** Its name in the run's output and its own (`small#2`, numbered within its size), which it reports back on joining. */
|
|
50
|
+
readonly id: string
|
|
51
|
+
readonly workspaceRoot: string
|
|
52
|
+
/** The commit of the tracked files as the run saw them at its start, whose tree a worker must check out. */
|
|
53
|
+
readonly commit: string
|
|
54
|
+
/**
|
|
55
|
+
* The run's cache directory, absolute, when the workspace names one
|
|
56
|
+
* (`cache.dir`, `VX_CACHE_DIR`, `--cache-dir`): an adapter starting workers on this
|
|
57
|
+
* machine points them at it, where the run reads what they save.
|
|
58
|
+
*/
|
|
59
|
+
readonly cacheDir?: string
|
|
60
|
+
/** What the worker's `vx dte worker` reads: `VX_DTE_RUN`, `VX_DTE_WORKER` (`id`), `VX_DTE_RELAY`, `VX_DTE_SECRET` (the run’s). */
|
|
61
|
+
readonly env: Readonly<Record<string, string>>
|
|
62
|
+
/** Says the worker's job or process ended, a success too, and why (its exit, its last stderr line). */
|
|
63
|
+
readonly exited: (reason: string) => void
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface DteAdapter {
|
|
67
|
+
/** A relay of its own, for a run whose `dte()` names none. */
|
|
68
|
+
relay?(): Promise<string>
|
|
69
|
+
spawn(worker: WorkerSpawn): void | Promise<void>
|
|
70
|
+
/** At run end: stop what it started. */
|
|
71
|
+
close?(): void | Promise<void>
|
|
72
|
+
/**
|
|
73
|
+
* Workers `ids` it started are not needed: stop each that has not joined,
|
|
74
|
+
* if it can. Answers with those it left running (`github()`: its job had a
|
|
75
|
+
* runner already), which the run takes when they join. `late`: given up
|
|
76
|
+
* on joining, stuck or queued, so stopped whatever its job holds.
|
|
77
|
+
*/
|
|
78
|
+
cancel?(ids: readonly string[], late?: boolean): void | Promise<readonly string[]>
|
|
79
|
+
/** The CI files that start its workers, which `vx dte init` writes; none for an adapter that starts them on this machine. */
|
|
80
|
+
init?(setup: DteSetup): Promise<DteInit>
|
|
81
|
+
/** Where it says a fault it works around (a status poll or a cancel that failed); called as the run starts. */
|
|
82
|
+
report?(warn: (message: string) => void): void
|
|
83
|
+
/** Its workers prove themselves by their CI identity (GitHub OIDC): the run makes no secret for them. */
|
|
84
|
+
readonly keyless?: boolean
|
|
85
|
+
/**
|
|
86
|
+
* A keyless adapter's: the CI runs (GitHub workflow run ids) whose jobs
|
|
87
|
+
* alone may join as its workers; `undefined` admits any job of the
|
|
88
|
+
* repository.
|
|
89
|
+
*/
|
|
90
|
+
workerRuns?(): ReadonlySet<string> | undefined
|
|
91
|
+
/** A worker's name in a job its CI started for several, when `VX_DTE_WORKER` is unset. */
|
|
92
|
+
workerId?(): string | undefined
|
|
93
|
+
/** The run's id when `VX_DTE_RUN` is unset, in a CI job it starts workers from; none elsewhere. */
|
|
94
|
+
runId?(): string | undefined
|
|
95
|
+
/**
|
|
96
|
+
* Workers the CI started for this run before it began (`size#n`): each
|
|
97
|
+
* is handed to `worker`, which counts it against its size's `max` and
|
|
98
|
+
* returns its spawn, for the adapter to watch and stop as its own.
|
|
99
|
+
*/
|
|
100
|
+
adopt?(worker: (id: string, size: string) => WorkerSpawn): void
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export interface DteOptions {
|
|
104
|
+
/** What the adapter may start, by size name. */
|
|
105
|
+
readonly workers?: Readonly<Record<string, WorkerSize>>
|
|
106
|
+
/** Starts workers; absent, the run uses the workers that join it, which needs `relay`. */
|
|
107
|
+
readonly adapter?: DteAdapter
|
|
108
|
+
/** The relay's URL; absent, the adapter's own. */
|
|
109
|
+
readonly relay?: string
|
|
110
|
+
/** The bearer token for the relay (default `VX_DTE_TOKEN`, else on GitHub Actions an OIDC token for the relay's URL). */
|
|
111
|
+
readonly token?: () => string | undefined | Promise<string | undefined>
|
|
112
|
+
/** How the run orders, places, books and scales (`Strategy`); each layer absent is the default's. */
|
|
113
|
+
readonly scheduler?: Partial<Strategy>
|
|
114
|
+
/**
|
|
115
|
+
* What a task with no measure counts for when the run decides how many
|
|
116
|
+
* workers to start (default 2 cores, 4GB). It books next to nothing, so
|
|
117
|
+
* it runs on any free core.
|
|
118
|
+
*/
|
|
119
|
+
readonly unmeasured?: { readonly cpu: number; readonly memory: number | string }
|
|
120
|
+
/**
|
|
121
|
+
* Headers every dial to the relay carries, the orchestrator's and each
|
|
122
|
+
* worker's, for a proxy in front of it (`{ 'CF-Access-Client-Id':
|
|
123
|
+
* process.env.CF_ACCESS_CLIENT_ID }`, an Access service token,
|
|
124
|
+
* 2026-10-25). An unset value is left out; `authorization` stays the
|
|
125
|
+
* token's.
|
|
126
|
+
*/
|
|
127
|
+
readonly headers?: Readonly<Record<string, string | undefined>>
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const KEYS: PluginOptionKinds<DteOptions> = {
|
|
131
|
+
workers: 'object',
|
|
132
|
+
adapter: 'object',
|
|
133
|
+
relay: 'string',
|
|
134
|
+
token: 'function',
|
|
135
|
+
scheduler: 'object',
|
|
136
|
+
unmeasured: 'object',
|
|
137
|
+
headers: 'object',
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// A typo in a size or in `unmeasured` (`maxx`) was dropped, and the size ran uncapped.
|
|
141
|
+
const SIZE_KEYS: PluginOptionKinds<WorkerSize> = {
|
|
142
|
+
max: 'number',
|
|
143
|
+
cpu: 'number',
|
|
144
|
+
memory: 'any',
|
|
145
|
+
platform: 'string',
|
|
146
|
+
}
|
|
147
|
+
const UNMEASURED_KEYS: PluginOptionKinds<NonNullable<DteOptions['unmeasured']>> = {
|
|
148
|
+
cpu: 'number',
|
|
149
|
+
memory: 'any',
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const USAGE = 'Usage: vx dte worker | vx dte init [--dry] [--force]'
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* The names a dial sets itself (wire.ts): one of them in `headers`, in
|
|
156
|
+
* another case, was joined to the dial's own value.
|
|
157
|
+
*/
|
|
158
|
+
const WIRE_HEADERS = new Set(['authorization', 'content-type', 'relay-proof'])
|
|
159
|
+
|
|
160
|
+
/** `headers` with unset values left out; a name or value no header can carry, or `authorization`, refused. */
|
|
161
|
+
function proxyHeaders(headers: DteOptions['headers']): Record<string, string> {
|
|
162
|
+
const out: Record<string, string> = {}
|
|
163
|
+
for (const [name, value] of Object.entries(headers ?? {})) {
|
|
164
|
+
if (value === undefined) continue
|
|
165
|
+
if (!/^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/.test(name) || WIRE_HEADERS.has(name.toLowerCase()))
|
|
166
|
+
throw new UserError(`dte() option "headers" cannot set "${name}"`)
|
|
167
|
+
if (typeof value !== 'string' || /[\r\n\0]/.test(value))
|
|
168
|
+
throw new UserError(
|
|
169
|
+
`dte() option "headers.${name}" must be one line of text (its value is not printed)`,
|
|
170
|
+
)
|
|
171
|
+
out[name] = value
|
|
172
|
+
}
|
|
173
|
+
return out
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export function dte(options: DteOptions = {}) {
|
|
177
|
+
refuseUnknownOptions('dte()', options, KEYS)
|
|
178
|
+
const headers = proxyHeaders(options.headers)
|
|
179
|
+
const how: Strategy = { ...DEFAULT_STRATEGY }
|
|
180
|
+
for (const [layer, choice] of Object.entries(options.scheduler ?? {})) {
|
|
181
|
+
const choices = STRATEGIES[layer as keyof Strategy] as readonly string[] | undefined
|
|
182
|
+
if (choices === undefined)
|
|
183
|
+
throw new UserError(
|
|
184
|
+
`dte() option "scheduler" has no layer ${JSON.stringify(layer)} (layers: ${Object.keys(STRATEGIES).join(', ')})`,
|
|
185
|
+
)
|
|
186
|
+
if (!choices.includes(choice as string))
|
|
187
|
+
refuse(`scheduler.${layer}`, `one of ${choices.join(', ')}`, choice)
|
|
188
|
+
;(how as unknown as Record<string, unknown>)[layer] = choice
|
|
189
|
+
}
|
|
190
|
+
const sizes = Object.entries(options.workers ?? {}).map(([name, s]) => {
|
|
191
|
+
const at = `workers.${name}`
|
|
192
|
+
refuseUnknownOptions(`dte() option "${at}"`, s, SIZE_KEYS)
|
|
193
|
+
if (!(Number.isInteger(s?.cpu) && s.cpu >= 1)) refuse(`${at}.cpu`, 'a whole number ≥ 1', s?.cpu)
|
|
194
|
+
const memory = bytes(s.memory)
|
|
195
|
+
if (memory === undefined)
|
|
196
|
+
refuse(`${at}.memory`, "bytes above 0 or a size such as '8GB'", s.memory)
|
|
197
|
+
if (!(s.max === undefined || (Number.isInteger(s.max) && s.max >= 0)))
|
|
198
|
+
refuse(`${at}.max`, 'a whole number ≥ 0', s.max)
|
|
199
|
+
if (s.platform !== undefined && !PLATFORMS.includes(s.platform))
|
|
200
|
+
refuse(`${at}.platform`, `one of ${PLATFORMS.join(', ')}`, s.platform)
|
|
201
|
+
return {
|
|
202
|
+
name,
|
|
203
|
+
max: s.max ?? Infinity,
|
|
204
|
+
cpu: s.cpu,
|
|
205
|
+
memory: memory!,
|
|
206
|
+
platform: s.platform ?? hostPlatform(),
|
|
207
|
+
}
|
|
208
|
+
})
|
|
209
|
+
// Only a platform the size names refuses a worker reporting another: an
|
|
210
|
+
// unnamed one is planned as this machine's, and takes whatever joins.
|
|
211
|
+
const named = (size: string): { platform?: Platform } => {
|
|
212
|
+
const platform = options.workers?.[size]?.platform
|
|
213
|
+
return platform === undefined ? {} : { platform }
|
|
214
|
+
}
|
|
215
|
+
const home = homeOf(sizes, hostPlatform())
|
|
216
|
+
const unmeasured = unmeasuredOf(options.unmeasured)
|
|
217
|
+
const adapter = options.adapter
|
|
218
|
+
// An adapter's run in its own CI with a relay names itself: the CI file
|
|
219
|
+
// sets nothing (2026-10-23: "super simple" CI).
|
|
220
|
+
let made: string | undefined
|
|
221
|
+
/** Each task's sends under the run's id, across the runs `vx watch` makes. */
|
|
222
|
+
const sent = new Map<string, number>()
|
|
223
|
+
const runOf = (): string | undefined =>
|
|
224
|
+
givenRun() ??
|
|
225
|
+
(options.relay === undefined
|
|
226
|
+
? undefined
|
|
227
|
+
: (made ??=
|
|
228
|
+
adapter?.keyless === true && onGitHub() ? crypto.randomUUID() : adapter?.runId?.()))
|
|
229
|
+
if (adapter !== undefined && typeof adapter.spawn !== 'function') {
|
|
230
|
+
throw new UserError('dte(): adapter is an adapter, such as local()')
|
|
231
|
+
}
|
|
232
|
+
// With none, no worker was ever asked for and the run waited until killed.
|
|
233
|
+
if (adapter !== undefined && !sizes.some((s) => s.max > 0)) {
|
|
234
|
+
throw new UserError(
|
|
235
|
+
"dte(): workers names no size the adapter may start (one with max above 0), such as small: { cpu: 2, memory: '8GB' }",
|
|
236
|
+
)
|
|
237
|
+
}
|
|
238
|
+
if (adapter === undefined && options.relay === undefined) {
|
|
239
|
+
throw new UserError('dte(): with no adapter, set relay: the workers that join it run the tasks')
|
|
240
|
+
}
|
|
241
|
+
// With no scheme the run failed in the executor as a bare "Invalid URL".
|
|
242
|
+
if (options.relay !== undefined && !/^https?:\/\//.test(options.relay)) {
|
|
243
|
+
throw new UserError(
|
|
244
|
+
`dte() option "relay" must be an http(s) URL, got ${JSON.stringify(options.relay)}`,
|
|
245
|
+
)
|
|
246
|
+
}
|
|
247
|
+
// On GitHub Actions with none given, one for the relay's URL.
|
|
248
|
+
const token = async (relay: string): Promise<string | undefined> =>
|
|
249
|
+
(await options.token?.()) ??
|
|
250
|
+
(process.env['VX_DTE_TOKEN'] ||
|
|
251
|
+
(await import('./github.js').then((g) => g.githubRelayToken(relay))))
|
|
252
|
+
|
|
253
|
+
let nodes: ReadonlyMap<string, TaskNode> | undefined
|
|
254
|
+
// Core prints the footer after teardown, and its ideal needs the
|
|
255
|
+
// dependencies: the last run's graph, kept until the next `schedule`.
|
|
256
|
+
let ran: ReadonlyMap<string, TaskNode> | undefined
|
|
257
|
+
/** The workspace names its cache dir (`cache.dir`): `config` reads it. */
|
|
258
|
+
let namesCache = false
|
|
259
|
+
let peaks: ReadonlyMap<string, TaskResources> = new Map()
|
|
260
|
+
/** Each size's warm-up an earlier run learned (`warm-ups.ts`), and the read of it. */
|
|
261
|
+
let learned: ReadonlyMap<string, number> = new Map()
|
|
262
|
+
let warming: Promise<void> = Promise.resolve()
|
|
263
|
+
/** On CI, where the run keeps the warm-ups it learned: `setup`'s cache, root and warn. */
|
|
264
|
+
let keep: { cache: PluginCache; root: string; warn: (m: string) => void } | undefined
|
|
265
|
+
/** Each task's tail by `nodes` and `peaks`, made again when either changes. */
|
|
266
|
+
let tails: ((id: string) => number) | undefined
|
|
267
|
+
/** Settles once the needs other plugins fetch are in `peaks`: `ctx.refining()`, then the read. */
|
|
268
|
+
let needsIn: (() => Promise<void>) | undefined
|
|
269
|
+
/** Called when `schedule` has read history; set by the executor, which core makes first. */
|
|
270
|
+
let scheduled: (() => void) | undefined
|
|
271
|
+
/** The cached tasks the last commit's changes make miss (`forecast.ts`): work before any worker can say. */
|
|
272
|
+
let likely: ReadonlySet<string> = new Set()
|
|
273
|
+
// Each finished task's cache key, a hit's too: what a worker restores a
|
|
274
|
+
// dependency by.
|
|
275
|
+
const keys = new Map<string, string>()
|
|
276
|
+
let orchestrator: Orchestrator | undefined
|
|
277
|
+
let closing: Promise<void> | undefined
|
|
278
|
+
|
|
279
|
+
const close = async (): Promise<void> => {
|
|
280
|
+
const o = orchestrator
|
|
281
|
+
orchestrator = undefined
|
|
282
|
+
ran = nodes
|
|
283
|
+
nodes = undefined
|
|
284
|
+
const ups = o?.warmUpsMs()
|
|
285
|
+
const k = keep
|
|
286
|
+
keep = undefined
|
|
287
|
+
if (k !== undefined && ups !== undefined) {
|
|
288
|
+
const { writeWarmUps } = await import('./warm-ups.js')
|
|
289
|
+
await writeWarmUps(k.cache, k.root, learned, ups).catch((err: unknown) =>
|
|
290
|
+
k.warn(`[vx] dte: warm-ups not kept: ${err instanceof Error ? err.message : String(err)}`),
|
|
291
|
+
)
|
|
292
|
+
}
|
|
293
|
+
learned = new Map()
|
|
294
|
+
warming = Promise.resolve()
|
|
295
|
+
peaks = new Map()
|
|
296
|
+
tails = undefined
|
|
297
|
+
needsIn = undefined
|
|
298
|
+
scheduled = undefined
|
|
299
|
+
likely = new Set()
|
|
300
|
+
keys.clear()
|
|
301
|
+
await o?.close()
|
|
302
|
+
await adapter?.close?.()
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** `vx dte init [--dry] [--force]`: the run's CI and its workers', from these sizes. */
|
|
306
|
+
const init = async (args: readonly string[], ctx: CommandContext): Promise<number> => {
|
|
307
|
+
const extra = args.find((a) => a !== '--dry' && a !== '--force')
|
|
308
|
+
if (extra !== undefined)
|
|
309
|
+
throw new UserError(
|
|
310
|
+
`vx dte init: ${extra.startsWith('-') ? 'unknown flag' : 'unexpected argument'}: ${extra} (see \`vx dte --help\`)`,
|
|
311
|
+
'VX_E_USAGE',
|
|
312
|
+
)
|
|
313
|
+
const root = ctx.workspaceRoot
|
|
314
|
+
if (adapter === undefined)
|
|
315
|
+
throw new UserError(
|
|
316
|
+
'vx dte init: dte() names no adapter, so vx starts no workers and needs no CI files',
|
|
317
|
+
)
|
|
318
|
+
if (adapter.init === undefined) {
|
|
319
|
+
ui.say(
|
|
320
|
+
'info',
|
|
321
|
+
'vx dte init: this adapter starts workers on this machine; no CI files to write',
|
|
322
|
+
'out',
|
|
323
|
+
)
|
|
324
|
+
return 0
|
|
325
|
+
}
|
|
326
|
+
const { askTasks, managerOf, originOf, writeInit } = await import('./init.js')
|
|
327
|
+
// CI reads its files at the repository's root; a workspace in a
|
|
328
|
+
// subdirectory got them in its own, which no CI reads (2026-10-25).
|
|
329
|
+
const git = (arg: string): string =>
|
|
330
|
+
Bun.spawnSync(['git', 'rev-parse', arg], { cwd: root, stderr: 'ignore' })
|
|
331
|
+
.stdout.toString()
|
|
332
|
+
.trim()
|
|
333
|
+
const top = git('--show-toplevel') || root
|
|
334
|
+
const files = await adapter.init({
|
|
335
|
+
workspaceRoot: root,
|
|
336
|
+
dir: git('--show-prefix').replace(/\/$/, ''),
|
|
337
|
+
...(options.relay !== undefined ? { relay: options.relay } : {}),
|
|
338
|
+
sizes: sizes.map((s) => ({
|
|
339
|
+
name: s.name,
|
|
340
|
+
...(s.max === Infinity ? {} : { max: s.max }),
|
|
341
|
+
...named(s.name),
|
|
342
|
+
})),
|
|
343
|
+
manager: managerOf(root),
|
|
344
|
+
tasks: askTasks(),
|
|
345
|
+
...originOf(root),
|
|
346
|
+
})
|
|
347
|
+
const out = (line: string): void => ui.say('info', line, 'out')
|
|
348
|
+
if (args.includes('--dry')) {
|
|
349
|
+
for (const [rel, text] of Object.entries(files.files)) ui.print('out', ui.excerpt(rel, text))
|
|
350
|
+
for (const line of files.next) out(line)
|
|
351
|
+
return 0
|
|
352
|
+
}
|
|
353
|
+
return writeInit(top, files, args.includes('--force'), out)
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
return definePlugin(import.meta, {
|
|
357
|
+
commands: {
|
|
358
|
+
dte: {
|
|
359
|
+
description: "Write a DTE run's CI (dte init) or run its tasks (dte worker).",
|
|
360
|
+
async run(argv, ctx) {
|
|
361
|
+
// Asked for, the usage is an answer: `vx dte --help` exited 1 (2026-10-26).
|
|
362
|
+
if (argv.some((a) => a === '--help' || a === '-h') || argv[0] === 'help') {
|
|
363
|
+
ui.say('info', USAGE, 'out')
|
|
364
|
+
return 0
|
|
365
|
+
}
|
|
366
|
+
if (argv[0] === 'init') return init(argv.slice(1), ctx)
|
|
367
|
+
// The bare usage said nothing of what was wrong with the line typed.
|
|
368
|
+
if (argv[0] === undefined)
|
|
369
|
+
throw new UserError(`missing subcommand\n${USAGE}`, 'VX_E_USAGE')
|
|
370
|
+
if (argv[0] !== 'worker')
|
|
371
|
+
throw new UserError(`unknown subcommand: ${argv[0]}\n${USAGE}`, 'VX_E_USAGE')
|
|
372
|
+
if (argv.length > 1)
|
|
373
|
+
throw new UserError(
|
|
374
|
+
`vx dte worker: ${argv[1]!.startsWith('-') ? 'unknown flag' : 'unexpected argument'}: ${argv[1]} (see \`vx dte --help\`)`,
|
|
375
|
+
'VX_E_USAGE',
|
|
376
|
+
)
|
|
377
|
+
const run = runOf()
|
|
378
|
+
const relay = process.env['VX_DTE_RELAY'] || options.relay
|
|
379
|
+
// Only what is missing: with VX_DTE_RUN set, the old line still asked for it.
|
|
380
|
+
if (run === undefined || relay === undefined) {
|
|
381
|
+
const missing = [
|
|
382
|
+
...(run === undefined ? ['VX_DTE_RUN'] : []),
|
|
383
|
+
...(relay === undefined ? ['VX_DTE_RELAY or dte({ relay })'] : []),
|
|
384
|
+
]
|
|
385
|
+
throw new UserError(`vx dte worker: needs ${missing.join(', and ')}`)
|
|
386
|
+
}
|
|
387
|
+
if (!/^https?:\/\//.test(relay))
|
|
388
|
+
throw new UserError(`vx dte worker: VX_DTE_RELAY must be http(s), got ${relay}`)
|
|
389
|
+
const secret = givenSecret()
|
|
390
|
+
if (secret === undefined && !onGitHub()) {
|
|
391
|
+
throw new UserError(
|
|
392
|
+
'vx dte worker: needs VX_DTE_SECRET, the run’s, or a GitHub Actions job with id-token: write',
|
|
393
|
+
)
|
|
394
|
+
}
|
|
395
|
+
const [{ runWorker }, seal] = await Promise.all([
|
|
396
|
+
import('./worker.js'),
|
|
397
|
+
import('./seal.js'),
|
|
398
|
+
])
|
|
399
|
+
const id = process.env['VX_DTE_WORKER'] || adapter?.workerId?.()
|
|
400
|
+
return runWorker({
|
|
401
|
+
relay,
|
|
402
|
+
run,
|
|
403
|
+
...(id === undefined || id === '' ? {} : { id }),
|
|
404
|
+
root: ctx.workspaceRoot,
|
|
405
|
+
token: () => token(relay),
|
|
406
|
+
headers,
|
|
407
|
+
proof: secret === undefined ? seal.githubProof() : seal.secretProof(secret),
|
|
408
|
+
artifacts: ctx.artifactsFor,
|
|
409
|
+
})
|
|
410
|
+
},
|
|
411
|
+
},
|
|
412
|
+
},
|
|
413
|
+
|
|
414
|
+
config(workspace) {
|
|
415
|
+
namesCache = workspace.cache?.dir !== undefined
|
|
416
|
+
},
|
|
417
|
+
|
|
418
|
+
setup(ctx) {
|
|
419
|
+
// A run that is not distributed subscribes nothing.
|
|
420
|
+
if (runOf() === undefined) return
|
|
421
|
+
if (ctx.ci) keep = { cache: ctx.cache, root: ctx.workspaceRoot, warn: (m) => ctx.warn(m) }
|
|
422
|
+
ctx.on('onTaskComplete', (node, outcome) => {
|
|
423
|
+
if (outcome.hash !== undefined) keys.set(node.id, outcome.hash)
|
|
424
|
+
})
|
|
425
|
+
},
|
|
426
|
+
|
|
427
|
+
async schedule(graph, ctx) {
|
|
428
|
+
nodes = graph
|
|
429
|
+
ran = undefined
|
|
430
|
+
tails = undefined
|
|
431
|
+
// A run that is not distributed reads nothing.
|
|
432
|
+
if (orchestrator === undefined) return undefined
|
|
433
|
+
const ids = [...graph.values()]
|
|
434
|
+
.filter((n) => n.config.exec?.command !== undefined)
|
|
435
|
+
.map((n) => n.id)
|
|
436
|
+
const read = async (): Promise<void> => {
|
|
437
|
+
if (orchestrator === undefined) return
|
|
438
|
+
try {
|
|
439
|
+
peaks = await localHistory(ctx.localCache, {
|
|
440
|
+
branch: ctx.branch,
|
|
441
|
+
defs: taskDefinitions(graph.values()),
|
|
442
|
+
}).resourcesFor(ids)
|
|
443
|
+
tails = undefined
|
|
444
|
+
} catch (err) {
|
|
445
|
+
ctx.warn(
|
|
446
|
+
`[vx] dte: tasks are packed one core each: ${err instanceof Error ? err.message : String(err)}`,
|
|
447
|
+
)
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
let reading = read()
|
|
451
|
+
warming = import('./warm-ups.js')
|
|
452
|
+
.then((w) => w.readWarmUps(ctx.cache, ctx.workspaceRoot))
|
|
453
|
+
.then(
|
|
454
|
+
(m) => void (learned = m),
|
|
455
|
+
(err: unknown) =>
|
|
456
|
+
ctx.warn(
|
|
457
|
+
`[vx] dte: no warm-ups learned: ${err instanceof Error ? err.message : String(err)}`,
|
|
458
|
+
),
|
|
459
|
+
)
|
|
460
|
+
const guess = changedSinceParent(ctx.workspaceRoot).then(async (changed) => {
|
|
461
|
+
const { likelyMisses } = await import('./forecast.js')
|
|
462
|
+
likely = likelyMisses(graph, changed, ctx.workspaceRoot)
|
|
463
|
+
})
|
|
464
|
+
await Promise.all([reading, guess])
|
|
465
|
+
// Needs another plugin fetched (the default branch's timings) land
|
|
466
|
+
// in history after this: the tasks not yet placed pack by them, and
|
|
467
|
+
// the first ask for workers waits for them.
|
|
468
|
+
ctx.onRefine(() => void (reading = read()))
|
|
469
|
+
// Asked at the first ask, when every plugin's schedule has promised its
|
|
470
|
+
// own. Read again then: a refine that landed before `onRefine` above
|
|
471
|
+
// was listening left the first read's empty history, and main planned
|
|
472
|
+
// 9 asks on 0 measured tasks, 25% of its cores used (2026-10-23).
|
|
473
|
+
needsIn = () =>
|
|
474
|
+
Promise.all([ctx.refining().then(() => (reading = read())), warming]).then(() => {})
|
|
475
|
+
scheduled?.()
|
|
476
|
+
return undefined
|
|
477
|
+
},
|
|
478
|
+
|
|
479
|
+
async executor(ctx) {
|
|
480
|
+
await closing
|
|
481
|
+
const run = runOf()
|
|
482
|
+
if (run === undefined) return undefined
|
|
483
|
+
const relay = options.relay ?? (await adapter?.relay?.())
|
|
484
|
+
if (relay === undefined)
|
|
485
|
+
throw new UserError('dte(): no relay: set relay, or use an adapter that has one')
|
|
486
|
+
const [
|
|
487
|
+
{ Orchestrator },
|
|
488
|
+
{ join },
|
|
489
|
+
{ decide, knownMisses, slowdown, tailsMs, WARM_UP_MS },
|
|
490
|
+
{ snapshot },
|
|
491
|
+
seal,
|
|
492
|
+
] = await Promise.all([
|
|
493
|
+
import('./orchestrator.js'),
|
|
494
|
+
import('./wire.js'),
|
|
495
|
+
import('./planner.js'),
|
|
496
|
+
import('./tree.js'),
|
|
497
|
+
import('./seal.js'),
|
|
498
|
+
])
|
|
499
|
+
// VX_DTE_SECRET wins. On GitHub, workers that join on their own or
|
|
500
|
+
// an adapter's keyless ones prove themselves by OIDC; the workers
|
|
501
|
+
// another adapter starts get a secret made for this run.
|
|
502
|
+
let secret = givenSecret()
|
|
503
|
+
let proof: Proof | undefined
|
|
504
|
+
if (secret === undefined) {
|
|
505
|
+
if ((adapter === undefined || adapter.keyless === true) && onGitHub())
|
|
506
|
+
proof = seal.githubProof(
|
|
507
|
+
undefined,
|
|
508
|
+
adapter?.workerRuns === undefined ? undefined : () => adapter.workerRuns!(),
|
|
509
|
+
)
|
|
510
|
+
else if (adapter === undefined)
|
|
511
|
+
throw new UserError(
|
|
512
|
+
'dte(): with no adapter, set VX_DTE_SECRET here and on each worker, or run on GitHub Actions with id-token: write',
|
|
513
|
+
)
|
|
514
|
+
else secret = seal.runSecret()
|
|
515
|
+
}
|
|
516
|
+
proof ??= seal.secretProof(secret!)
|
|
517
|
+
const { commit, tree } = await snapshot(ctx.workspaceRoot)
|
|
518
|
+
if (tree === '') throw new UserError('dte(): a DTE run needs a git commit')
|
|
519
|
+
const need = (id: string): Need => booked(needOf(peaks.get(id), unmeasured), how.book)
|
|
520
|
+
// The run plans again on it, now when `now`; set once the plan is.
|
|
521
|
+
let onChange = (_now?: boolean): void => {}
|
|
522
|
+
const o = new Orchestrator({
|
|
523
|
+
me: seal.sealOrchestrator(
|
|
524
|
+
await join(relay, run, 'orchestrator', null, () => token(relay), headers),
|
|
525
|
+
run,
|
|
526
|
+
proof,
|
|
527
|
+
ctx.warn,
|
|
528
|
+
),
|
|
529
|
+
root: ctx.workspaceRoot,
|
|
530
|
+
tree,
|
|
531
|
+
nodes: () => nodes,
|
|
532
|
+
need,
|
|
533
|
+
artifacts: ctx.artifacts,
|
|
534
|
+
cacheFacts: () => ctx.cacheFacts(),
|
|
535
|
+
run,
|
|
536
|
+
sent,
|
|
537
|
+
keyOf: (id) => keys.get(id),
|
|
538
|
+
tail: (id) =>
|
|
539
|
+
(tails ??= tailsMs(nodes ?? new Map(), (x) => peaks.get(x)?.p50DurationMs))(id),
|
|
540
|
+
how,
|
|
541
|
+
home,
|
|
542
|
+
lost: (id) => {
|
|
543
|
+
const s = sizeOf.get(id)
|
|
544
|
+
if (s === undefined) return
|
|
545
|
+
sizeOf.delete(id)
|
|
546
|
+
started.set(s.name, started.get(s.name)! - 1)
|
|
547
|
+
// Before the run counts what is left to start: none would fail it.
|
|
548
|
+
onChange(true)
|
|
549
|
+
},
|
|
550
|
+
joined: () => onChange(),
|
|
551
|
+
// A worker given up on joining: its job, still queued or stuck, is stopped.
|
|
552
|
+
overdue: (id) => void adapter?.cancel?.([id], true),
|
|
553
|
+
// Before the run counts what is left to start: a worker that let a
|
|
554
|
+
// task's lease end failed main's macOS run with another to start
|
|
555
|
+
// (2026-10-23).
|
|
556
|
+
faulted: () => onChange(true),
|
|
557
|
+
stalled: (waiting) =>
|
|
558
|
+
ctx.warn(`[vx] dte(): no task began or ended for a minute; waiting on\n${waiting}`),
|
|
559
|
+
warn: ctx.warn,
|
|
560
|
+
})
|
|
561
|
+
orchestrator = o
|
|
562
|
+
const started = new Map<string, number>()
|
|
563
|
+
const sizeOf = new Map<string, Size>()
|
|
564
|
+
let index = 0
|
|
565
|
+
// Numbered within its size: one count across sizes left M asking
|
|
566
|
+
// where small#1 was (mac#1 had it, 2026-10-23).
|
|
567
|
+
const numbered = new Map<string, number>()
|
|
568
|
+
const nextId = (size: string): string => {
|
|
569
|
+
const n = (numbered.get(size) ?? 0) + 1
|
|
570
|
+
numbered.set(size, n)
|
|
571
|
+
return `${size}#${n}`
|
|
572
|
+
}
|
|
573
|
+
// A worker the run counts as started, against its size's max.
|
|
574
|
+
const spawnOf = (s: Size, id: string, n: number): WorkerSpawn => {
|
|
575
|
+
started.set(s.name, (started.get(s.name) ?? 0) + 1)
|
|
576
|
+
o.spawned(id, {
|
|
577
|
+
size: s.name,
|
|
578
|
+
cpu: s.cpu,
|
|
579
|
+
memory: s.memory,
|
|
580
|
+
...named(s.name),
|
|
581
|
+
})
|
|
582
|
+
sizeOf.set(id, s)
|
|
583
|
+
return {
|
|
584
|
+
run,
|
|
585
|
+
relay,
|
|
586
|
+
size: s.name,
|
|
587
|
+
max: s.max,
|
|
588
|
+
cpu: s.cpu,
|
|
589
|
+
memory: s.memory,
|
|
590
|
+
index: n,
|
|
591
|
+
id,
|
|
592
|
+
workspaceRoot: ctx.workspaceRoot,
|
|
593
|
+
commit,
|
|
594
|
+
...(namesCache ||
|
|
595
|
+
process.env['VX_CACHE_DIR'] ||
|
|
596
|
+
ctx.cacheDir !== path.join(ctx.workspaceRoot, '.vx', 'cache')
|
|
597
|
+
? { cacheDir: ctx.cacheDir }
|
|
598
|
+
: {}),
|
|
599
|
+
env: {
|
|
600
|
+
VX_DTE_RUN: run,
|
|
601
|
+
VX_DTE_WORKER: id,
|
|
602
|
+
VX_DTE_RELAY: relay,
|
|
603
|
+
...(secret === undefined ? {} : { VX_DTE_SECRET: secret }),
|
|
604
|
+
},
|
|
605
|
+
exited: (reason) => o.failed(id, reason),
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
adapter?.report?.(ctx.warn)
|
|
609
|
+
// Workers the CI started before the run did (`DteAdapter.adopt`).
|
|
610
|
+
adapter?.adopt?.((id, size) => {
|
|
611
|
+
const s = sizes.find((x) => x.name === size)
|
|
612
|
+
const n = Number(id.slice(id.lastIndexOf('#') + 1))
|
|
613
|
+
if (s === undefined || !Number.isInteger(n) || n < 1)
|
|
614
|
+
throw new UserError(`dte(): a started worker ${id} is no worker of a declared size`)
|
|
615
|
+
numbered.set(size, Math.max(numbered.get(size) ?? 0, n))
|
|
616
|
+
return spawnOf(s, id, ++index)
|
|
617
|
+
})
|
|
618
|
+
// Tasks core handed over: each missed the cache (`missedWork`).
|
|
619
|
+
const executed = new Set<string>()
|
|
620
|
+
// The run's deadline (`Plan.boundMs`), and the work its plans knew.
|
|
621
|
+
let deadline: number | undefined
|
|
622
|
+
const known = new Set<string>()
|
|
623
|
+
// A plan that needs a worker later plans again then.
|
|
624
|
+
let again: ReturnType<typeof setTimeout> | undefined
|
|
625
|
+
// The fewest workers that end the run's known work within a margin of
|
|
626
|
+
// the most would, played out (`planner.ts`); idle ones it does
|
|
627
|
+
// without leave. Again at each miss, finish, join and history read.
|
|
628
|
+
const shape = (remaining: ReadonlySet<string>): void => {
|
|
629
|
+
// This run's for the size, else an earlier run's, else any size's
|
|
630
|
+
// this run, else the default.
|
|
631
|
+
const ups = o.warmUpsMs()
|
|
632
|
+
const any = o.warmUpMs()
|
|
633
|
+
const warmUp = (size: string | undefined): number =>
|
|
634
|
+
(size === undefined ? undefined : (ups.get(size) ?? learned.get(size))) ??
|
|
635
|
+
any ??
|
|
636
|
+
WARM_UP_MS
|
|
637
|
+
// What is left takes as much longer than history as what ended did.
|
|
638
|
+
const p50 = (id: string): number | undefined => peaks.get(id)?.p50DurationMs
|
|
639
|
+
const slow = slowdown(o.finished(p50))
|
|
640
|
+
const ms = (id: string): number | undefined => {
|
|
641
|
+
const m = p50(id)
|
|
642
|
+
return m === undefined ? undefined : m * slow
|
|
643
|
+
}
|
|
644
|
+
const { workers, running } = o.planState(ms, warmUp)
|
|
645
|
+
const final = adapter === undefined
|
|
646
|
+
const p = decide({
|
|
647
|
+
remaining,
|
|
648
|
+
nodes: nodes ?? new Map(),
|
|
649
|
+
executed: knownMisses(likely, sure, executed, remaining),
|
|
650
|
+
// As the run books them: a plan of history alone asks for workers it then fills less.
|
|
651
|
+
need: (id) => o.booking(need(id)),
|
|
652
|
+
durationMs: ms,
|
|
653
|
+
workers,
|
|
654
|
+
running,
|
|
655
|
+
sizes,
|
|
656
|
+
started,
|
|
657
|
+
final,
|
|
658
|
+
warmUpMs: warmUp,
|
|
659
|
+
replaceable: adapter !== undefined,
|
|
660
|
+
limitMs: deadline === undefined ? undefined : deadline - performance.now(),
|
|
661
|
+
known,
|
|
662
|
+
how,
|
|
663
|
+
home,
|
|
664
|
+
queued: o.queued(warmUp),
|
|
665
|
+
})
|
|
666
|
+
deadline = performance.now() + p.boundMs
|
|
667
|
+
for (const t of p.tasks) known.add(t.id)
|
|
668
|
+
if ((p.start.length > 0 && !final) || p.release.length > 0)
|
|
669
|
+
o.planned({
|
|
670
|
+
start: final ? [] : p.start.map((s) => s.name),
|
|
671
|
+
release: p.release,
|
|
672
|
+
tasks: p.tasks.length,
|
|
673
|
+
endsInMs: p.endsInMs,
|
|
674
|
+
boundMs: p.boundMs,
|
|
675
|
+
workers,
|
|
676
|
+
...(p.start.length > 0 && !final
|
|
677
|
+
? { work: p.tasks, warmUps: new Map(sizes.map((s) => [s.name, warmUp(s.name)])) }
|
|
678
|
+
: {}),
|
|
679
|
+
})
|
|
680
|
+
const { tasks } = p
|
|
681
|
+
// At the end the relay closing ends them all.
|
|
682
|
+
if (p.release.length > 0 && remaining.size > 0) {
|
|
683
|
+
const cancelled = o.release(p.release)
|
|
684
|
+
const sized = new Map<string, Size>()
|
|
685
|
+
for (const id of cancelled) {
|
|
686
|
+
const s = sizeOf.get(id)
|
|
687
|
+
if (s === undefined) continue
|
|
688
|
+
sized.set(id, s)
|
|
689
|
+
sizeOf.delete(id)
|
|
690
|
+
started.set(s.name, started.get(s.name)! - 1)
|
|
691
|
+
}
|
|
692
|
+
// One its adapter cannot stop joins all the same: the run counts
|
|
693
|
+
// it again, and takes it.
|
|
694
|
+
if (cancelled.length > 0)
|
|
695
|
+
void Promise.resolve(
|
|
696
|
+
adapter?.cancel === undefined ? cancelled : adapter.cancel(cancelled),
|
|
697
|
+
)
|
|
698
|
+
.then((left) => {
|
|
699
|
+
const back = o.leftRunning(left ?? [])
|
|
700
|
+
for (const id of back) {
|
|
701
|
+
const s = sized.get(id)
|
|
702
|
+
if (s === undefined) continue
|
|
703
|
+
sizeOf.set(id, s)
|
|
704
|
+
started.set(s.name, (started.get(s.name) ?? 0) + 1)
|
|
705
|
+
}
|
|
706
|
+
if (back.length > 0) onChange()
|
|
707
|
+
})
|
|
708
|
+
.catch((err: unknown) =>
|
|
709
|
+
ctx.warn(
|
|
710
|
+
`[vx] dte(): stopping ${cancelled.join(', ')} failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
711
|
+
),
|
|
712
|
+
)
|
|
713
|
+
}
|
|
714
|
+
if (again !== undefined) clearTimeout(again)
|
|
715
|
+
again = undefined
|
|
716
|
+
if (p.againInMs !== undefined && !final) {
|
|
717
|
+
again = setTimeout(replan, p.againInMs)
|
|
718
|
+
again.unref?.()
|
|
719
|
+
}
|
|
720
|
+
if (p.start.length === 0 || final) return
|
|
721
|
+
o.asked(
|
|
722
|
+
p.start.length,
|
|
723
|
+
tasks.length,
|
|
724
|
+
tasks.filter((t) => measured(t.id)).length,
|
|
725
|
+
p.endsInMs,
|
|
726
|
+
tasks.filter((t) => !measured(t.id)).map((t) => t.id),
|
|
727
|
+
)
|
|
728
|
+
for (const s of p.start) {
|
|
729
|
+
++index
|
|
730
|
+
const id = nextId(s.name)
|
|
731
|
+
const a = spawnOf(s, id, index)
|
|
732
|
+
// A sync throw too: `spawn` may return void, and one thrown here
|
|
733
|
+
// escaped the plan's timer and ended the process.
|
|
734
|
+
void new Promise<void>((done) => done(adapter.spawn(a))).catch((err: unknown) =>
|
|
735
|
+
o.failed(id, err instanceof Error ? err.message : String(err)),
|
|
736
|
+
)
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
// Events come in bursts (core hands over tasks ready at once): one
|
|
740
|
+
// plan per tick, paced by its cost.
|
|
741
|
+
const paced = pace(() => {
|
|
742
|
+
if (pending !== undefined) shape(pending)
|
|
743
|
+
})
|
|
744
|
+
const replan = (now?: boolean): void => {
|
|
745
|
+
if (!missed || pending === undefined || !ready()) return
|
|
746
|
+
if (now === true) return paced.now()
|
|
747
|
+
paced.soon()
|
|
748
|
+
}
|
|
749
|
+
// Until a task misses, demand waits: a run all hits starts no worker.
|
|
750
|
+
let missed = false
|
|
751
|
+
let pending: ReadonlySet<string> | undefined
|
|
752
|
+
// The tasks core's probe found in no cache: they run, known before any worker keys them.
|
|
753
|
+
let sure: ReadonlySet<string> = new Set()
|
|
754
|
+
const measured = (id: string): boolean => peaks.get(id)?.maxCpuParallelism !== undefined
|
|
755
|
+
// Needs another plugin fetches (the default branch's timings) land a
|
|
756
|
+
// moment into the run. Asked before them, every task books a whole
|
|
757
|
+
// worker: main asked 12 workers for 13 misses and 5 ran nothing
|
|
758
|
+
// (2026-10-23). The first ask waits for them.
|
|
759
|
+
// Core makes the executor and settles its demand before `schedule`
|
|
760
|
+
// reads history: asked then, main's first ask booked a whole worker
|
|
761
|
+
// for each of 122 tasks, 0 measured, and cancelled 13 of 14
|
|
762
|
+
// (2026-10-23). A run that never reaches `schedule` (every task a
|
|
763
|
+
// restore) waits for its first miss.
|
|
764
|
+
let waited: boolean | undefined
|
|
765
|
+
const ready = (): boolean => {
|
|
766
|
+
if (waited === true) return true
|
|
767
|
+
if (needsIn === undefined) return executed.size > 0
|
|
768
|
+
if (waited === undefined) {
|
|
769
|
+
waited = false
|
|
770
|
+
void needsIn().then(() => {
|
|
771
|
+
waited = true
|
|
772
|
+
replan()
|
|
773
|
+
})
|
|
774
|
+
}
|
|
775
|
+
return false
|
|
776
|
+
}
|
|
777
|
+
onChange = replan
|
|
778
|
+
// Work the last commit's changes make: planned before any task misses.
|
|
779
|
+
const forecast = (): void => {
|
|
780
|
+
if (likely.size > 0) missed = true
|
|
781
|
+
replan()
|
|
782
|
+
}
|
|
783
|
+
if (needsIn === undefined) scheduled = forecast
|
|
784
|
+
else forecast()
|
|
785
|
+
return {
|
|
786
|
+
name: 'dte',
|
|
787
|
+
remote: true,
|
|
788
|
+
// No task runs on the orchestrator; only hits restore here (2026-10-24).
|
|
789
|
+
exclusive: true,
|
|
790
|
+
// Each worker times a task from its start: a wait for a booting worker is not its run.
|
|
791
|
+
timeouts: true,
|
|
792
|
+
sandbox: true,
|
|
793
|
+
// A task's persistent dependencies start on its worker, never here.
|
|
794
|
+
servers: true,
|
|
795
|
+
// A worker answers a task's runtime probes before it runs it.
|
|
796
|
+
runtime: true,
|
|
797
|
+
// Its sizes' platforms; workers that join on their own, this machine's.
|
|
798
|
+
platforms:
|
|
799
|
+
sizes.length === 0 ? [hostPlatform()] : [...new Set(sizes.map((s) => s.platform))],
|
|
800
|
+
// Room for the workers' cores and their prefetch; the orchestrator
|
|
801
|
+
// queues the rest. Workers that join on their own are as many as join.
|
|
802
|
+
capacity:
|
|
803
|
+
adapter === undefined || sizes.some((s) => s.max === Infinity)
|
|
804
|
+
? JOINED_CAPACITY
|
|
805
|
+
: Math.max(
|
|
806
|
+
1,
|
|
807
|
+
sizes.reduce((n, s) => n + 2 * s.max * s.cpu, 0),
|
|
808
|
+
),
|
|
809
|
+
execute: (req) => {
|
|
810
|
+
// A miss: the run's known work grows.
|
|
811
|
+
missed = true
|
|
812
|
+
executed.add(req.taskId)
|
|
813
|
+
replan()
|
|
814
|
+
return o.execute(req)
|
|
815
|
+
},
|
|
816
|
+
footer: () => o.footer(() => o.idealMs((id) => (nodes ?? ran)?.get(id)?.deps ?? [], sizes)),
|
|
817
|
+
machines: () => o.machines(),
|
|
818
|
+
idealMs: () => o.idealMs((id) => (nodes ?? ran)?.get(id)?.deps ?? [], sizes),
|
|
819
|
+
profile: () => o.profile(),
|
|
820
|
+
running: (id) => o.where(id),
|
|
821
|
+
demand(remaining, misses) {
|
|
822
|
+
if (pending === undefined) o.probed(remaining.size, misses.size)
|
|
823
|
+
o.missed = misses
|
|
824
|
+
pending = remaining
|
|
825
|
+
sure = misses
|
|
826
|
+
if (misses.size > 0) missed = true
|
|
827
|
+
replan()
|
|
828
|
+
},
|
|
829
|
+
}
|
|
830
|
+
},
|
|
831
|
+
|
|
832
|
+
async teardown() {
|
|
833
|
+
closing = close()
|
|
834
|
+
await closing
|
|
835
|
+
},
|
|
836
|
+
})
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
/** A GitHub Actions job that may ask for OIDC tokens: all githubProof() reads. */
|
|
840
|
+
function onGitHub(): boolean {
|
|
841
|
+
return Boolean(
|
|
842
|
+
process.env['ACTIONS_ID_TOKEN_REQUEST_URL'] &&
|
|
843
|
+
process.env['ACTIONS_ID_TOKEN_REQUEST_TOKEN'] &&
|
|
844
|
+
process.env['GITHUB_REPOSITORY_ID'],
|
|
845
|
+
)
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
/** `VX_DTE_SECRET`: the run's, given. */
|
|
849
|
+
function givenSecret(): string | undefined {
|
|
850
|
+
return process.env['VX_DTE_SECRET'] || undefined
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
/** A plan waits this many times the last one's length after it: its share of the CPU at most one in eleven. */
|
|
854
|
+
const PLAN_GAP = 10
|
|
855
|
+
|
|
856
|
+
/**
|
|
857
|
+
* `fn` at most once a tick (`soon`), and no sooner than `PLAN_GAP` times
|
|
858
|
+
* its last run's length after that run; `now` runs it at once. Planned
|
|
859
|
+
* after each task's end, 2000 tasks made 1281 plans and 9 s of the
|
|
860
|
+
* orchestrator's 19: workers waited on it, and its relay found them lost
|
|
861
|
+
* (2026-10-25).
|
|
862
|
+
*/
|
|
863
|
+
export function pace(fn: () => void): { soon(): void; now(): void } {
|
|
864
|
+
let pending: { cancel(): void } | undefined
|
|
865
|
+
let next = 0
|
|
866
|
+
const run = (): void => {
|
|
867
|
+
pending?.cancel()
|
|
868
|
+
pending = undefined
|
|
869
|
+
const start = performance.now()
|
|
870
|
+
try {
|
|
871
|
+
fn()
|
|
872
|
+
} finally {
|
|
873
|
+
const end = performance.now()
|
|
874
|
+
next = end + PLAN_GAP * (end - start)
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
return {
|
|
878
|
+
now: run,
|
|
879
|
+
soon() {
|
|
880
|
+
if (pending !== undefined) return
|
|
881
|
+
const wait = next - performance.now()
|
|
882
|
+
if (wait <= 0) {
|
|
883
|
+
const t = setImmediate(run)
|
|
884
|
+
pending = { cancel: () => clearImmediate(t) }
|
|
885
|
+
} else {
|
|
886
|
+
const t = setTimeout(run, wait)
|
|
887
|
+
t.unref?.()
|
|
888
|
+
pending = { cancel: () => clearTimeout(t) }
|
|
889
|
+
}
|
|
890
|
+
},
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
/**
|
|
895
|
+
* What an unmeasured task counts for in the worker ask (`dte({ unmeasured
|
|
896
|
+
* })`): fixed, since a plan cannot know which worker it lands on
|
|
897
|
+
* (2026-10-23: "by default, 2 cores, 4 gigs").
|
|
898
|
+
*/
|
|
899
|
+
const FRESH: Need = { cores: 2, memory: 4e9 }
|
|
900
|
+
/**
|
|
901
|
+
* What it books: one free core, no memory (2026-10-23: "pack them
|
|
902
|
+
* where we have free capability"). Simulated on main's graph with a third
|
|
903
|
+
* of its tasks unmeasured, it ended at 161 s where a whole worker each
|
|
904
|
+
* ended at 195 s; a quarter core packed a cold run's tasks 16 to a
|
|
905
|
+
* 4-core worker and ended at 267 s.
|
|
906
|
+
*/
|
|
907
|
+
const PLACED = { cores: 1, memory: 0 }
|
|
908
|
+
|
|
909
|
+
function unmeasuredOf(u: DteOptions['unmeasured']): Need {
|
|
910
|
+
if (u === undefined) return FRESH
|
|
911
|
+
refuseUnknownOptions('dte() option "unmeasured"', u, UNMEASURED_KEYS)
|
|
912
|
+
if (!(typeof u.cpu === 'number' && u.cpu > 0 && Number.isFinite(u.cpu)))
|
|
913
|
+
refuse('unmeasured.cpu', 'a number above 0', u.cpu)
|
|
914
|
+
const memory = bytes(u.memory)
|
|
915
|
+
if (memory === undefined)
|
|
916
|
+
refuse('unmeasured.memory', "bytes above 0 or a size such as '4GB'", u.memory)
|
|
917
|
+
return { cores: u.cpu, memory: memory! }
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
/** A `dte()` option it cannot use, named with the value as written (a NaN as NaN, not null). */
|
|
921
|
+
function refuse(option: string, want: string, got: unknown): never {
|
|
922
|
+
const said = typeof got === 'number' ? String(got) : (JSON.stringify(got) ?? String(got))
|
|
923
|
+
throw new UserError(`dte() option "${option}" must be ${want}, got ${said}`)
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
/** What a task asks of a worker, from its history. */
|
|
927
|
+
export function needOf(r: TaskResources | undefined, ask: Need = FRESH): Need {
|
|
928
|
+
// Unmeasured (new, or over before the Linux sandbox's first look, which
|
|
929
|
+
// leaves no CPU): any free core, counted as `ask` when the run is sized.
|
|
930
|
+
// A whole worker each left main's small#1 at 13% for three sub-second
|
|
931
|
+
// tasks (2026-10-23: "we can't waste whole worker for one task").
|
|
932
|
+
// One that filled its worker: a whole bigger one.
|
|
933
|
+
if (r?.maxCpuParallelism === undefined)
|
|
934
|
+
return { ...PLACED, ask: { cores: ask.cores, memory: ask.memory } }
|
|
935
|
+
if (r.saturatedCores !== undefined || r.saturatedMemoryBytes !== undefined)
|
|
936
|
+
return {
|
|
937
|
+
cores: Infinity,
|
|
938
|
+
memory: Infinity,
|
|
939
|
+
above: { cores: r.saturatedCores ?? 0, memory: r.saturatedMemoryBytes ?? 0 },
|
|
940
|
+
}
|
|
941
|
+
// The cores it keeps busy on average, unrounded: a suite at 0.7 booked a
|
|
942
|
+
// whole core and CI's workers ran at 30-50% (2026-10-22: "close to
|
|
943
|
+
// 100%"). A quarter at least, so a task that only waits is not free.
|
|
944
|
+
return { cores: Math.max(0.25, r.maxCpuParallelism), memory: r.maxPeakRssBytes ?? 0 }
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
/** The executor's pool when workers are uncapped: more than any run starts at once. */
|
|
948
|
+
const JOINED_CAPACITY = 1024
|
|
949
|
+
|
|
950
|
+
const UNITS: Record<string, number> = {
|
|
951
|
+
B: 1,
|
|
952
|
+
KB: 1e3,
|
|
953
|
+
MB: 1e6,
|
|
954
|
+
GB: 1e9,
|
|
955
|
+
TB: 1e12,
|
|
956
|
+
KIB: 1024,
|
|
957
|
+
MIB: 1024 ** 2,
|
|
958
|
+
GIB: 1024 ** 3,
|
|
959
|
+
TIB: 1024 ** 4,
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
/** `8GB`, `512MiB` or a byte count; undefined for anything else. */
|
|
963
|
+
export function bytes(v: unknown): number | undefined {
|
|
964
|
+
if (typeof v === 'number') return Number.isFinite(v) && v > 0 ? v : undefined
|
|
965
|
+
if (typeof v !== 'string') return undefined
|
|
966
|
+
const m = /^\s*(\d+(?:\.\d+)?)\s*([a-z]+)\s*$/i.exec(v)
|
|
967
|
+
const unit = m === null ? undefined : UNITS[m[2]!.toUpperCase()]
|
|
968
|
+
const n = unit === undefined ? 0 : Number(m![1]) * unit
|
|
969
|
+
// `'0GB'` is no more a size than `0`: a worker of no memory fits nothing beside.
|
|
970
|
+
return n > 0 ? n : undefined
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
/** A random (v4) UUID, as the relay takes: a run id no one can guess. */
|
|
974
|
+
const RUN_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
|
|
975
|
+
|
|
976
|
+
/** `VX_DTE_RUN`, which turns distribution on. */
|
|
977
|
+
function givenRun(): string | undefined {
|
|
978
|
+
const run = process.env['VX_DTE_RUN']
|
|
979
|
+
if (run === undefined || run === '') return undefined
|
|
980
|
+
if (!RUN_ID.test(run)) {
|
|
981
|
+
throw new UserError(
|
|
982
|
+
`VX_DTE_RUN is a random UUID, such as one \`uuidgen\` prints, so no one can guess the run: not ${JSON.stringify(run)}`,
|
|
983
|
+
)
|
|
984
|
+
}
|
|
985
|
+
return run
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
/**
|
|
989
|
+
* The workspace-relative paths changed since HEAD's first parent, the
|
|
990
|
+
* working tree's changes included: none when git cannot say (no parent, a
|
|
991
|
+
* shallow clone), and the run waits for its misses as before.
|
|
992
|
+
*/
|
|
993
|
+
export async function changedSinceParent(workspaceRoot: string): Promise<string[]> {
|
|
994
|
+
try {
|
|
995
|
+
const proc = Bun.spawn({
|
|
996
|
+
cmd: ['git', 'diff', '--name-only', '-z', '--relative', '--no-renames', 'HEAD^', '--'],
|
|
997
|
+
cwd: workspaceRoot,
|
|
998
|
+
stdout: 'pipe',
|
|
999
|
+
stderr: 'ignore',
|
|
1000
|
+
})
|
|
1001
|
+
const [out, exit] = await Promise.all([new Response(proc.stdout).text(), proc.exited])
|
|
1002
|
+
return exit === 0 ? out.split('\0').filter((l) => l !== '') : []
|
|
1003
|
+
// quiet: a local git read; no files is the answer
|
|
1004
|
+
} catch {
|
|
1005
|
+
return []
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
/**
|
|
1010
|
+
* The platform a task naming none runs on: `host`, when the sizes run it
|
|
1011
|
+
* and another. A worker of another platform then runs only the tasks that
|
|
1012
|
+
* name it, so they never wait behind the rest, and leaves when they are
|
|
1013
|
+
* done (2026-10-23). No size of `host`: anywhere, or nothing runs them.
|
|
1014
|
+
*/
|
|
1015
|
+
export function homeOf(sizes: readonly Size[], host: string): string | undefined {
|
|
1016
|
+
const of = new Set(sizes.filter((s) => s.max > 0).map((s) => s.platform))
|
|
1017
|
+
return of.has(host) && of.size > 1 ? host : undefined
|
|
1018
|
+
}
|