@vzn/vx-dte 0.0.0 → 0.0.650

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/local.ts ADDED
@@ -0,0 +1,256 @@
1
+ // `local()`: workers as processes on this machine, each in its own git
2
+ // worktree of the working tree's tracked state, and a relay of its own on
3
+ // loopback. For tests, demos
4
+ // and trying a distributed run before a CI starts the workers.
5
+
6
+ import { mkdtemp, writeFile } from 'node:fs/promises'
7
+ import os from 'node:os'
8
+ import path from 'node:path'
9
+ import { refuseUnknownOptions, type PluginOptionKinds } from '@vzn/vx'
10
+ import type { DteAdapter } from './plugin.js'
11
+
12
+ export interface LocalOptions {
13
+ /** A shell line each worktree runs before its worker starts, such as `bun install`. */
14
+ readonly prepare?: string
15
+ }
16
+
17
+ const KEYS: PluginOptionKinds<LocalOptions> = { prepare: 'string' }
18
+
19
+ export function local(options: LocalOptions = {}): DteAdapter {
20
+ refuseUnknownOptions('local()', options, KEYS)
21
+ let relay: { url: string; stop(): void } | undefined
22
+ let base: Promise<string> | undefined
23
+ let closed = false
24
+ const procs = new Set<Bun.Subprocess>()
25
+ /** A worktree add or a prepare: what close stops at once. */
26
+ const helpers = new Set<Bun.Subprocess>()
27
+ const trees: Array<{ root: string; dir: string }> = []
28
+ const pending = new Set<Promise<unknown>>()
29
+
30
+ // Async throughout: a run starts its workers while it shows its first rows.
31
+ const start = <P extends Bun.Subprocess>(p: P): P => {
32
+ procs.add(p)
33
+ void p.exited.then(() => procs.delete(p))
34
+ return p
35
+ }
36
+ const run = async (
37
+ argv: string[],
38
+ cwd: string,
39
+ env?: Record<string, string | undefined>,
40
+ helper = true,
41
+ // A prepare's reason can be on stdout: pnpm prints its install errors there.
42
+ stdout = false,
43
+ ) => {
44
+ const p = start(
45
+ Bun.spawn(argv, {
46
+ cwd,
47
+ env: env ?? process.env,
48
+ stdin: 'ignore',
49
+ stdout: stdout ? 'pipe' : 'ignore',
50
+ stderr: 'pipe',
51
+ // Its own group, which close signals whole: SIGTERM to the shell
52
+ // alone left the install it started writing into a removed tree.
53
+ detached: helper,
54
+ }),
55
+ )
56
+ if (helper) {
57
+ helpers.add(p)
58
+ void p.exited.then(() => helpers.delete(p))
59
+ }
60
+ const chunks: Uint8Array[] = []
61
+ const read = async (from: ReadableStream<Uint8Array>) => {
62
+ for await (const c of from) chunks.push(c)
63
+ }
64
+ const reading = Promise.all([
65
+ read(p.stderr),
66
+ p.stdout instanceof ReadableStream ? read(p.stdout) : undefined,
67
+ ])
68
+ const code = await p.exited
69
+ // A grandchild of `sh -c` that outlives it keeps the pipe open; what it
70
+ // said by then is enough.
71
+ await Promise.race([reading, Bun.sleep(200)])
72
+ return { code, err: Buffer.concat(chunks).toString() }
73
+ }
74
+ const why = reasons()
75
+
76
+ return {
77
+ // Each run asks for one (`vx watch` runs again): a new run, on the tree as it is now.
78
+ async relay() {
79
+ closed = false
80
+ relay = await loopbackRelay()
81
+ return relay.url
82
+ },
83
+
84
+ async spawn(a) {
85
+ const work = (async () => {
86
+ base ??= mkdtemp(path.join(os.tmpdir(), 'vx-dte-'))
87
+ const dir = path.join(await base, String(a.index))
88
+ if (closed) return
89
+ // The run's own snapshot: one taken here, after a hit restored a
90
+ // committed file, was another tree, and every worker was refused
91
+ // (2026-10-25).
92
+ const add = await run(
93
+ ['git', 'worktree', 'add', '-q', '--detach', dir, a.commit],
94
+ a.workspaceRoot,
95
+ { ...process.env, GIT_INDEX_FILE: undefined },
96
+ )
97
+ if (add.code !== 0) throw new Error(`git worktree add: ${errorLine(add.err)}`)
98
+ trees.push({ root: a.workspaceRoot, dir })
99
+ // The workspace's own place in the checkout: from the worktree's
100
+ // root, a workspace in a subdirectory of its repository had no
101
+ // `.vx/workspace` above, and every worker exited (2026-10-25).
102
+ const prefix = Bun.spawn(['git', 'rev-parse', '--show-prefix'], {
103
+ cwd: a.workspaceRoot,
104
+ stdout: 'pipe',
105
+ stderr: 'ignore',
106
+ })
107
+ const at = path.join(dir, (await new Response(prefix.stdout).text()).trim())
108
+ const env: Record<string, string | undefined> = { ...process.env, ...a.env }
109
+ // The worktree is a repository of its own: a hook's index or
110
+ // repository would stand in for it.
111
+ for (const name of GIT_REPO_ENV) delete env[name]
112
+ // A cache dir the workspace names, relative, would name each
113
+ // worktree's own store, where the run cannot read what it saved.
114
+ if (a.cacheDir !== undefined) env['VX_CACHE_DIR'] = a.cacheDir
115
+ if (options.prepare !== undefined && !closed) {
116
+ const p = await run(['sh', '-c', options.prepare], at, env, true, true)
117
+ if (p.code !== 0 && !closed)
118
+ throw new Error(await why(`prepare exited ${p.code}`, a.id, p.err))
119
+ }
120
+ if (closed) return
121
+ void run([...vx(), 'dte', 'worker'], at, env, false).then(async (r) => {
122
+ if (!closed) a.exited(await why(`exited ${r.code}`, a.id, r.err))
123
+ })
124
+ })()
125
+ pending.add(work)
126
+ try {
127
+ await work
128
+ } finally {
129
+ pending.delete(work)
130
+ }
131
+ },
132
+
133
+ async close() {
134
+ closed = true
135
+ // The relay closing the run tells each worker to leave; a prepare
136
+ // still running is stopped.
137
+ relay?.stop()
138
+ relay = undefined
139
+ 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))]
153
+ if (base !== undefined) {
154
+ const dir = await base
155
+ 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()
167
+ }
168
+ },
169
+ }
170
+ }
171
+
172
+ /** Signal `p`'s process group: a helper leads its own (`detached`). */
173
+ function group(p: Bun.Subprocess, signal: NodeJS.Signals): void {
174
+ try {
175
+ process.kill(-p.pid, signal)
176
+ } catch {
177
+ // quiet: the group is gone
178
+ }
179
+ }
180
+
181
+ /**
182
+ * `<what>: <its error line> (log: <path>)`, a worker's whole stderr kept at
183
+ * that path, which outlives the run.
184
+ */
185
+ export function reasons(): (what: string, id: string, err: string) => Promise<string> {
186
+ let logs: Promise<string> | undefined
187
+ return async (what, id, err) => {
188
+ const line = errorLine(err)
189
+ if (line === '') return what
190
+ logs ??= mkdtemp(path.join(os.tmpdir(), 'vx-dte-logs-'))
191
+ const file = path.join(await logs, `${id}.log`)
192
+ await writeFile(file, err)
193
+ return `${what}: ${line} (log: ${file})`
194
+ }
195
+ }
196
+
197
+ /**
198
+ * The line of a process's stderr that says what went wrong: vx's own `✗`
199
+ * line, else the first naming an error or git's `fatal:`, else the last. The last alone was
200
+ * often a hint (`fix vx run …`) that hid the error above it.
201
+ */
202
+ export function errorLine(text: string): string {
203
+ const lines = Bun.stripANSI(text)
204
+ .split('\n')
205
+ .map((l) => l.trim())
206
+ .filter(Boolean)
207
+ return (
208
+ lines.find((l) => l.startsWith('✗')) ??
209
+ lines.find((l) => /\berror\b|^fatal:/i.test(l)) ??
210
+ lines.at(-1) ??
211
+ ''
212
+ )
213
+ }
214
+
215
+ /** A relay of the run's own on loopback, with no auth: only this machine reaches it. */
216
+ export async function loopbackRelay(): Promise<{ url: string; stop(): void }> {
217
+ const { serveRelay } = await import('@vzn/vx-dte-server')
218
+ const server = serveRelay({ hostname: '127.0.0.1', port: 0 })
219
+ return {
220
+ url: server.url,
221
+ stop() {
222
+ void server.stop()
223
+ },
224
+ }
225
+ }
226
+
227
+ /**
228
+ * What `git rev-parse --local-env-vars` names: variables that tie a
229
+ * process to one repository and its index. A git hook sets
230
+ * `GIT_INDEX_FILE` (and, in a linked worktree, `GIT_DIR`); a worktree add
231
+ * under them wrote its checkout to the hook's index, or failed on a
232
+ * relative one, and a worker read the hook's repository.
233
+ */
234
+ const GIT_REPO_ENV = [
235
+ 'GIT_ALTERNATE_OBJECT_DIRECTORIES',
236
+ 'GIT_CONFIG',
237
+ 'GIT_CONFIG_PARAMETERS',
238
+ 'GIT_CONFIG_COUNT',
239
+ 'GIT_OBJECT_DIRECTORY',
240
+ 'GIT_DIR',
241
+ 'GIT_WORK_TREE',
242
+ 'GIT_IMPLICIT_WORK_TREE',
243
+ 'GIT_GRAFT_FILE',
244
+ 'GIT_INDEX_FILE',
245
+ 'GIT_NO_REPLACE_OBJECTS',
246
+ 'GIT_REPLACE_REF_BASE',
247
+ 'GIT_PREFIX',
248
+ 'GIT_SHALLOW_FILE',
249
+ 'GIT_COMMON_DIR',
250
+ ]
251
+
252
+ /** The argv that runs this vx: the binary when compiled, else Bun and its entry. */
253
+ function vx(): string[] {
254
+ const compiled = Bun.main.startsWith('/$bunfs/') || Bun.main.startsWith('B:/~BUN/')
255
+ return compiled ? [process.execPath] : [process.execPath, Bun.main]
256
+ }
@@ -0,0 +1,219 @@
1
+ // What the orchestrator and its workers say to each other through the relay
2
+ // (the relay never reads it): orders and status only. Every task's files
3
+ // and output go through the workspace's cache, by the keys these name.
4
+
5
+ import type { CacheFacts, CapturedChunk, ExecuteResult, SandboxConfig } from '@vzn/vx'
6
+
7
+ /**
8
+ * A worker says what it holds every this many heartbeats, and a task's
9
+ * lease is three of them. Every heartbeat (2026-10-25: the fastest, not
10
+ * a free plan's message count): a task its worker dropped while alive
11
+ * goes back in 15 s, not 90.
12
+ */
13
+ export const HELD_EVERY = 1
14
+
15
+ /** A worker's `join` info. */
16
+ export interface WorkerInfo {
17
+ readonly v: 1
18
+ /** `WorkerSpawn.id` (`VX_DTE_WORKER`), for a worker the run started. */
19
+ readonly id?: string
20
+ /** What its machine has (core's `machineParallelism`, `machineMemoryBytes`), not what its size says. */
21
+ readonly cores: number
22
+ readonly memory: number
23
+ /** Its machine's platform, `os-arch` (`linux-x64`): a task naming `exec.platforms` goes only to a worker of its platform. */
24
+ readonly platform?: string
25
+ /** Its checkout's tree: a worker with other tracked files than the run is refused. */
26
+ readonly tree: string
27
+ /** When its `vx` started, wall-clock ms: the run's trace splits its warm-up there. */
28
+ readonly booted?: number
29
+ }
30
+
31
+ /** A task whose files the worker restores before it runs one that needs them. */
32
+ export interface DepOrder {
33
+ task: string
34
+ /** Where its entry is: its cache key, or the run key it was saved under. */
35
+ key: string
36
+ /** Workspace-relative. */
37
+ project: string
38
+ outputs: { files: string[]; workspaceFiles: string[] }
39
+ }
40
+
41
+ /** One task a worker is sent. */
42
+ export interface RunOrder {
43
+ task: string
44
+ /** Its cache key, which a success with no violation is saved under; absent, uncached. */
45
+ key?: string
46
+ /** What anything else is saved under: no hit ever serves it. */
47
+ runKey: string
48
+ /**
49
+ * The run reads no cache (`--force`): a success is saved under `key`, which
50
+ * keeps an entry it holds, and under `runKey`, whose fresh bytes this run's
51
+ * machines restore.
52
+ */
53
+ fresh?: true
54
+ /** The run's probe found `key` in no cache: its upload asks the remote nothing first. */
55
+ absent?: true
56
+ /** The tasks it depends on, transitively, whose files the worker does not hold yet. */
57
+ deps: DepOrder[]
58
+ /** Workspace-relative projects inside `project`, whose files are not its outputs. */
59
+ nested: string[]
60
+ command: string
61
+ /** Workspace-relative, as `project` is. */
62
+ cwd: string
63
+ project: string
64
+ env: Record<string, string>
65
+ /** Its `exec.env.secret`: the names in `env` whose values its saved output masks. */
66
+ secret?: string[]
67
+ timeoutMs?: number
68
+ outputs: { files: string[]; workspaceFiles: string[] }
69
+ /** Its recorded peak: the worker starts it when that much is free. */
70
+ cores: number
71
+ memory: number
72
+ /**
73
+ * Its `exec.sandbox`, and for a cached task the projects its key answers
74
+ * for (workspace-relative): the worker builds the request on its own
75
+ * paths and OS (`sandboxRequestFor`), as its orchestrator may run another.
76
+ */
77
+ sandbox?: { config: SandboxConfig; keyed?: string[] }
78
+ /** Its persistent dependencies (`ServerSpec`), `cwd` workspace-relative: the worker starts each once. */
79
+ servers?: ServerOrder[]
80
+ /** The runtime probes its key includes, as the orchestrator answered them: the worker's must answer the same. */
81
+ runtime?: RuntimeOrder[]
82
+ }
83
+
84
+ export interface RuntimeOrder {
85
+ command: string
86
+ output: string
87
+ /** A `workspaceRuntime` probe, run at the root; else in `project`. */
88
+ workspace?: true
89
+ }
90
+
91
+ export interface ServerOrder {
92
+ task: string
93
+ command: string
94
+ cwd: string
95
+ env: Record<string, string>
96
+ readyWhen?: string
97
+ timeoutMs?: number
98
+ /** Its `exec.sandbox` and the projects nested in its own, workspace-relative: it starts inside it. */
99
+ sandbox?: { config: SandboxConfig; nested: string[] }
100
+ }
101
+
102
+ export type ToWorker =
103
+ /** Tasks that fit what it has free, each begun at once; `root` is swapped for the worker's in `env`. */
104
+ | {
105
+ type: 'batch'
106
+ root: string
107
+ /** `root`'s real path, as sandbox paths spell it. */
108
+ realRoot: string
109
+ tasks: RunOrder[]
110
+ /** The run's sandboxed tasks' `exec.sandbox`, in a worker's first batch that has a sandboxed task. */
111
+ sandboxes?: SandboxConfig[]
112
+ }
113
+ /** Part of an order's `deps`, ahead of the batch whose order was too long for one frame. */
114
+ | { type: 'deps'; task: string; deps: DepOrder[] }
115
+ | { type: 'cancel'; task: string }
116
+ /**
117
+ * It was taken into the run, under the name the run's output gives it,
118
+ * and reads the cache as the run does: its refs, and the access the
119
+ * run's remotes lend, for one with none of its own.
120
+ */
121
+ | {
122
+ type: 'welcome'
123
+ name: string
124
+ cache: CacheFacts
125
+ }
126
+ /** The run's remotes lent again, before the last tokens end. */
127
+ | { type: 'cache'; cache: CacheFacts }
128
+ | { type: 'refused'; reason: string }
129
+ /** The run needs it no more: it leaves. */
130
+ | { type: 'exit' }
131
+
132
+ /**
133
+ * What its machine uses now: cores busy over the last second and bytes of
134
+ * memory in use. Each frame a worker sends carries it, so the run places
135
+ * by what is free, not only by what history booked (2026-10-23: "you
136
+ * watch instruments and play most efficient as they go").
137
+ */
138
+ export interface Load {
139
+ readonly cpu: number
140
+ readonly memory: number
141
+ /** Cores its running tasks' process trees used over the last look: the rest of `cpu` is no task's. */
142
+ readonly tasks?: number
143
+ /**
144
+ * Cores busy on average since the worker started: the summary's
145
+ * `machine avg`. A worker sends few frames, so the run cannot average
146
+ * `cpu` (one that sent 3, each as a task started, read 0.6 of 2.0 used).
147
+ */
148
+ readonly avg?: number
149
+ }
150
+
151
+ export type ToOrchestrator =
152
+ /** It began the task. */
153
+ | { type: 'start'; task: string; load?: Load }
154
+ /** Its command is done; its outputs save now, its cores are free. */
155
+ | { type: 'ran'; task: string; load?: Load }
156
+ /** Each heartbeat while it runs any: what it runs, which renews its lease on each. */
157
+ | { type: 'held'; running: string[]; load?: Load }
158
+ /** It could not run a task it began, not a non-zero exit: a full disk, a failed unpack. */
159
+ | { type: 'fault'; task: string; reason: string }
160
+ /** It ran the task and saved it under `key`; its output is in that entry. */
161
+ | { type: 'end'; task: string; key: string; result: WiredResult; load?: Load }
162
+ /**
163
+ * Ahead of an `end` too long for one relay frame, some of its lists:
164
+ * the `end` that follows adds its own to them. One `end` with a sandboxed
165
+ * task's 9000 violations was over the relay's 1 MiB, and the run waited
166
+ * for it (2026-10-25).
167
+ */
168
+ | ({ type: 'part'; task: string } & Partial<Pick<WiredResult, PartList>>)
169
+
170
+ /**
171
+ * A message's JSON in UTF-8 bytes, what a relay frame holds: counted in
172
+ * UTF-16 units, a part of CJK output sealed past the relay's 1 MiB and its
173
+ * result never arrived (2026-10-25).
174
+ */
175
+ export const bytesOf = (body: unknown): number => Buffer.byteLength(JSON.stringify(body))
176
+
177
+ /** The lists a long result sends ahead of its `end`, in parts. */
178
+ export const PART_LISTS = ['violations', 'changed', 'removed', 'output'] as const
179
+ export type PartList = (typeof PART_LISTS)[number]
180
+
181
+ /** A result as JSON carries it: its output stays in the cache entry, and a violation's time is a string. */
182
+ export type WiredResult = Omit<ExecuteResult, 'violations' | 'outputs' | 'stdout' | 'stderr'> & {
183
+ violations?: (Omit<ExecuteResult['violations'][number], 'timestamp'> & { timestamp: string })[]
184
+ /** The share of a core each runnable thread got on the worker while it ran (`ExecuteResult.machine.cpuShare`). */
185
+ cpuShare?: number
186
+ /** Workspace-relative upstream outputs the task removed: this machine removes them too. */
187
+ removed?: string[]
188
+ /** Dependency keys whose remote copy did not read back: this machine sends its own over them. */
189
+ unread?: string[]
190
+ /**
191
+ * No remote took its entry's upload (too big, a 429): its files are on
192
+ * that worker alone, and its output comes here in `output`.
193
+ */
194
+ unshared?: true
195
+ /** Its output, both streams in the order written, when `unshared`. */
196
+ output?: CapturedChunk[]
197
+ }
198
+
199
+ /**
200
+ * Names whose value is the worker's own: the orchestrator sends one only
201
+ * when a task's `env.define` sets it, a value its key includes.
202
+ */
203
+ export const HOST_ENV = [
204
+ 'HOME',
205
+ 'SHELL',
206
+ 'USER',
207
+ 'LOGNAME',
208
+ 'TMPDIR',
209
+ 'TEMP',
210
+ 'TMP',
211
+ 'npm_execpath',
212
+ 'PNPM_HOME',
213
+ 'COREPACK_HOME',
214
+ 'XDG_CONFIG_HOME',
215
+ 'XDG_DATA_HOME',
216
+ 'XDG_CACHE_HOME',
217
+ 'XDG_STATE_HOME',
218
+ 'XDG_RUNTIME_DIR',
219
+ ]