@vzn/vx-dte 0.0.0-stage → 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/index.ts ADDED
@@ -0,0 +1,4 @@
1
+ // Root entry shim: Bun 1.4.0's `--compile` binaries resolve an on-disk
2
+ // package by `<pkg>/index.ts` and ignore package.json `exports` / `main`
3
+ // (see packages/vx/index.ts). Same module as the exports map names.
4
+ export * from './src/index.js'
package/package.json CHANGED
@@ -1,6 +1,44 @@
1
1
  {
2
2
  "name": "@vzn/vx-dte",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.0.649",
4
+ "license": "MIT",
5
+ "description": "Distributed task execution for vx: one run spread over workers your own CI spawns, joined through a relay.",
6
+ "keywords": [
7
+ "vx",
8
+ "monorepo",
9
+ "vx-plugin",
10
+ "distributed",
11
+ "ci"
12
+ ],
13
+ "type": "module",
14
+ "main": "./src/index.ts",
15
+ "types": "./src/index.ts",
16
+ "exports": {
17
+ ".": {
18
+ "types": "./src/index.ts",
19
+ "import": "./src/index.ts"
20
+ }
21
+ },
22
+ "files": [
23
+ "index.ts",
24
+ "src",
25
+ "README.md",
26
+ "LICENSE"
27
+ ],
28
+ "engines": {
29
+ "bun": ">=1.4.4"
30
+ },
31
+ "dependencies": {
32
+ "@vzn/vx-dte-server": "0.0.649"
33
+ },
34
+ "peerDependencies": {
35
+ "@vzn/vx": "^0.0.649"
36
+ },
37
+ "repository": {
38
+ "type": "git",
39
+ "url": "git+https://github.com/vznjs/vx.git",
40
+ "directory": "packages/vx-dte"
41
+ },
42
+ "homepage": "https://github.com/vznjs/vx/tree/main/packages/vx-dte#readme",
43
+ "bugs": "https://github.com/vznjs/vx/issues"
44
+ }
@@ -0,0 +1,134 @@
1
+ // Where a worker's machine spent its CPU while the worker served a run,
2
+ // said once as it leaves, in its own log: its tasks, the worker itself, its
3
+ // other children, and the processes that used the most besides. The run's
4
+ // summary showed machines busier than their tasks (2.8 cores against 2.0,
5
+ // 2026-10-24) and nothing said what the rest was.
6
+
7
+ import { readFileSync } from 'node:fs'
8
+
9
+ /** Each process's CPU so far by pid, with its name, from one `ps`. */
10
+ export type ProcTable = Map<number, { name: string; ms: number }>
11
+
12
+ export interface CpuAccount {
13
+ readonly onlineMs: number
14
+ /** Busy core-time of the whole machine over `onlineMs`, any process's. */
15
+ readonly machineMs: number
16
+ readonly tasksMs: number
17
+ readonly selfMs: number
18
+ /** Its reaped children's, less its tasks': Linux only. */
19
+ readonly childrenMs?: number | undefined
20
+ /** The processes that used the most while it served, its own tree aside. */
21
+ readonly top: readonly { name: string; ms: number }[]
22
+ }
23
+
24
+ /** `ps`'s `time`, `[[dd-]hh:]mm:ss[.cc]`, in ms; macOS may print a comma for the point. */
25
+ export function psTime(s: string): number | undefined {
26
+ const dash = s.indexOf('-')
27
+ const days = dash === -1 ? 0 : Number(s.slice(0, dash))
28
+ const parts = s
29
+ .slice(dash + 1)
30
+ .replace(',', '.')
31
+ .split(':')
32
+ .map(Number)
33
+ if (parts.length < 2 || !Number.isFinite(days) || parts.some((n) => !Number.isFinite(n))) {
34
+ return undefined
35
+ }
36
+ return Math.round((days * 86_400 + parts.reduce((a, n) => a * 60 + n, 0)) * 1000)
37
+ }
38
+
39
+ export interface Ps {
40
+ readonly table: ProcTable
41
+ readonly parents: Map<number, number>
42
+ }
43
+
44
+ /** `ps -A -o pid=,ppid=,time=,comm=`, read. */
45
+ export function parsePs(text: string): Ps {
46
+ const table: ProcTable = new Map()
47
+ const parents = new Map<number, number>()
48
+ for (const line of text.split('\n')) {
49
+ const m = /^\s*(\d+)\s+(\d+)\s+(\S+)\s+(.+?)\s*$/.exec(line)
50
+ if (m === null) continue
51
+ const ms = psTime(m[3]!)
52
+ if (ms === undefined) continue
53
+ // macOS prints the path it ran; Linux a name, a kernel thread's with a
54
+ // slash in it (`kworker/0:1`).
55
+ const name = /^[./]/.test(m[4]!) ? m[4]!.split('/').pop()! : m[4]!
56
+ table.set(Number(m[1]), { name, ms })
57
+ parents.set(Number(m[1]), Number(m[2]))
58
+ }
59
+ return { table, parents }
60
+ }
61
+
62
+ /** One `ps` of every process. Undefined where it fails. */
63
+ export function readPs(): Ps | undefined {
64
+ try {
65
+ const r = Bun.spawnSync(['ps', '-A', '-o', 'pid=,ppid=,time=,comm='], { stderr: 'ignore' })
66
+ return r.exitCode === 0 ? parsePs(r.stdout.toString()) : undefined
67
+ } catch {
68
+ // quiet: no `ps`, no list of the rest
69
+ return undefined
70
+ }
71
+ }
72
+
73
+ /**
74
+ * The CPU each process used between two tables, by name, the most first:
75
+ * `self`'s tree left out (its tasks and itself are counted apart). A
76
+ * process gone by the second table is not seen.
77
+ */
78
+ export function topBetween(
79
+ before: ProcTable,
80
+ after: Ps,
81
+ self: number,
82
+ n = 5,
83
+ ): { name: string; ms: number }[] {
84
+ const mine = (pid: number): boolean => {
85
+ // `self` asked before the stop at pid 1: docker() execs the worker as
86
+ // its container's init.
87
+ for (let p: number | undefined = pid, hops = 0; p !== undefined && hops < 64; hops++) {
88
+ if (p === self) return true
89
+ if (p <= 1) break
90
+ p = after.parents.get(p)
91
+ }
92
+ return false
93
+ }
94
+ const byName = new Map<string, number>()
95
+ for (const [pid, p] of after.table) {
96
+ if (mine(pid)) continue
97
+ const was = before.get(pid)
98
+ const ms = was !== undefined && was.name === p.name ? p.ms - was.ms : p.ms
99
+ if (ms > 0) byName.set(p.name, (byName.get(p.name) ?? 0) + ms)
100
+ }
101
+ return [...byName]
102
+ .map(([name, ms]) => ({ name, ms }))
103
+ .sort((a, b) => b.ms - a.ms)
104
+ .slice(0, n)
105
+ }
106
+
107
+ /** The CPU its reaped children used, from `/proc/self/stat` (`cutime`, `cstime`); undefined off Linux. */
108
+ export function childrenCpuMs(
109
+ read: () => string = () => readFileSync('/proc/self/stat', 'utf8'),
110
+ ): number | undefined {
111
+ try {
112
+ const stat = read()
113
+ const f = stat.slice(stat.lastIndexOf(')') + 2).split(' ')
114
+ // USER_HZ is 100 on every Linux ABI.
115
+ return (Number(f[13]) + Number(f[14])) * 10
116
+ } catch {
117
+ // quiet: not Linux
118
+ return undefined
119
+ }
120
+ }
121
+
122
+ const cores = (ms: number, over: number): string => `${(ms / over).toFixed(2)} cores`
123
+
124
+ export function describeCpu(a: CpuAccount): string {
125
+ const over = Math.max(1, a.onlineMs)
126
+ const parts = [
127
+ `machine ${cores(a.machineMs, over)}`,
128
+ `tasks ${cores(a.tasksMs, over)}`,
129
+ `this worker ${cores(a.selfMs, over)}`,
130
+ ...(a.childrenMs === undefined ? [] : [`its other children ${cores(a.childrenMs, over)}`]),
131
+ ]
132
+ const rest = a.top.map((p) => `${p.name} ${cores(p.ms, over)}`).join(', ')
133
+ return `cpu over ${(over / 1000).toFixed(1)}s: ${parts.join(' · ')}${rest === '' ? '' : ` · most besides: ${rest}`}`
134
+ }
@@ -0,0 +1,268 @@
1
+ // Which worker each waiting task goes to now: one rule for the live run
2
+ // (`Orchestrator.pump`) and for the plan's play-out (`planner.ts`), so the
3
+ // plan sizes the run the way the run packs it.
4
+
5
+ import type { Strategy } from './strategy.js'
6
+
7
+ /**
8
+ * `ids` (waiting, in the order they got ready) in the order `how` takes
9
+ * them. `tail`: each one's longest chain after it, its own ms included.
10
+ */
11
+ export function ordered(
12
+ ids: readonly string[],
13
+ how: Strategy['order'],
14
+ tail: (id: string) => number,
15
+ need: (id: string) => Need,
16
+ cores: number,
17
+ ): string[] {
18
+ if (how === 'fifo') return [...ids]
19
+ const byTail = (x: string, y: string): number => tail(y) - tail(x)
20
+ if (how === 'critical-path') return [...ids].sort(byTail)
21
+ // Troublesome: within a quarter of the longest chain, or half a worker wide.
22
+ let longest = 0
23
+ for (const id of ids) longest = Math.max(longest, tail(id))
24
+ const hard = (id: string): boolean => tail(id) >= 0.75 * longest || need(id).cores >= cores / 2
25
+ return [...ids].sort((x, y) => Number(hard(y)) - Number(hard(x)) || byTail(x, y))
26
+ }
27
+
28
+ /** What a task asks of a worker: its history's cores and peak memory. */
29
+ export interface Need {
30
+ readonly cores: number
31
+ readonly memory: number
32
+ /** It filled a machine this big last time: only a bigger one takes it, if any worker is. */
33
+ readonly above?: { readonly cores: number; readonly memory: number } | undefined
34
+ /** The platform it runs on (`ExecuteRequest.platform`): only a worker of it takes it. */
35
+ readonly platform?: string | undefined
36
+ /**
37
+ * What it counts for when the plan sizes the run, when that is not what
38
+ * it books: an unmeasured task books next to nothing, so it goes to any
39
+ * free core, but a run of only such tasks still asks enough workers.
40
+ */
41
+ readonly ask?: { readonly cores: number; readonly memory: number } | undefined
42
+ }
43
+
44
+ /** A worker as dispatch sees it: its size and what is in use on it now. */
45
+ export interface Bin {
46
+ readonly id: string
47
+ readonly cores: number
48
+ readonly memory: number
49
+ /** Cores and memory in use: booked, or more when its load says more. */
50
+ readonly used: { cores: number; memory: number }
51
+ /** Tasks on it holding cores. */
52
+ running: number
53
+ /** Tasks whose outputs it holds. */
54
+ readonly has?: ReadonlySet<string>
55
+ /** Its machine's platform (`linux-x64`), as it reported or its size declares. */
56
+ readonly platform?: string | undefined
57
+ }
58
+
59
+ export interface Ask {
60
+ readonly id: string
61
+ readonly need: Need
62
+ /** Its dependencies, all the way up: a worker holding them is preferred. */
63
+ readonly up?: Iterable<string>
64
+ /** Names its need: asks of one kind fit and fail alike (`kindOf`). */
65
+ readonly kind?: string
66
+ /** The one bin that may take it: it holds files no cache does. */
67
+ readonly only?: string
68
+ /** A bin that may not take it. */
69
+ readonly avoid?: string
70
+ }
71
+
72
+ /** What `Ask.kind` names: every field of a need `assign` reads. */
73
+ export function kindOf(need: Need): string {
74
+ return `${need.cores}\0${need.memory}\0${need.above?.cores}\0${need.above?.memory}\0${need.platform}`
75
+ }
76
+
77
+ /**
78
+ * Whether `need` fits on `bin` now: alone on an empty one, whatever it
79
+ * asks; beside others only in full, cores and memory. Beside others on a
80
+ * free core alone, main's workers booked 4.3 of 4 (2026-10-23).
81
+ */
82
+ export function fits(bin: Bin, need: Need): boolean {
83
+ if (need.platform !== undefined && bin.platform !== need.platform) return false
84
+ if (bin.running === 0) return true
85
+ return (
86
+ need.cores < bin.cores &&
87
+ bin.used.cores + need.cores <= bin.cores + 1e-9 &&
88
+ bin.used.memory + need.memory <= bin.memory
89
+ )
90
+ }
91
+
92
+ /**
93
+ * Each of `asks`, in order, to the bin it fits now (`fits`): of those, the
94
+ * one holding most of its dependencies' outputs, then the one with fewest
95
+ * cores free, so workers fill before the next takes work and the rest
96
+ * fall idle and leave. A task bigger than every bin's last size goes to a
97
+ * bigger one only, unless none is or `waits(above, platform)` says one of its platform is coming.
98
+ * The first that fits nowhere but an empty bin holds the one using least: no task after it goes there, or later tasks fill it again and
99
+ * again (check.bun, the gate of 56 tasks, waited 50 s on main 871ffba).
100
+ * Bins' `used` and `running` count what is given. `all`, what the asks
101
+ * need all told (the least of each, and how many kinds), ends the pass
102
+ * once no bin takes the least or every kind failed since the last given:
103
+ * the rest change nothing.
104
+ */
105
+ export function assign<B extends Bin>(
106
+ asks: Iterable<Ask>,
107
+ bins: readonly B[],
108
+ waits: (above: NonNullable<Need['above']>, platform: string | undefined) => boolean = () => false,
109
+ place: Strategy['place'] = 'best-fit',
110
+ all?: {
111
+ readonly floor: { readonly cores: number; readonly memory: number }
112
+ readonly kinds: number
113
+ /** Where it keeps the kinds spent, for the asks to skip them as it reads. */
114
+ readonly spent?: Set<string>
115
+ /** Where it keeps the bins' bounds as it gives, for the asks to skip each `dead` one as it reads. */
116
+ readonly bounds?: Bounds
117
+ },
118
+ ): Map<string, B> {
119
+ const out = new Map<string, B>()
120
+ const held = new Set<B>()
121
+ const bounds = all?.bounds
122
+ const rebound = (): void => {
123
+ if (bounds !== undefined) boundsOf(bins, held, bounds, all?.floor)
124
+ }
125
+ rebound()
126
+ // Kinds that failed and held nothing since a bin was last given a task:
127
+ // until one is, each ask of them fails alike.
128
+ const spent = all?.spent ?? new Set<string>()
129
+ spent.clear()
130
+ for (const ask of asks) {
131
+ if (
132
+ all !== undefined &&
133
+ (spent.size >= all.kinds || !bins.some((b) => !held.has(b) && fits(b, all.floor)))
134
+ )
135
+ break
136
+ if (ask.kind !== undefined && spent.has(ask.kind)) continue
137
+ const { need } = ask
138
+ const bigger = (b: B): boolean =>
139
+ need.above === undefined || b.cores > need.above.cores || b.memory > need.above.memory
140
+ const allowed =
141
+ ask.only === undefined && ask.avoid === undefined
142
+ ? bins
143
+ : bins.filter((b) => (ask.only === undefined || b.id === ask.only) && b.id !== ask.avoid)
144
+ const of =
145
+ need.platform === undefined ? allowed : allowed.filter((b) => b.platform === need.platform)
146
+ const anyBigger = of.some(bigger)
147
+ if (!anyBigger && need.above !== undefined && waits(need.above, need.platform)) {
148
+ if (ask.kind !== undefined) spent.add(ask.kind)
149
+ continue
150
+ }
151
+ const pool = anyBigger ? of.filter(bigger) : of
152
+ const up = ask.up === undefined ? [] : [...ask.up]
153
+ let best: B | undefined
154
+ let bestScore: [number, number] = [0, 0]
155
+ for (const b of pool) {
156
+ if (held.has(b) || !fits(b, need)) continue
157
+ let has = 0
158
+ for (const id of up) if (b.has?.has(id) === true) has++
159
+ const score: [number, number] = [has, fit(b, need, place, pool.indexOf(b))]
160
+ if (
161
+ best === undefined ||
162
+ score[0] > bestScore[0] ||
163
+ (score[0] === bestScore[0] && score[1] > bestScore[1])
164
+ ) {
165
+ best = b
166
+ bestScore = score
167
+ }
168
+ }
169
+ if (best === undefined) {
170
+ // One it fits only empty: the one using least is held for it.
171
+ let soonest: B | undefined
172
+ for (const b of pool) {
173
+ if (held.has(b) || b.running === 0) continue
174
+ if (need.cores < b.cores && need.memory <= b.memory - b.used.memory) continue
175
+ if (soonest === undefined || b.used.cores < soonest.used.cores) soonest = b
176
+ }
177
+ if (soonest !== undefined) {
178
+ held.add(soonest)
179
+ rebound()
180
+ } else if (ask.kind !== undefined) spent.add(ask.kind)
181
+ continue
182
+ }
183
+ spent.clear()
184
+ out.set(ask.id, best)
185
+ best.used.cores += Math.min(need.cores, best.cores)
186
+ best.used.memory += Math.min(need.memory, best.memory)
187
+ best.running++
188
+ rebound()
189
+ }
190
+ return out
191
+ }
192
+
193
+ /**
194
+ * The bins not held, as an ask `dead` reads them: one asked of nowhere
195
+ * fits it and holds nothing, so `assign` only spends its kind. With every
196
+ * task its own kind, a pass read each ready one to learn that: 2.4 million
197
+ * asks for a plan of 9000 (2026-10-25).
198
+ */
199
+ export interface Bounds {
200
+ /** None takes the least any ask needs: the pass is over. */
201
+ done: boolean
202
+ /** One is empty: every ask fits it. */
203
+ open: boolean
204
+ /** The most cores free on one. */
205
+ free: number
206
+ /** The fewest cores one has. */
207
+ cores: number
208
+ /** The least memory free on one. */
209
+ memory: number
210
+ }
211
+
212
+ function boundsOf(
213
+ bins: readonly Bin[],
214
+ held: ReadonlySet<Bin>,
215
+ out: Bounds,
216
+ floor: Need | undefined,
217
+ ): void {
218
+ out.done = floor !== undefined
219
+ out.open = false
220
+ out.free = -Infinity
221
+ out.cores = Infinity
222
+ out.memory = Infinity
223
+ for (const b of bins) {
224
+ if (held.has(b)) continue
225
+ if (floor !== undefined && fits(b, floor)) out.done = false
226
+ if (b.running === 0) out.open = true
227
+ out.free = Math.max(out.free, b.cores - b.used.cores)
228
+ out.cores = Math.min(out.cores, b.cores)
229
+ out.memory = Math.min(out.memory, b.memory - b.used.memory)
230
+ }
231
+ }
232
+
233
+ /**
234
+ * True when `assign` would fit `need` on no bin and hold none for it, by
235
+ * `fits` and its hold rule: no bin is empty, and on each it is less than
236
+ * the bin, fits its memory, and wants more cores than are free.
237
+ */
238
+ export function dead(need: Need, b: Bounds): boolean {
239
+ return (
240
+ need.platform === undefined &&
241
+ need.above === undefined &&
242
+ !b.open &&
243
+ need.cores > b.free + 1e-9 &&
244
+ need.cores < b.cores &&
245
+ need.memory <= b.memory
246
+ )
247
+ }
248
+
249
+ /** How well `need` suits `b` by `place`: the higher, the better. */
250
+ function fit(b: Bin, need: Need, place: Strategy['place'], index: number): number {
251
+ switch (place) {
252
+ case 'best-fit':
253
+ return b.used.cores - b.cores
254
+ case 'worst-fit':
255
+ return b.cores - b.used.cores
256
+ case 'first-fit':
257
+ return -index
258
+ case 'tetris': {
259
+ // The dot product of the need and what is free, each as a share of
260
+ // the worker: a task goes where its shape matches the hole.
261
+ const cores = Math.min(need.cores, b.cores) / b.cores
262
+ const memory = b.memory > 0 ? Math.min(need.memory, b.memory) / b.memory : 0
263
+ const freeCores = (b.cores - b.used.cores) / b.cores
264
+ const freeMemory = b.memory > 0 ? (b.memory - b.used.memory) / b.memory : 0
265
+ return cores * freeCores + memory * freeMemory
266
+ }
267
+ }
268
+ }
package/src/docker.ts ADDED
@@ -0,0 +1,156 @@
1
+ // `docker()`: each worker a container on this machine, held to its size's
2
+ // cores and memory, in a fresh clone made inside it of the tracked files
3
+ // as the run sees them, sharing this machine's store. The relay
4
+ // is the run's own on loopback; a container reaches it through the host's
5
+ // network on Linux and through `host.docker.internal` elsewhere.
6
+
7
+ import { mkdirSync, realpathSync } from 'node:fs'
8
+ import { homedir } from 'node:os'
9
+ import path from 'node:path'
10
+ import { refuseUnknownOptions, UserError, type PluginOptionKinds } from '@vzn/vx'
11
+ import { loopbackRelay, reasons } from './local.js'
12
+ import type { DteAdapter } from './plugin.js'
13
+
14
+ export interface DockerOptions {
15
+ /** An image with Bun and git, such as one built `FROM oven/bun` with git added. */
16
+ readonly image: string
17
+ /** A shell line the clone runs before its worker starts, such as `bun install`. */
18
+ readonly prepare?: string
19
+ }
20
+
21
+ const KEYS: PluginOptionKinds<DockerOptions> = { image: 'string', prepare: 'string' }
22
+
23
+ export function docker(options: DockerOptions): DteAdapter {
24
+ refuseUnknownOptions('docker()', options, KEYS)
25
+ // A bare `docker()` read `options.image` off undefined: a TypeError, a crash.
26
+ if (typeof options?.image !== 'string' || options.image === '') {
27
+ throw new UserError(
28
+ "docker() needs image: one with Bun and git, such as 'oven/bun:1.4' plus git",
29
+ )
30
+ }
31
+ let relay: { url: string; stop(): void } | undefined
32
+ const workers = new Map<string, ReturnType<typeof Bun.spawn>>()
33
+ const linux = process.platform === 'linux'
34
+ let closed = false
35
+ const why = reasons()
36
+
37
+ return {
38
+ // Each run asks for one (`vx watch` runs again): a new run, on the tree as it is now.
39
+ async relay() {
40
+ closed = false
41
+ relay = await loopbackRelay()
42
+ return relay.url
43
+ },
44
+
45
+ async spawn(a) {
46
+ const git = (...args: string[]): string => {
47
+ const r = Bun.spawnSync(['git', ...args], { cwd: a.workspaceRoot, stderr: 'pipe' })
48
+ if (r.exitCode !== 0) throw new Error(`git ${args[0]}: ${r.stderr.toString().trim()}`)
49
+ return r.stdout.toString().trim()
50
+ }
51
+ const top = git('rev-parse', '--show-toplevel')
52
+ // The tracked files as the run saw them at its start, uncommitted
53
+ // changes too: a clone holds only refs, so the commit is fetched by its id.
54
+ const sha = a.commit
55
+ // git names the real path: through a link (macOS's /tmp) the relative one climbed out.
56
+ const rel = path.relative(top, realpathSync(a.workspaceRoot)) || '.'
57
+ // The store is keyed by the repository's identity, its origin: the
58
+ // clone's would be /src, a store of its own the run never reads.
59
+ const origin = Bun.spawnSync(['git', 'remote', 'get-url', 'origin'], {
60
+ cwd: a.workspaceRoot,
61
+ stderr: 'ignore',
62
+ })
63
+ .stdout.toString()
64
+ .trim()
65
+ // On Linux a container's root writes the store as root; as this user
66
+ // it has no home but /tmp, where the store is mounted instead.
67
+ const uid = process.getuid?.() ?? 0
68
+ const asUser = linux && uid !== 0
69
+ const work = asUser ? '/tmp/work' : '/work'
70
+ const script = [
71
+ 'set -e',
72
+ `git clone -q /src ${work}`,
73
+ `cd ${work}`,
74
+ origin === '' ? 'git remote remove origin' : `git remote set-url origin ${quote(origin)}`,
75
+ `git fetch -q --upload-pack='git -c uploadpack.allowAnySHA1InWant=true upload-pack' /src ${sha}`,
76
+ `git checkout -q --detach ${sha}`,
77
+ `cd ${quote(rel)}`,
78
+ // On stderr, which names why a container failed: pnpm prints an install's error on stdout.
79
+ ...(options.prepare === undefined ? [] : ['{', options.prepare, '} >&2']),
80
+ 'exec ./node_modules/.bin/vx dte worker',
81
+ ].join('\n')
82
+ // This machine's store and cache dir at their own paths, as local()'s
83
+ // workers share them: what a worker saves, the run reads.
84
+ const home = process.env['HOME'] || homedir()
85
+ const store = path.join(home, '.vx')
86
+ mkdirSync(store, { recursive: true })
87
+ const mounts = [store]
88
+ const cacheEnv: Record<string, string> = {}
89
+ if (a.cacheDir !== undefined) {
90
+ mkdirSync(a.cacheDir, { recursive: true })
91
+ mounts.push(a.cacheDir)
92
+ cacheEnv['VX_CACHE_DIR'] = a.cacheDir
93
+ }
94
+ const inHome = asUser ? '/tmp' : home
95
+ const env = {
96
+ ...a.env,
97
+ HOME: inHome,
98
+ ...cacheEnv,
99
+ ...(linux ? {} : { VX_DTE_RELAY: a.relay.replace('127.0.0.1', 'host.docker.internal') }),
100
+ // The checkout is the host's, owned by another user than the container's.
101
+ GIT_CONFIG_COUNT: '1',
102
+ GIT_CONFIG_KEY_0: 'safe.directory',
103
+ GIT_CONFIG_VALUE_0: '*',
104
+ }
105
+ const name = `vx-dte-${process.pid}-${a.index}`
106
+ const proc = Bun.spawn(
107
+ [
108
+ 'docker',
109
+ 'run',
110
+ '--rm',
111
+ '--name',
112
+ name,
113
+ '--cpus',
114
+ String(a.cpu),
115
+ '--memory',
116
+ `${a.memory}b`,
117
+ ...(linux ? ['--network', 'host'] : []),
118
+ '--volume',
119
+ `${top}:/src:ro`,
120
+ ...(asUser ? ['--user', `${uid}:${process.getgid?.() ?? uid}`] : []),
121
+ ...mounts.flatMap((m) => [
122
+ '--volume',
123
+ `${m}:${m === store ? path.join(inHome, '.vx') : m}`,
124
+ ]),
125
+ ...Object.keys(env).flatMap((k) => ['--env', k]),
126
+ options.image,
127
+ 'sh',
128
+ '-c',
129
+ script,
130
+ ],
131
+ { env: { ...process.env, ...env }, stdin: 'ignore', stdout: 'ignore', stderr: 'pipe' },
132
+ )
133
+ workers.set(name, proc)
134
+ void (async () => {
135
+ const [err, code] = await Promise.all([new Response(proc.stderr).text(), proc.exited])
136
+ workers.delete(name)
137
+ if (!closed) a.exited(await why(`exited ${code}`, a.id, err))
138
+ })()
139
+ },
140
+
141
+ async close() {
142
+ closed = true
143
+ // The relay closing the run tells each worker to leave; a container
144
+ // still up after the grace is removed.
145
+ relay?.stop()
146
+ relay = undefined
147
+ const left = [...workers]
148
+ await Promise.race([Promise.all(left.map(([, p]) => p.exited)), Bun.sleep(2_000)])
149
+ const up = left.filter(([name]) => workers.has(name)).map(([name]) => name)
150
+ if (up.length > 0)
151
+ Bun.spawnSync(['docker', 'rm', '-f', ...up], { stdout: 'ignore', stderr: 'ignore' })
152
+ },
153
+ }
154
+ }
155
+
156
+ const quote = (s: string): string => `'${s.replaceAll("'", `'\\''`)}'`
@@ -0,0 +1,68 @@
1
+ // Which cached tasks will miss, guessed before any worker can say: those
2
+ // whose inputs the last commit changed, and every task after one, its key
3
+ // folding theirs. The run learns a task missed only once a worker keys it,
4
+ // 20 s in: main a09b987's plan saw its 56 shards only then, and ended
5
+ // 14 s after the best (tests/sim.test.ts). The guess only sizes the run: a
6
+ // hit it names costs a worker the plan cancels, a miss it does not names
7
+ // the wait the run always had.
8
+
9
+ import type { TaskNode } from '@vzn/vx'
10
+
11
+ /** Root files every key reads: a lockfile, the workspace config. */
12
+ const WHOLE = /^(?:\.vx\/|(?:bun\.lockb?|pnpm-lock\.yaml|package-lock\.json|yarn\.lock)$)/
13
+ const GLOB = /[*?[{]/
14
+
15
+ /** Whether `rel` is one of `patterns`' files: a literal names a file or its tree; `!` entries are left out, so it errs to a miss. */
16
+ function matches(patterns: readonly string[] | undefined, rel: string): boolean {
17
+ for (const raw of patterns ?? []) {
18
+ if (raw.startsWith('!')) continue
19
+ const p = raw.replace(/^\.\//, '').replace(/\/$/, '')
20
+ if (GLOB.test(p) ? new Bun.Glob(p).match(rel) : rel === p || rel.startsWith(`${p}/`))
21
+ return true
22
+ }
23
+ return false
24
+ }
25
+
26
+ /**
27
+ * The cached tasks of `nodes` that `changed` (workspace-relative paths)
28
+ * makes miss: a task whose `inputs` match one, whose project's
29
+ * `package.json` or config is one, or after such a task.
30
+ */
31
+ export function likelyMisses(
32
+ nodes: ReadonlyMap<string, TaskNode>,
33
+ changed: readonly string[],
34
+ workspaceRoot: string,
35
+ ): Set<string> {
36
+ const out = new Set<string>()
37
+ if (changed.length === 0) return out
38
+ const whole = changed.some((p) => WHOLE.test(p))
39
+ const root = workspaceRoot.endsWith('/') ? workspaceRoot : `${workspaceRoot}/`
40
+ const own = (n: TaskNode): boolean => {
41
+ const inputs = n.config.inputs
42
+ if (inputs === undefined) return false
43
+ if (whole) return true
44
+ const dir = n.projectDir.startsWith(root) ? n.projectDir.slice(root.length) : ''
45
+ for (const p of changed) {
46
+ if (matches(inputs.workspaceFiles, p)) return true
47
+ if (dir !== '' && !p.startsWith(`${dir}/`)) continue
48
+ const rel = dir === '' ? p : p.slice(dir.length + 1)
49
+ if (rel === 'package.json' || /^vx\.config\.[cm]?[jt]s$/.test(rel)) return true
50
+ if (matches(inputs.files, rel)) return true
51
+ }
52
+ return false
53
+ }
54
+ const keyed = new Map<string, boolean>()
55
+ const changes = (id: string): boolean => {
56
+ const known = keyed.get(id)
57
+ if (known !== undefined) return known
58
+ keyed.set(id, false)
59
+ const n = nodes.get(id)
60
+ const c = n !== undefined && (own(n) || n.deps.some(changes))
61
+ keyed.set(id, c)
62
+ return c
63
+ }
64
+ for (const [id, n] of nodes)
65
+ if (n.config.exec?.command !== undefined && n.config.cache !== undefined && changes(id))
66
+ out.add(id)
67
+ return out
68
+ }