@vzn/vx-dte 0.0.651 → 0.0.653
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/README.md +164 -60
- package/package.json +3 -3
- package/src/dispatch.ts +540 -30
- package/src/forecast.ts +1 -2
- package/src/github.ts +0 -3
- package/src/index.ts +1 -0
- package/src/init.ts +9 -10
- package/src/local.ts +112 -28
- package/src/messages.ts +26 -5
- package/src/orchestrator.ts +678 -233
- package/src/planner.ts +1470 -192
- package/src/plugin.ts +242 -33
- package/src/seal.ts +159 -43
- package/src/server-fit.ts +80 -0
- package/src/tree.ts +215 -7
- package/src/usage.ts +65 -3
- package/src/warm-ups.ts +72 -6
- package/src/wire.ts +119 -49
- package/src/worker.ts +174 -19
package/src/index.ts
CHANGED
package/src/init.ts
CHANGED
|
@@ -25,6 +25,8 @@ export interface DteSetup {
|
|
|
25
25
|
/** Absent: as many as the work needs. */
|
|
26
26
|
readonly max?: number
|
|
27
27
|
readonly platform?: Platform
|
|
28
|
+
/** The runner label its machine names (`WorkerSize.runsOn`). */
|
|
29
|
+
readonly runsOn?: string
|
|
28
30
|
}[]
|
|
29
31
|
/** The workspace's package manager, by its lockfile. */
|
|
30
32
|
readonly manager: PackageManager
|
|
@@ -45,12 +47,14 @@ export interface DteInit {
|
|
|
45
47
|
/** `owner/repo` of the `origin` remote and its host: what the relay's ALLOW names. */
|
|
46
48
|
export function originOf(root: string): { repository?: string; host?: string } {
|
|
47
49
|
const r = Bun.spawnSync(['git', 'config', '--get', 'remote.origin.url'], { cwd: root })
|
|
48
|
-
|
|
50
|
+
// No credential reaches the written files: a query (`?private_token=`)
|
|
51
|
+
// is dropped, and a URL's userinfo ends at its last `@` (a password may hold one).
|
|
52
|
+
const url = (r.exitCode === 0 ? r.stdout.toString().trim() : '').replace(/[?#].*$/s, '')
|
|
49
53
|
const m = /[:/]([^:]+?\/[^/]+?)(?:\.git)?\/?$/.exec(url.replace(/^[a-z+]+:\/\/[^/]+\//, '/'))
|
|
50
54
|
// An https URL's port is its issuer's; an ssh one's is not.
|
|
51
55
|
const host =
|
|
52
|
-
/^https?:\/\/(?:[
|
|
53
|
-
/^[a-z+]+:\/\/(?:[
|
|
56
|
+
/^https?:\/\/(?:[^/]*@)?([^/]+)/.exec(url)?.[1] ??
|
|
57
|
+
/^[a-z+]+:\/\/(?:[^/]*@)?([^/:]+)/.exec(url)?.[1] ??
|
|
54
58
|
/^(?:[^@/:]*@)?([^/:]+):/.exec(url)?.[1]
|
|
55
59
|
return {
|
|
56
60
|
...(m?.[1] === undefined ? {} : { repository: m[1] }),
|
|
@@ -249,12 +253,7 @@ const RUNNERS: Record<Platform, string> = {
|
|
|
249
253
|
export function githubInit(options: GithubOptions, setup: DteSetup): DteInit {
|
|
250
254
|
const workflow = options.workflow ?? 'vx-worker.yml'
|
|
251
255
|
const runners = Object.fromEntries(
|
|
252
|
-
setup.sizes.map((s) =>
|
|
253
|
-
const label: unknown = options.runsOn?.[s.name]
|
|
254
|
-
if (label !== undefined && (typeof label !== 'string' || label === ''))
|
|
255
|
-
throw new UserError(`vx dte init: github() runsOn.${s.name} is a runner label`)
|
|
256
|
-
return [s.name, label ?? RUNNERS[s.platform ?? 'linux-x64']]
|
|
257
|
-
}),
|
|
256
|
+
setup.sizes.map((s) => [s.name, s.runsOn ?? RUNNERS[s.platform ?? 'linux-x64']]),
|
|
258
257
|
)
|
|
259
258
|
// A workspace in a subdirectory: CI files live at the repository's root,
|
|
260
259
|
// and their commands run in the workspace.
|
|
@@ -448,7 +447,7 @@ export function gitlabInit(options: GitlabOptions, setup: DteSetup): DteInit {
|
|
|
448
447
|
// A matrix takes at most 200 jobs; a wave picks its slots.
|
|
449
448
|
if (s.max === undefined || s.max < 1 || s.max > 200)
|
|
450
449
|
throw new UserError(
|
|
451
|
-
`vx dte init: gitlab() needs workers.${s.name}.max, 1 to 200: its job is a matrix of that many slots`,
|
|
450
|
+
`vx dte init: gitlab() needs workers.${s.name.replace('.', '.machines.')}.max, 1 to 200: its job is a matrix of that many slots`,
|
|
452
451
|
)
|
|
453
452
|
const slots = Array.from({ length: s.max }, (_, i) => quoted(String(i + 1)))
|
|
454
453
|
return `
|
package/src/local.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// loopback. For tests, demos
|
|
4
4
|
// and trying a distributed run before a CI starts the workers.
|
|
5
5
|
|
|
6
|
-
import { mkdtemp, writeFile } from 'node:fs/promises'
|
|
6
|
+
import { mkdtemp, readFile, writeFile } from 'node:fs/promises'
|
|
7
7
|
import os from 'node:os'
|
|
8
8
|
import path from 'node:path'
|
|
9
9
|
import { refuseUnknownOptions, type PluginOptionKinds } from '@vzn/vx'
|
|
@@ -24,7 +24,8 @@ export function local(options: LocalOptions = {}): DteAdapter {
|
|
|
24
24
|
const procs = new Set<Bun.Subprocess>()
|
|
25
25
|
/** A worktree add or a prepare: what close stops at once. */
|
|
26
26
|
const helpers = new Set<Bun.Subprocess>()
|
|
27
|
-
|
|
27
|
+
/** The repositories this run adds worktrees to, whose records close prunes. */
|
|
28
|
+
const roots = new Set<string>()
|
|
28
29
|
const pending = new Set<Promise<unknown>>()
|
|
29
30
|
|
|
30
31
|
// Async throughout: a run starts its workers while it shows its first rows.
|
|
@@ -83,7 +84,14 @@ export function local(options: LocalOptions = {}): DteAdapter {
|
|
|
83
84
|
|
|
84
85
|
async spawn(a) {
|
|
85
86
|
const work = (async () => {
|
|
86
|
-
base ??=
|
|
87
|
+
base ??= (async () => {
|
|
88
|
+
const d = await mkdtemp(path.join(os.tmpdir(), BASE_PREFIX))
|
|
89
|
+
// Whose they are, for a later run to reclaim once this vx is gone.
|
|
90
|
+
await writeFile(path.join(d, OWNER), String(process.pid))
|
|
91
|
+
void reclaim(a.workspaceRoot)
|
|
92
|
+
return d
|
|
93
|
+
})()
|
|
94
|
+
roots.add(a.workspaceRoot)
|
|
87
95
|
const dir = path.join(await base, String(a.index))
|
|
88
96
|
if (closed) return
|
|
89
97
|
// The run's own snapshot: one taken here, after a hit restored a
|
|
@@ -95,7 +103,6 @@ export function local(options: LocalOptions = {}): DteAdapter {
|
|
|
95
103
|
{ ...process.env, GIT_INDEX_FILE: undefined },
|
|
96
104
|
)
|
|
97
105
|
if (add.code !== 0) throw new Error(`git worktree add: ${errorLine(add.err)}`)
|
|
98
|
-
trees.push({ root: a.workspaceRoot, dir })
|
|
99
106
|
// The workspace's own place in the checkout: from the worktree's
|
|
100
107
|
// root, a workspace in a subdirectory of its repository had no
|
|
101
108
|
// `.vx/workspace` above, and every worker exited (2026-10-25).
|
|
@@ -137,38 +144,115 @@ export function local(options: LocalOptions = {}): DteAdapter {
|
|
|
137
144
|
relay?.stop()
|
|
138
145
|
relay = undefined
|
|
139
146
|
for (const p of helpers) group(p, 'SIGTERM')
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
await Promise.all([...procs].map((p) => p.exited))
|
|
148
|
-
clearTimeout(grace)
|
|
149
|
-
// A detached shell removes the worktrees after vx exits: removing ones
|
|
150
|
-
// an install filled took seconds after a Ctrl-C (2026-10-21), and a
|
|
151
|
-
// 300k-file tree 2.7 s. `prune` then drops git's record of each.
|
|
152
|
-
const roots = [...new Set(trees.splice(0).map((t) => t.root))]
|
|
147
|
+
// A detached shell waits for what this run started, kills what is
|
|
148
|
+
// left after 5 s, then removes the worktrees, after vx exits:
|
|
149
|
+
// removing ones an install filled took seconds after a Ctrl-C
|
|
150
|
+
// (2026-10-21), and a 300k-file tree 2.7 s. Spawned first: core stops
|
|
151
|
+
// waiting on a teardown after 3 s, and a worker slow to leave kept
|
|
152
|
+
// close from spawning it before vx exited, so every worktree stayed
|
|
153
|
+
// (2026-11-08). `prune` then drops git's record of each.
|
|
153
154
|
if (base !== undefined) {
|
|
154
155
|
const dir = await base
|
|
155
156
|
base = undefined
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
{ stdio: ['ignore', 'ignore', 'ignore'], detached: true },
|
|
166
|
-
).unref()
|
|
157
|
+
const helping = [...helpers].map((p) => p.pid).join(' ')
|
|
158
|
+
const working = [...procs]
|
|
159
|
+
.filter((p) => !helpers.has(p))
|
|
160
|
+
.map((p) => p.pid)
|
|
161
|
+
.join(' ')
|
|
162
|
+
Bun.spawn(['sh', '-c', CLEANUP, 'sh', dir, helping, working, ...roots], {
|
|
163
|
+
stdio: ['ignore', 'ignore', 'ignore'],
|
|
164
|
+
detached: true,
|
|
165
|
+
}).unref()
|
|
167
166
|
}
|
|
167
|
+
roots.clear()
|
|
168
|
+
// Bounded under core's deadline: what still runs after it is the shell's.
|
|
169
|
+
await Promise.race([
|
|
170
|
+
Promise.allSettled([...pending, ...[...procs].map((p) => p.exited)]),
|
|
171
|
+
Bun.sleep(2_000),
|
|
172
|
+
])
|
|
168
173
|
},
|
|
169
174
|
}
|
|
170
175
|
}
|
|
171
176
|
|
|
177
|
+
/**
|
|
178
|
+
* `sh -c CLEANUP sh <base> <helper pids> <worker pids> <roots…>`: waits up
|
|
179
|
+
* to 5 s for the pids, kills each left (a helper's whole group), removes
|
|
180
|
+
* the base and prunes each root's worktree records.
|
|
181
|
+
*/
|
|
182
|
+
const CLEANUP = `d=$1 h=$2 w=$3; shift 3
|
|
183
|
+
i=0
|
|
184
|
+
while [ $i -lt 50 ]; do
|
|
185
|
+
up=; for p in $h $w; do kill -0 $p 2>/dev/null && up=1; done
|
|
186
|
+
[ -z "$up" ] && break
|
|
187
|
+
sleep 0.1; i=$((i+1))
|
|
188
|
+
done
|
|
189
|
+
for p in $h; do kill -9 -$p 2>/dev/null; done
|
|
190
|
+
for p in $w; do kill -9 $p 2>/dev/null; done
|
|
191
|
+
rm -rf "$d"; for r; do git -C "$r" worktree prune; done`
|
|
192
|
+
|
|
193
|
+
const BASE_PREFIX = 'vx-dte-'
|
|
194
|
+
const OWNER = 'owner'
|
|
195
|
+
/** `mkdtemp`'s name: the prefix and six characters. */
|
|
196
|
+
const OWN_BASE = new RegExp(`^${BASE_PREFIX}[A-Za-z0-9]{6}$`)
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* The worktrees a run whose vx was killed (SIGKILL, a lost machine) left in
|
|
200
|
+
* `root`'s repository, removed: each under a base whose owner process is
|
|
201
|
+
* gone. A killed run's close never ran, and they stayed for good
|
|
202
|
+
* (2026-11-06). One `git worktree list`, off the run's path.
|
|
203
|
+
*/
|
|
204
|
+
async function reclaim(root: string): Promise<void> {
|
|
205
|
+
try {
|
|
206
|
+
const env: Record<string, string | undefined> = { ...process.env }
|
|
207
|
+
for (const name of GIT_REPO_ENV) delete env[name]
|
|
208
|
+
const list = Bun.spawn(['git', 'worktree', 'list', '--porcelain'], {
|
|
209
|
+
cwd: root,
|
|
210
|
+
env,
|
|
211
|
+
stdin: 'ignore',
|
|
212
|
+
stdout: 'pipe',
|
|
213
|
+
stderr: 'ignore',
|
|
214
|
+
})
|
|
215
|
+
const bases = new Set<string>()
|
|
216
|
+
for (const line of (await new Response(list.stdout).text()).split('\n')) {
|
|
217
|
+
if (!line.startsWith('worktree ')) continue
|
|
218
|
+
const dir = line.slice('worktree '.length)
|
|
219
|
+
const base = path.dirname(dir)
|
|
220
|
+
if (/^\d+$/.test(path.basename(dir)) && OWN_BASE.test(path.basename(base))) bases.add(base)
|
|
221
|
+
}
|
|
222
|
+
const gone: string[] = []
|
|
223
|
+
for (const base of bases) {
|
|
224
|
+
const pid = Number(
|
|
225
|
+
await readFile(path.join(base, OWNER), 'utf8').catch(
|
|
226
|
+
// quiet: a base with no owner is not known to be gone
|
|
227
|
+
() => '',
|
|
228
|
+
),
|
|
229
|
+
)
|
|
230
|
+
if (Number.isInteger(pid) && pid > 0 && !alive(pid)) gone.push(base)
|
|
231
|
+
}
|
|
232
|
+
if (gone.length === 0) return
|
|
233
|
+
Bun.spawn(
|
|
234
|
+
['sh', '-c', 'r=$1; shift; rm -rf "$@"; git -C "$r" worktree prune', 'sh', root, ...gone],
|
|
235
|
+
{
|
|
236
|
+
env,
|
|
237
|
+
stdio: ['ignore', 'ignore', 'ignore'],
|
|
238
|
+
detached: true,
|
|
239
|
+
},
|
|
240
|
+
).unref()
|
|
241
|
+
} catch {
|
|
242
|
+
// quiet: what is left is reclaimed by the next run
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function alive(pid: number): boolean {
|
|
247
|
+
try {
|
|
248
|
+
process.kill(pid, 0)
|
|
249
|
+
return true
|
|
250
|
+
} catch (err) {
|
|
251
|
+
// Another user's process is alive too.
|
|
252
|
+
return (err as NodeJS.ErrnoException).code === 'EPERM'
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
172
256
|
/** Signal `p`'s process group: a helper leads its own (`detached`). */
|
|
173
257
|
function group(p: Bun.Subprocess, signal: NodeJS.Signals): void {
|
|
174
258
|
try {
|
package/src/messages.ts
CHANGED
|
@@ -54,6 +54,8 @@ export interface RunOrder {
|
|
|
54
54
|
cwd: string
|
|
55
55
|
project: string
|
|
56
56
|
env: Record<string, string>
|
|
57
|
+
/** `HOST_ENV` names its key folds as unset: the worker leaves them unset, not its own. */
|
|
58
|
+
unset?: string[]
|
|
57
59
|
/** Its `exec.env.secret`: the names in `env` whose values its saved output masks. */
|
|
58
60
|
secret?: string[]
|
|
59
61
|
timeoutMs?: number
|
|
@@ -64,15 +66,26 @@ export interface RunOrder {
|
|
|
64
66
|
/**
|
|
65
67
|
* Its `exec.sandbox`, and for a cached task the projects its key answers
|
|
66
68
|
* for (workspace-relative): the worker builds the request on its own
|
|
67
|
-
* paths and OS (`sandboxRequestFor`), as its orchestrator may run another
|
|
69
|
+
* paths and OS (`sandboxRequestFor`), as its orchestrator may run another;
|
|
70
|
+
* `reads`, the dependencies' outputs it may read, and `reported`, the
|
|
71
|
+
* heads a refused read under is reported (workspace-relative).
|
|
68
72
|
*/
|
|
69
|
-
sandbox?: { config: SandboxConfig; keyed?: string[] }
|
|
73
|
+
sandbox?: { config: SandboxConfig; keyed?: string[]; reads?: string[]; reported?: string[] }
|
|
70
74
|
/** Its persistent dependencies (`ServerSpec`), `cwd` workspace-relative: the worker starts each once. */
|
|
71
75
|
servers?: ServerOrder[]
|
|
72
76
|
/** The runtime probes its key includes, as the orchestrator answered them: the worker's must answer the same. */
|
|
73
77
|
runtime?: RuntimeOrder[]
|
|
74
78
|
}
|
|
75
79
|
|
|
80
|
+
/**
|
|
81
|
+
* A dependency as an order carries it: whole the first time its worker is
|
|
82
|
+
* sent it under that key, then by its task's name alone.
|
|
83
|
+
*/
|
|
84
|
+
export type WiredDep = DepOrder | string
|
|
85
|
+
|
|
86
|
+
/** A `RunOrder` as it travels: its deps `WiredDep`s, made whole again by the worker. */
|
|
87
|
+
export type SentOrder = Omit<RunOrder, 'deps'> & { deps: WiredDep[] }
|
|
88
|
+
|
|
76
89
|
export interface RuntimeOrder {
|
|
77
90
|
command: string
|
|
78
91
|
output: string
|
|
@@ -102,13 +115,15 @@ export type ToWorker =
|
|
|
102
115
|
root: string
|
|
103
116
|
/** `root`'s real path, as sandbox paths spell it. */
|
|
104
117
|
realRoot: string
|
|
105
|
-
tasks:
|
|
118
|
+
tasks: SentOrder[]
|
|
106
119
|
/** The run's sandboxed tasks' `exec.sandbox`, in a worker's first batch that has a sandboxed task. */
|
|
107
120
|
sandboxes?: SandboxConfig[]
|
|
108
121
|
}
|
|
109
122
|
/** Part of an order's `deps`, ahead of the batch whose order was too long for one frame. */
|
|
110
|
-
| { type: 'deps'; task: string; deps:
|
|
123
|
+
| { type: 'deps'; task: string; deps: WiredDep[] }
|
|
111
124
|
| { type: 'cancel'; task: string }
|
|
125
|
+
/** No task left in the run uses persistent task `task`: it stops here. */
|
|
126
|
+
| { type: 'unserve'; task: string }
|
|
112
127
|
/**
|
|
113
128
|
* It was taken into the run, under the name the run's output gives it,
|
|
114
129
|
* and reads the cache as the run does: its refs, and the access the
|
|
@@ -142,8 +157,12 @@ export interface Load {
|
|
|
142
157
|
* `cpu` (one that sent 3, each as a task started, read 0.6 of 2.0 used).
|
|
143
158
|
*/
|
|
144
159
|
readonly avg?: number
|
|
160
|
+
/** Cores the worker's own process used on average since it started: what the run's bookkeeping costs it. */
|
|
161
|
+
readonly self?: number
|
|
145
162
|
/** When it was read, by the worker's clock: its task times are read against it. */
|
|
146
163
|
readonly at?: number
|
|
164
|
+
/** Each server it runs, its base as fitted (`server-fit.ts`): cores it uses with no dependant's work. */
|
|
165
|
+
readonly servers?: Record<string, number>
|
|
147
166
|
}
|
|
148
167
|
|
|
149
168
|
/** A task's times on its worker, epoch ms by the worker's clock: begun, command ended. */
|
|
@@ -209,7 +228,9 @@ export type WiredResult = Omit<ExecuteResult, 'violations' | 'outputs' | 'stdout
|
|
|
209
228
|
|
|
210
229
|
/**
|
|
211
230
|
* Names whose value is the worker's own: the orchestrator sends one only
|
|
212
|
-
* when a task's `env.define` sets it, a value
|
|
231
|
+
* when a task's `env.define` sets it or its `inputs.env` keys it, a value
|
|
232
|
+
* its key includes; a keyed one unset there is unset on the worker too
|
|
233
|
+
* (`RunOrder.unset`).
|
|
213
234
|
*/
|
|
214
235
|
export const HOST_ENV = [
|
|
215
236
|
'HOME',
|