@vzn/vx-dte 0.0.0 → 0.0.649

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,11 @@
1
+ // Its own file: github() and gitlab() load with every run's workspace
2
+ // config, and importing wire.ts from them loaded the relay client too.
3
+
4
+ /**
5
+ * `s` with each C0/C1 control and DEL written as `\xNN`: a server's words
6
+ * reach the terminal, and an `ESC ]` or `\r` in them retitled it or
7
+ * overwrote the line before (core's `printable`, L-31).
8
+ */
9
+ export const printable = (s: string): string =>
10
+ // oxlint-disable-next-line no-control-regex
11
+ s.replace(/[\x00-\x1f\x7f-\x9f]/g, (c) => `\\x${c.charCodeAt(0).toString(16).padStart(2, '0')}`)
package/src/seal.ts ADDED
@@ -0,0 +1,509 @@
1
+ // End-to-end encryption over the relay (design dte-2026-10.md, "Relay
2
+ // encryption"): each worker and the orchestrator run a Noise handshake
3
+ // (`noise.ts`) through it. With the run's key, `Noise_NNpsk0`: the key is
4
+ // the psk, so only a side that holds it completes. On GitHub keyless,
5
+ // `Noise_NN`, each side's OIDC token binding what it said. Every body after
6
+ // that is a Noise transport message. Ephemeral keys only: a leaked run key
7
+ // opens no past run.
8
+
9
+ import { createHash } from 'node:crypto'
10
+ import { type Bytes, type CipherState, Handshake, MAX_PLAINTEXT } from './noise.js'
11
+ import { RelayError, unsent as unsentBy, type Participant, type RelayMessage } from './wire.js'
12
+ import { ui } from '@vzn/vx'
13
+
14
+ /**
15
+ * What admits a side: the run's key (Noise's psk), or each side's CI
16
+ * identity, whose token binds the handshake.
17
+ */
18
+ export interface Proof {
19
+ /** The psk: `NNpsk0`. Absent: `NN`, with `sign` and `check`. */
20
+ readonly psk?: () => Promise<Bytes>
21
+ sign?(parts: readonly string[]): Promise<string>
22
+ check?(proof: string, parts: readonly string[]): Promise<boolean>
23
+ /** What a side whose key fails lacks. */
24
+ readonly mismatch: string
25
+ }
26
+
27
+ /** The run's shared secret: its SHA-256 is the psk. */
28
+ export function secretProof(secret: string): Proof {
29
+ let psk: Promise<Bytes> | undefined
30
+ return {
31
+ psk: () =>
32
+ (psk ??= crypto.subtle
33
+ .digest('SHA-256', new TextEncoder().encode(secret))
34
+ .then((b) => new Uint8Array(b))),
35
+ mismatch: 'set the same VX_DTE_SECRET',
36
+ }
37
+ }
38
+
39
+ const GITHUB = 'https://token.actions.githubusercontent.com'
40
+ /** GitHub's keys are read again for a key not among them at most this often. */
41
+ const KEYS_REREAD_MS = 60_000
42
+
43
+ /**
44
+ * An OIDC token for `audience` from GitHub's token endpoint. A 5xx or a
45
+ * dropped connection is tried again, three tries in 10 s at most: one 503
46
+ * ended a worker before it joined (PR #4146, 2026-10-23).
47
+ */
48
+ export async function oidcToken(url: string, bearer: string, audience: string): Promise<string> {
49
+ const until = performance.now() + 10_000
50
+ for (let attempt = 1; ; attempt++) {
51
+ const res = await fetch(`${url}&audience=${encodeURIComponent(audience)}`, {
52
+ headers: { authorization: `bearer ${bearer}` },
53
+ signal: AbortSignal.timeout(Math.max(1, until - performance.now())),
54
+ }).catch((err: unknown) => (err instanceof Error ? err.message : String(err)))
55
+ if (typeof res !== 'string' && res.ok) {
56
+ // quiet: a body that is not JSON holds no token, said below
57
+ const value = ((await res.json().catch(() => null)) as { value?: unknown } | null)?.value
58
+ if (typeof value !== 'string' || value === '')
59
+ throw new Error("GitHub's OIDC token: no token in its answer")
60
+ return value
61
+ }
62
+ const why = typeof res === 'string' ? res : await refusedWith(res)
63
+ const again = typeof res === 'string' || res.status >= 500 || res.status === 429
64
+ const pause = 250 * 2 ** (attempt - 1)
65
+ if (!again || attempt >= 3 || performance.now() + pause >= until)
66
+ throw new Error(`GitHub's OIDC token: ${why}`)
67
+ await Bun.sleep(pause)
68
+ }
69
+ }
70
+
71
+ /** A refusal by its status and GitHub's own `message`, bounded: a 403 said why the token was refused. */
72
+ async function refusedWith(res: Response): Promise<string> {
73
+ // quiet: a body that is not JSON leaves the status to name the refusal
74
+ const body = (await res.json().catch(() => null)) as { message?: unknown } | null
75
+ const said =
76
+ typeof body?.message === 'string' ? body.message.replace(/\s+/g, ' ').trim().slice(0, 200) : ''
77
+ return `HTTP ${res.status}${said === '' ? '' : `: ${said}`}`
78
+ }
79
+
80
+ /**
81
+ * Keyless on GitHub Actions: a side signs by an OIDC token whose audience
82
+ * is the digest of what it signs, and checks one as GitHub's and of this
83
+ * repository (`GITHUB_REPOSITORY_ID`). No secret is stored anywhere.
84
+ * `runs`, the orchestrator's: the workflow runs it dispatched, whose jobs
85
+ * alone are its workers (a run's id is a dispatch input anyone who reads
86
+ * the repository's Actions sees); `undefined` admits any job of the
87
+ * repository, as workers that join on their own are.
88
+ */
89
+ export function githubProof(
90
+ fetchJson?: (url: string) => Promise<unknown>,
91
+ runs?: () => ReadonlySet<string> | undefined,
92
+ ): Proof {
93
+ const { ACTIONS_ID_TOKEN_REQUEST_URL: url, ACTIONS_ID_TOKEN_REQUEST_TOKEN: bearer } = process.env
94
+ const repo = process.env['GITHUB_REPOSITORY_ID']
95
+ if (!url || !bearer || !repo) {
96
+ const missing = Object.entries({
97
+ ACTIONS_ID_TOKEN_REQUEST_URL: url,
98
+ ACTIONS_ID_TOKEN_REQUEST_TOKEN: bearer,
99
+ GITHUB_REPOSITORY_ID: repo,
100
+ }).flatMap(([k, v]) => (v ? [] : [k]))
101
+ throw new Error(`not a GitHub Actions job with id-token: write (${missing.join(', ')})`)
102
+ }
103
+ const aud = (parts: readonly string[]): string =>
104
+ `vx-dte:${createHash('sha256').update(parts.join('\0')).digest('base64url')}`
105
+ // GitHub's keys, read once for every check, and again for a key not
106
+ // among them once a minute at most.
107
+ const seen = new Map<string, { at: number; p: Promise<unknown> }>()
108
+ const read =
109
+ fetchJson ??
110
+ (async (u: string): Promise<unknown> => {
111
+ const res = await fetch(u, { signal: AbortSignal.timeout(10_000) })
112
+ if (!res.ok) throw new Error(`${u}: ${res.status}`)
113
+ return res.json()
114
+ })
115
+ const once = (u: string, fresh: boolean): Promise<unknown> => {
116
+ const had = seen.get(u)
117
+ if (had !== undefined && !(fresh && Date.now() - had.at >= KEYS_REREAD_MS)) return had.p
118
+ const entry = { at: Date.now(), p: read(u) }
119
+ // quiet: the caller awaits p and hears its error; this only forgets it
120
+ entry.p.catch(() => seen.get(u) === entry && seen.delete(u))
121
+ seen.set(u, entry)
122
+ return entry.p
123
+ }
124
+ return {
125
+ async sign(parts) {
126
+ return oidcToken(url, bearer, aud(parts))
127
+ },
128
+ async check(token, parts) {
129
+ const { createVerifier, TokenError } = await import('@vzn/vx-dte-server')
130
+ // A read of GitHub's keys that failed is no check: refused as one, a
131
+ // keys outage read "its key is not the run's" (2026-10-23).
132
+ let unread: unknown
133
+ // A verifier per check, its audience the parts: its own refetch for
134
+ // an unknown key never fires, so a stale read is redone here.
135
+ const verify = (fresh: boolean) =>
136
+ createVerifier({
137
+ allow: [{ issuer: GITHUB }],
138
+ audience: aud(parts),
139
+ runClaims: {
140
+ [GITHUB]: runs === undefined ? ['repository_id'] : ['repository_id', 'run_id'],
141
+ },
142
+ fetchJson: (u) =>
143
+ once(u, fresh).catch((err: unknown) => {
144
+ unread = err
145
+ throw err
146
+ }),
147
+ })(token)
148
+ return verify(false)
149
+ .catch((err: unknown) => {
150
+ if (err instanceof TokenError && err.message === 'unknown signing key')
151
+ return verify(true)
152
+ throw err
153
+ })
154
+ .then(
155
+ (t) => {
156
+ const mine = runs?.()
157
+ return t.run[0] === repo && (mine === undefined || mine.has(t.run[1]!))
158
+ },
159
+ () => {
160
+ if (unread !== undefined) throw unread
161
+ return false
162
+ },
163
+ )
164
+ },
165
+ mismatch: 'run it in a job of this repository with id-token: write',
166
+ }
167
+ }
168
+
169
+ const b64 = (b: Uint8Array): string => Buffer.from(b).toString('base64')
170
+ const unb64 = (s: string): Bytes => new Uint8Array(Buffer.from(s, 'base64'))
171
+ const text = new TextEncoder()
172
+
173
+ /** Both sides bind the run: a handshake of one run does not complete in another. */
174
+ const prologue = (run: string): Bytes => text.encode(`vx-dte\0${run}`)
175
+
176
+ async function handshake(proof: Proof, initiator: boolean, run: string): Promise<Handshake> {
177
+ const psk = await proof.psk?.()
178
+ return Handshake.start({
179
+ pattern: psk === undefined ? 'NN' : 'NNpsk0',
180
+ initiator,
181
+ prologue: prologue(run),
182
+ ...(psk === undefined ? {} : { psk }),
183
+ })
184
+ }
185
+
186
+ const REPLAY = Symbol('replay')
187
+
188
+ /**
189
+ * The size a body is padded to, so the relay learns its size class, not
190
+ * its size: a power of two from 256 bytes, then whole transport messages.
191
+ */
192
+ function padded(n: number): number {
193
+ if (n >= MAX_PLAINTEXT) return Math.ceil(n / MAX_PLAINTEXT) * MAX_PLAINTEXT
194
+ return Math.min(2 ** Math.ceil(Math.log2(Math.max(n, 256))), MAX_PLAINTEXT)
195
+ }
196
+
197
+ /**
198
+ * One worker and the orchestrator after their handshake. Each body is
199
+ * Noise transport messages of at most 64 KiB, each naming its nonce, as
200
+ * Noise allows for a transport that may reorder (§11.4); a nonce seen
201
+ * before is a replay. The wire delivers in send order, so a body older
202
+ * than one opened does not open.
203
+ */
204
+ class Session {
205
+ private sent = 0
206
+ private readonly seen = new Set<number>()
207
+ /** The last nonce of the newest body opened. */
208
+ private newest = -1
209
+ /** The last send handed to the wire, or still sealing: the next waits on it. */
210
+ private turn: Promise<unknown> = Promise.resolve()
211
+
212
+ constructor(
213
+ private readonly tx: CipherState,
214
+ private readonly rx: CipherState,
215
+ ) {}
216
+
217
+ /**
218
+ * `body` sealed and handed to `wire`, in the order sends were asked for:
219
+ * sealing is async, and a big body sealed first reached the wire after a
220
+ * small one sealed next (a worker's `end` before its `ran`, 2026-10-26).
221
+ * The wire numbers a send as it is handed over, so only the handing waits.
222
+ */
223
+ async send(body: unknown, wire: (sealed: { seal: string[] }) => Promise<void>): Promise<void> {
224
+ let sent: Promise<void> | undefined
225
+ const mine = this.turn.then(async () => {
226
+ sent = wire(await this.seal(body))
227
+ })
228
+ // quiet: `await mine` below hands the caller this error; the next turn
229
+ // must not inherit it.
230
+ this.turn = mine.catch(() => undefined)
231
+ await mine
232
+ await sent
233
+ }
234
+
235
+ private async seal(body: unknown): Promise<{ seal: string[] }> {
236
+ const plain = squeeze(text.encode(JSON.stringify(body)))
237
+ // Trailing spaces: JSON.parse reads past them, and `unsqueeze` stops before them.
238
+ const bytes = new Uint8Array(padded(plain.length)).fill(0x20)
239
+ bytes.set(plain)
240
+ const out: string[] = []
241
+ const ad = bodyAd(this.sent, Math.ceil(bytes.length / MAX_PLAINTEXT))
242
+ for (let at = 0; at === 0 || at < bytes.length; at += MAX_PLAINTEXT) {
243
+ const n = this.sent++
244
+ const c = await this.tx.encrypt(n, ad, bytes.subarray(at, at + MAX_PLAINTEXT))
245
+ out.push(`${n}.${b64(c)}`)
246
+ }
247
+ return { seal: out }
248
+ }
249
+
250
+ /** The body; `REPLAY` for one delivered before; undefined for one that does not open. */
251
+ async open(body: unknown): Promise<unknown> {
252
+ const s = (body as { seal?: unknown } | null)?.seal
253
+ if (!Array.isArray(s) || s.length === 0) return undefined
254
+ const parts: { n: number; c: string }[] = []
255
+ for (const p of s) {
256
+ if (typeof p !== 'string') return undefined
257
+ const [n, c] = p.split('.')
258
+ const seq = Number(n)
259
+ if (!Number.isSafeInteger(seq) || seq < 0 || c === undefined) return undefined
260
+ // A body's parts take consecutive nonces: every part shares one ad, so
261
+ // only this keeps a relay from repeating or reordering them.
262
+ if (parts.length > 0 && seq !== parts[0]!.n + parts.length) return undefined
263
+ parts.push({ n: seq, c })
264
+ }
265
+ if (this.seen.has(parts[0]!.n)) return REPLAY
266
+ // The wire numbers sends outside the seal, in the order they were
267
+ // sealed: one older than a body already opened was held back by the relay.
268
+ if (parts[0]!.n <= this.newest) return undefined
269
+ // Each part is sealed to its body's first nonce and part count: unbound,
270
+ // a relay spliced a fresh first part onto a delivered body's second.
271
+ const ad = bodyAd(parts[0]!.n, parts.length)
272
+ try {
273
+ const plain: Uint8Array[] = []
274
+ for (const p of parts) plain.push(await this.rx.decrypt(p.n, ad, unb64(p.c)))
275
+ const body: unknown = JSON.parse(unsqueeze(Buffer.concat(plain)).toString())
276
+ for (const p of parts) this.seen.add(p.n)
277
+ this.newest = parts.at(-1)!.n
278
+ return body
279
+ // quiet: a body that does not open is the relay's tampering, which the caller says
280
+ } catch {
281
+ return undefined
282
+ }
283
+ }
284
+ }
285
+
286
+ /** Leads a compressed body: no JSON starts with it. */
287
+ const ZSTD = 0
288
+
289
+ /**
290
+ * A body over the smallest size class, zstd-compressed before it is sealed:
291
+ * once sealed it is random bytes no relay or socket compression shrinks.
292
+ * The padding still hides its size within a class.
293
+ */
294
+ function squeeze(json: Uint8Array): Uint8Array {
295
+ if (json.length <= 256) return json
296
+ const z = Bun.zstdCompressSync(json)
297
+ if (z.length + 5 >= json.length) return json
298
+ const out = new Uint8Array(z.length + 5)
299
+ out[0] = ZSTD
300
+ new DataView(out.buffer).setUint32(1, z.length)
301
+ out.set(z, 5)
302
+ return out
303
+ }
304
+
305
+ function unsqueeze(plain: Uint8Array): Uint8Array {
306
+ if (plain[0] !== ZSTD) return plain
307
+ const n = new DataView(plain.buffer, plain.byteOffset).getUint32(1)
308
+ return Bun.zstdDecompressSync(plain.subarray(5, 5 + n))
309
+ }
310
+
311
+ /** What each part of one body is sealed to: its first nonce and how many parts. */
312
+ function bodyAd(first: number, parts: number): Uint8Array<ArrayBuffer> {
313
+ return text.encode(`${first}.${parts}`)
314
+ }
315
+
316
+ /** A worker's poll raises it when a body from the orchestrator does not open: the relay changed or replayed it. */
317
+ export class SealError extends Error {}
318
+
319
+ /** A flat object's JSON with sorted keys. */
320
+ function canonical(info: Record<string, unknown>): string {
321
+ return JSON.stringify(
322
+ Object.fromEntries(Object.entries(info).sort(([a], [b]) => (a < b ? -1 : 1))),
323
+ )
324
+ }
325
+
326
+ /**
327
+ * The orchestrator's side, the responder. Each relay `joined` carries an
328
+ * worker's first handshake message, its info the payload; one this side
329
+ * completes is answered with the second and passed on with that info, one
330
+ * it cannot is refused here and never seen.
331
+ */
332
+ export function sealOrchestrator(
333
+ me: Participant,
334
+ run: string,
335
+ proof: Proof,
336
+ warn: (message: string) => void = (m) => ui.say('warn', m),
337
+ ): Participant {
338
+ const unsent = (what: string, to: string) => unsentBy((m) => warn(`[vx] ${m}`), what, to)
339
+ const sessions = new Map<string, Session>()
340
+ /** A worker's first message, read: its info, and this side's answer. */
341
+ const admit = async (
342
+ info: Record<string, unknown>,
343
+ ): Promise<{ info: Record<string, unknown>; hello: object; session: Session } | undefined> => {
344
+ const { noise, token } = info
345
+ if (typeof noise !== 'string') return undefined
346
+ if (proof.psk === undefined) {
347
+ if (typeof token !== 'string' || !(await proof.check!(token, ['join', run, noise])))
348
+ return undefined
349
+ }
350
+ const hs = await handshake(proof, false, run)
351
+ let payload: Uint8Array
352
+ try {
353
+ payload = await hs.read(unb64(noise))
354
+ // quiet: a handshake that fails is a key mismatch, which the caller says
355
+ } catch {
356
+ return undefined
357
+ }
358
+ const parsed = JSON.parse(Buffer.from(payload).toString()) as Record<string, unknown>
359
+ const answer = b64(await hs.write())
360
+ const { send, receive } = await hs.split()
361
+ const hello =
362
+ proof.psk === undefined
363
+ ? { type: 'hello', noise: answer, token: await proof.sign!(['hello', run, b64(hs.hash)]) }
364
+ : { type: 'hello', noise: answer }
365
+ return { info: parsed, hello, session: new Session(send, receive) }
366
+ }
367
+ return {
368
+ id: me.id,
369
+ heartbeatMs: me.heartbeatMs,
370
+ get online() {
371
+ return me.online
372
+ },
373
+ resumed: (f) => me.resumed(f),
374
+ async send(to, body) {
375
+ const s = sessions.get(to)
376
+ // Lost or never admitted: the relay's own answer for a peer it does
377
+ // not know, which a caller takes as gone, not as a refused send.
378
+ if (s === undefined) throw new RelayError(404, `no session with ${to}`)
379
+ await s.send(body, (sealed) => me.send(to, sealed))
380
+ },
381
+ async poll(signal) {
382
+ const out: RelayMessage[] = []
383
+ for (const m of await me.poll(signal)) {
384
+ if (m.from === 'relay') {
385
+ const b = m.body as { type?: string; id?: string; info?: Record<string, unknown> | null }
386
+ if (b.type === 'joined') {
387
+ const id = b.id!
388
+ // A check that could not run (GitHub's keys unreachable) is no
389
+ // mismatch: it was refused as one, and the cause never said.
390
+ const got = await admit(b.info ?? {}).catch(
391
+ (err: unknown) =>
392
+ `its token could not be checked: ${err instanceof Error ? err.message : String(err)}`,
393
+ )
394
+ if (got === undefined || typeof got === 'string') {
395
+ const reason = got ?? `its key is not the run’s: ${proof.mismatch}`
396
+ warn(`[vx] dte: refused a worker: ${reason}`)
397
+ await me.send(id, { type: 'refused', reason }).catch(unsent('refused', id))
398
+ await me.remove(id).catch(unsent('remove', id))
399
+ continue
400
+ }
401
+ sessions.set(id, got.session)
402
+ await me.send(id, got.hello).catch(unsent('hello', id))
403
+ out.push({ from: 'relay', body: { ...b, info: got.info } })
404
+ continue
405
+ }
406
+ if (b.type === 'lost') sessions.delete(b.id!)
407
+ out.push(m)
408
+ continue
409
+ }
410
+ // One from a worker already gone is dropped; one that does not open
411
+ // loses its worker, whose tasks run again elsewhere.
412
+ const s = sessions.get(m.from)
413
+ if (s === undefined) continue
414
+ const body = await s.open(m.body)
415
+ if (body === REPLAY) continue
416
+ if (body === undefined) {
417
+ sessions.delete(m.from)
418
+ out.push({ from: 'relay', body: { type: 'lost', id: m.from } })
419
+ continue
420
+ }
421
+ out.push({ from: m.from, body })
422
+ }
423
+ return out
424
+ },
425
+ remove: (worker) => me.remove(worker),
426
+ leave: () => me.leave(),
427
+ }
428
+ }
429
+
430
+ /**
431
+ * The worker's side, the initiator: `join` with its first handshake message
432
+ * (`info` the payload, sealed under the psk), then wait for the
433
+ * orchestrator's. Before it, only a plain `refused` is passed on.
434
+ */
435
+ export async function sealWorker(
436
+ joinAs: (info: Record<string, unknown>) => Promise<Participant>,
437
+ run: string,
438
+ proof: Proof,
439
+ info: Record<string, unknown>,
440
+ ): Promise<Participant> {
441
+ const hs = await handshake(proof, true, run)
442
+ const noise = b64(await hs.write(text.encode(canonical(info))))
443
+ const me = await joinAs(
444
+ proof.psk === undefined ? { noise, token: await proof.sign!(['join', run, noise]) } : { noise },
445
+ )
446
+ let session: Session | undefined
447
+ const answer = async (b: { noise?: unknown; token?: unknown }): Promise<boolean> => {
448
+ if (typeof b.noise !== 'string') return false
449
+ try {
450
+ await hs.read(unb64(b.noise))
451
+ // quiet: a hello that does not open is refused by the throw below
452
+ } catch {
453
+ return false
454
+ }
455
+ if (
456
+ proof.psk === undefined &&
457
+ (typeof b.token !== 'string' || !(await proof.check!(b.token, ['hello', run, b64(hs.hash)])))
458
+ )
459
+ return false
460
+ const { send, receive } = await hs.split()
461
+ session = new Session(send, receive)
462
+ return true
463
+ }
464
+ return {
465
+ id: me.id,
466
+ heartbeatMs: me.heartbeatMs,
467
+ get online() {
468
+ return me.online
469
+ },
470
+ resumed: (f) => me.resumed(f),
471
+ async send(to, body) {
472
+ if (session === undefined) throw new Error('no session with the orchestrator yet')
473
+ await session.send(body, (sealed) => me.send(to, sealed))
474
+ },
475
+ async poll(signal) {
476
+ const out: RelayMessage[] = []
477
+ for (const m of await me.poll(signal)) {
478
+ if (m.from === 'relay') {
479
+ out.push(m)
480
+ continue
481
+ }
482
+ if (session === undefined) {
483
+ const b = m.body as { type?: string; noise?: unknown; token?: unknown; reason?: unknown }
484
+ if (b.type === 'hello' && (await answer(b))) continue
485
+ if (b.type === 'refused' && typeof b.reason === 'string') {
486
+ out.push(m)
487
+ continue
488
+ }
489
+ throw new SealError(`the orchestrator’s key is not the run’s: ${proof.mismatch}`)
490
+ }
491
+ const body = await session.open(m.body)
492
+ if (body === REPLAY) continue
493
+ if (body === undefined)
494
+ throw new SealError(
495
+ 'a message from the orchestrator did not open: the relay changed or replayed it',
496
+ )
497
+ out.push({ from: m.from, body })
498
+ }
499
+ return out
500
+ },
501
+ remove: (worker) => me.remove(worker),
502
+ leave: () => me.leave(),
503
+ }
504
+ }
505
+
506
+ /** A secret for one run, for workers its adapter starts. */
507
+ export function runSecret(): string {
508
+ return Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString('base64url')
509
+ }
@@ -0,0 +1,50 @@
1
+ // How a run schedules, as `dte({ scheduler })` names it: loaded with the
2
+ // plugin, so it holds only the names and the booking rule.
3
+
4
+ import type { Need } from './dispatch.js'
5
+
6
+ /**
7
+ * How a distributed run schedules, one choice per layer, so each can be
8
+ * compared on the same runs (tests/sim.test.ts) and a workspace can pick
9
+ * another (2026-10-23):
10
+ * - `order`, which waiting task goes first: `critical-path` (longest
11
+ * chain after it, HEFT's rank), `graphene` (the long and the wide
12
+ * first, then by chain, Grandl OSDI'16), `fifo` (as they got ready);
13
+ * - `place`, which worker takes it: `best-fit` (fewest cores left),
14
+ * `tetris` (needs most aligned with what is free, SIGCOMM'14),
15
+ * `worst-fit` (most free), `first-fit`;
16
+ * - `book`, what a task holds: `measured` (its history), `margin`
17
+ * (history and a fifth more, Borg's), `whole` (a worker each);
18
+ * - `scale`, how many workers: `plan` (the fewest that end the known
19
+ * work near its best, `planner.ts`), `backlog` (enough cores for what
20
+ * is ready or running, of each platform, Spark's dynamic allocation).
21
+ */
22
+ export interface Strategy {
23
+ readonly order: 'critical-path' | 'graphene' | 'fifo'
24
+ readonly place: 'best-fit' | 'tetris' | 'worst-fit' | 'first-fit'
25
+ readonly book: 'measured' | 'margin' | 'whole'
26
+ readonly scale: 'plan' | 'backlog'
27
+ }
28
+
29
+ export const DEFAULT_STRATEGY: Strategy = {
30
+ order: 'critical-path',
31
+ place: 'best-fit',
32
+ book: 'measured',
33
+ scale: 'plan',
34
+ }
35
+
36
+ /** Every choice of each layer: what `dte({ scheduler })` accepts. */
37
+ export const STRATEGIES: { readonly [K in keyof Strategy]: readonly Strategy[K][] } = {
38
+ order: ['critical-path', 'graphene', 'fifo'],
39
+ place: ['best-fit', 'tetris', 'worst-fit', 'first-fit'],
40
+ book: ['measured', 'margin', 'whole'],
41
+ scale: ['plan', 'backlog'],
42
+ }
43
+
44
+ /** `need` as `book` holds it. */
45
+ export function booked(need: Need, book: Strategy['book']): Need {
46
+ if (book === 'whole') return { cores: Infinity, memory: Infinity, above: need.above }
47
+ if (book === 'margin' && need.cores !== Infinity && need.ask === undefined)
48
+ return { cores: need.cores * 1.2, memory: need.memory * 1.2, above: need.above }
49
+ return need
50
+ }