@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/src/index.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  export {
2
2
  dte,
3
3
  type WorkerSize,
4
+ type WorkerType,
4
5
  type WorkerSpawn,
5
6
  type DteAdapter,
6
7
  type DteOptions,
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
- const url = r.exitCode === 0 ? r.stdout.toString().trim() : ''
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?:\/\/(?:[^@/]*@)?([^/]+)/.exec(url)?.[1] ??
53
- /^[a-z+]+:\/\/(?:[^@/]*@)?([^/:]+)/.exec(url)?.[1] ??
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
- const trees: Array<{ root: string; dir: string }> = []
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 ??= mkdtemp(path.join(os.tmpdir(), 'vx-dte-'))
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
- const grace = setTimeout(() => {
141
- for (const p of procs) {
142
- if (helpers.has(p)) group(p, 'SIGKILL')
143
- else p.kill('SIGKILL')
144
- }
145
- }, 5_000)
146
- await Promise.allSettled([...pending])
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
- Bun.spawn(
157
- [
158
- 'sh',
159
- '-c',
160
- 'rm -rf "$1"; shift; for r; do git -C "$r" worktree prune; done',
161
- 'sh',
162
- dir,
163
- ...roots,
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: RunOrder[]
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: DepOrder[] }
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 its key includes.
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',