@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.
- 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 +1020 -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 +72 -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/printable.ts
ADDED
|
@@ -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
|
+
}
|
package/src/strategy.ts
ADDED
|
@@ -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
|
+
}
|