@vzn/vx 0.0.18 → 0.0.22

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.
Files changed (149) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +107 -70
  3. package/package.json +10 -20
  4. package/src/bin.ts +64 -5
  5. package/src/cache/archive.ts +279 -28
  6. package/src/cache/cache.ts +952 -1584
  7. package/src/cache/chained-cache.ts +14 -10
  8. package/src/cache/config-evals.ts +112 -0
  9. package/src/cache/file-hashes.ts +290 -0
  10. package/src/cache/git-inputs.ts +966 -0
  11. package/src/cache/index.ts +23 -7
  12. package/src/cache/inputs.ts +553 -739
  13. package/src/cache/key-fold.ts +232 -0
  14. package/src/cache/layer.ts +674 -0
  15. package/src/cache/layered-cache.ts +211 -49
  16. package/src/cache/output-index.ts +323 -0
  17. package/src/cache/policy.ts +84 -0
  18. package/src/cache/run-history.ts +152 -0
  19. package/src/cache/zstd.ts +183 -0
  20. package/src/cli/cache.ts +88 -30
  21. package/src/cli/completions.ts +104 -0
  22. package/src/cli/core-alias.ts +25 -0
  23. package/src/cli/format.ts +4 -15
  24. package/src/cli/help.ts +76 -38
  25. package/src/cli/index.ts +82 -17
  26. package/src/cli/info.ts +162 -91
  27. package/src/cli/init.ts +87 -0
  28. package/src/cli/last.ts +130 -23
  29. package/src/cli/lock.ts +36 -18
  30. package/src/cli/plan-format.ts +18 -7
  31. package/src/cli/plugin-commands.ts +34 -12
  32. package/src/cli/run.ts +138 -313
  33. package/src/cli/select.ts +342 -0
  34. package/src/cli/show.ts +151 -71
  35. package/src/cli/upgrade.ts +136 -15
  36. package/src/cli/watch.ts +1175 -116
  37. package/src/cli/why.ts +51 -29
  38. package/src/cli/workspace-config.ts +138 -0
  39. package/src/config.ts +183 -126
  40. package/src/exec/env.ts +11 -0
  41. package/src/exec/executor.ts +62 -31
  42. package/src/exec/index.ts +16 -2
  43. package/src/exec/kill-tree.ts +281 -0
  44. package/src/exec/local-executor.ts +41 -0
  45. package/src/exec/runner.ts +361 -77
  46. package/src/exec/sandbox-binds.ts +211 -0
  47. package/src/exec/sandbox-paths.ts +70 -0
  48. package/src/exec/sandbox-runtime.ts +1152 -415
  49. package/src/exec/sandbox-violations.ts +293 -0
  50. package/src/graph/dependency-spec.ts +14 -14
  51. package/src/graph/index.ts +6 -4
  52. package/src/graph/priorities.ts +130 -0
  53. package/src/graph/scheduler.ts +332 -329
  54. package/src/graph/task-graph.ts +552 -188
  55. package/src/index.ts +111 -102
  56. package/src/orchestrator/admission.ts +161 -0
  57. package/src/orchestrator/colors.ts +8 -3
  58. package/src/orchestrator/deferred-outputs.ts +25 -7
  59. package/src/orchestrator/doctor.ts +363 -0
  60. package/src/orchestrator/events.ts +111 -8
  61. package/src/orchestrator/excluded-keys.ts +155 -0
  62. package/src/orchestrator/execute-task.ts +450 -565
  63. package/src/orchestrator/failure-mode.ts +169 -28
  64. package/src/orchestrator/failure-recap.ts +121 -0
  65. package/src/orchestrator/fingerprint-watch.ts +89 -0
  66. package/src/orchestrator/framed-output.ts +121 -49
  67. package/src/orchestrator/history.ts +123 -104
  68. package/src/orchestrator/hit-restore.ts +285 -0
  69. package/src/orchestrator/index.ts +42 -5
  70. package/src/orchestrator/keyed-projects.ts +89 -0
  71. package/src/orchestrator/local-shortcircuit.ts +83 -23
  72. package/src/orchestrator/lockfile-claim.ts +401 -0
  73. package/src/orchestrator/logger.ts +128 -41
  74. package/src/orchestrator/metrics.ts +201 -72
  75. package/src/orchestrator/miss-save.ts +232 -0
  76. package/src/orchestrator/options.ts +63 -34
  77. package/src/orchestrator/persistent.ts +99 -0
  78. package/src/orchestrator/placement.ts +212 -0
  79. package/src/orchestrator/plan.ts +24 -11
  80. package/src/orchestrator/plugin-host.ts +200 -49
  81. package/src/orchestrator/plugin.ts +190 -14
  82. package/src/orchestrator/prepare.ts +168 -137
  83. package/src/orchestrator/projects.ts +270 -0
  84. package/src/orchestrator/remote-prefetch.ts +22 -2
  85. package/src/orchestrator/run-artifacts.ts +34 -3
  86. package/src/orchestrator/run-context.ts +20 -5
  87. package/src/orchestrator/run-lock.ts +349 -0
  88. package/src/orchestrator/run-records.ts +132 -0
  89. package/src/orchestrator/run-report.ts +6 -4
  90. package/src/orchestrator/run.ts +467 -611
  91. package/src/orchestrator/sandbox-request.ts +569 -0
  92. package/src/orchestrator/save-lane.ts +44 -0
  93. package/src/orchestrator/shell-verdict.ts +117 -0
  94. package/src/orchestrator/signals.ts +167 -0
  95. package/src/orchestrator/stable-keys.ts +259 -10
  96. package/src/orchestrator/status-line.ts +35 -2
  97. package/src/orchestrator/summary.ts +115 -25
  98. package/src/orchestrator/task-hash.ts +103 -21
  99. package/src/orchestrator/task-log-buffer.ts +15 -18
  100. package/src/orchestrator/telemetry-host.ts +27 -8
  101. package/src/orchestrator/telemetry.ts +71 -44
  102. package/src/orchestrator/upstream.ts +91 -45
  103. package/src/util/bun-version.ts +43 -0
  104. package/src/util/cgroup.ts +160 -0
  105. package/src/util/edit-distance.ts +49 -0
  106. package/src/util/errors.ts +86 -0
  107. package/src/util/hash.ts +10 -1
  108. package/src/util/index.ts +42 -5
  109. package/src/util/paths.ts +168 -5
  110. package/src/util/procfs.ts +20 -0
  111. package/src/util/settle.ts +23 -2
  112. package/src/util/size.ts +33 -0
  113. package/src/util/task-id.ts +20 -0
  114. package/src/util/timing.ts +11 -1
  115. package/src/util/verbs.ts +39 -0
  116. package/src/util/which.ts +48 -0
  117. package/src/workspace/affected.ts +368 -60
  118. package/src/workspace/config-cache.ts +271 -32
  119. package/src/workspace/config-eval.ts +60 -16
  120. package/src/workspace/config-imports.ts +131 -9
  121. package/src/workspace/config-schema.ts +1033 -0
  122. package/src/workspace/filter.ts +69 -17
  123. package/src/workspace/fingerprint.ts +59 -7
  124. package/src/workspace/index.ts +37 -3
  125. package/src/workspace/json-data.ts +90 -0
  126. package/src/workspace/load-reads.ts +32 -0
  127. package/src/workspace/lockfile.ts +41 -14
  128. package/src/workspace/migrate-scripts.ts +187 -0
  129. package/src/workspace/migration.ts +353 -0
  130. package/src/workspace/package-graph.ts +239 -31
  131. package/src/workspace/project-loader.ts +295 -815
  132. package/src/workspace/workspace.ts +266 -31
  133. package/plugins/local-cache/index.ts +0 -2
  134. package/plugins/local-executor/index.ts +0 -2
  135. package/plugins/schedule-history/index.ts +0 -2
  136. package/src/cache/cas-backend.ts +0 -115
  137. package/src/cache/digest.ts +0 -32
  138. package/src/cli/migrate-emit.ts +0 -21
  139. package/src/cli/migrate-nx.ts +0 -473
  140. package/src/cli/migrate-scripts.ts +0 -127
  141. package/src/cli/migrate-turbo.ts +0 -401
  142. package/src/cli/migrate.ts +0 -305
  143. package/src/cli/prune.ts +0 -291
  144. package/src/orchestrator/missing-plugin.ts +0 -9
  145. package/src/orchestrator/resources.ts +0 -45
  146. package/src/orchestrator/verify.ts +0 -174
  147. package/src/plugins/local-cache/index.ts +0 -11
  148. package/src/plugins/local-executor/index.ts +0 -45
  149. package/src/plugins/schedule-history/index.ts +0 -109
@@ -0,0 +1,281 @@
1
+ // Signal a task and everything it forked. Every task child is spawned
2
+ // `detached` — its own session and process group — so the group IS the
3
+ // task's tree: the shell, what the shell backgrounded (`server &`), a
4
+ // test runner's workers. Signalling the child alone left all of that
5
+ // alive: a timed-out `sh -c "x & y"` killed the shell and orphaned `x`,
6
+ // and a Ctrl-C did the same for every compound command (2026-09-16). A
7
+ // group signal reaches a member whose parent already died, too — the
8
+ // group outlives its leader — so the order of death does not matter.
9
+ // What still escapes is a daemon that calls setsid itself (the residual
10
+ // every non-cgroup runner shares); under a sandbox the pid namespace
11
+ // takes even that.
12
+
13
+ import { closeSync, readdirSync, readFileSync, writeSync } from 'node:fs'
14
+ import { executablePath, procfsIsOwn } from '../util/index.js'
15
+
16
+ export type Child = ReturnType<typeof Bun.spawn>
17
+
18
+ /**
19
+ * A child whose polite signals go down a pipe instead of to its group: a
20
+ * Linux sandboxed task. The group there is bwrap's, whose monitor dies of
21
+ * a SIGTERM and takes the namespace down with SIGKILL (`--die-with-parent`),
22
+ * so the command's own cleanup never ran; a shell inside reads the
23
+ * signal's name off the pipe and signals the command's group (item 752).
24
+ * SIGKILL still goes to the group: bwrap's death is the hard stop.
25
+ */
26
+ const channels = new Map<Child, number>()
27
+
28
+ /** Route `child`'s SIGINT and SIGTERM through `fd`, an end vx owns. */
29
+ export function signalThrough(child: Child, fd: number): void {
30
+ channels.set(child, fd)
31
+ }
32
+
33
+ /**
34
+ * Close `child`'s channel once it has exited. The entry goes before the
35
+ * descriptor, so a later kill never writes to a number the process has
36
+ * since reused.
37
+ */
38
+ export function closeSignalChannel(child: Child): void {
39
+ const fd = channels.get(child)
40
+ if (fd === undefined) return
41
+ channels.delete(child)
42
+ try {
43
+ closeSync(fd)
44
+ } catch {
45
+ // already closed
46
+ }
47
+ }
48
+
49
+ /**
50
+ * The groups vx must take down if it dies without running a line: a
51
+ * `kill -9`, the OOM killer. Nothing in vx runs then, so the kill has to
52
+ * live outside it — one `sh` per vx process, its own group, reading a
53
+ * pipe only vx holds the write end of. `+<pgid>` and `-<pgid>` lines keep
54
+ * its list; when vx dies the kernel closes the pipe, the read hits EOF,
55
+ * and every group still listed is SIGKILLed — what bwrap's
56
+ * `--die-with-parent` does for a Linux sandboxed task, extended to the
57
+ * rest. A group signal reaches what the task forked, which
58
+ * `PR_SET_PDEATHSIG` never did (kill-tree.md). Started just before the
59
+ * first spawn, so a run that spawns nothing (a warm run) never starts
60
+ * it; a normal exit leaves the list empty, so the EOF then kills nothing.
61
+ * `undefined` until then, `null` once it could not start or write: the
62
+ * guard is best-effort, and its failure is the documented limit, never a
63
+ * failed task.
64
+ */
65
+ let guardFd: number | null | undefined
66
+
67
+ const GUARD_SCRIPT = [
68
+ "g=' '",
69
+ 'while IFS= read -r l; do',
70
+ ' case $l in',
71
+ ' +*) g="$g${l#+} " ;;',
72
+ ' -*) p=${l#-}; case $g in *" $p "*) g="${g%% $p *} ${g#* $p }" ;; esac ;;',
73
+ ' esac',
74
+ 'done <&3',
75
+ 'for p in $g; do kill -s KILL -- "-$p"; done 2>/dev/null',
76
+ ].join('\n')
77
+
78
+ function startGuard(): void {
79
+ if (guardFd !== undefined) return
80
+ guardFd = null
81
+ try {
82
+ const guard = Bun.spawn([executablePath('sh'), '-c', GUARD_SCRIPT], {
83
+ argv0: 'vx-group-guard',
84
+ stdio: ['ignore', 'ignore', 'ignore', 'pipe'],
85
+ // Out of vx's group, so a Ctrl-C at the terminal leaves it to the
86
+ // pipe; vx's own teardown owns that path.
87
+ detached: true,
88
+ })
89
+ guard.unref()
90
+ guardFd = guard.stdio[3] as number
91
+ } catch {
92
+ // The limit as it was: nothing takes the groups down.
93
+ }
94
+ }
95
+
96
+ function guardWrite(line: string): void {
97
+ if (typeof guardFd !== 'number') return
98
+ try {
99
+ writeSync(guardFd, line)
100
+ } catch {
101
+ guardFd = null
102
+ }
103
+ }
104
+
105
+ /**
106
+ * Spawn a task child and list its group for the guard to kill if vx dies
107
+ * holding it. The guard starts BEFORE the spawn: started after it, the
108
+ * guard's own spawn was a window in which the task ran and a `kill -9`
109
+ * of vx found its group unlisted — under a traced sandbox, a third of
110
+ * the kills landed there. What is left is the step from the spawn's
111
+ * return to one pipe write.
112
+ */
113
+ export function spawnGuarded(spawn: () => Child): Child {
114
+ startGuard()
115
+ const child = spawn()
116
+ if (child.pid > 0) guardWrite(`+${child.pid}\n`)
117
+ return child
118
+ }
119
+
120
+ /**
121
+ * Strike `child`'s group from the guard's list: vx is done with it. What
122
+ * it left running is left as before, and a pid the kernel reuses is
123
+ * never killed on the old task's account. A group a teardown holds is
124
+ * struck when the teardown lets it go.
125
+ */
126
+ export function releaseGroup(child: Child): void {
127
+ if (!(child.pid > 0)) return
128
+ const hold = holds.get(child.pid)
129
+ if (hold !== undefined) hold.released = true
130
+ else guardWrite(`-${child.pid}\n`)
131
+ }
132
+
133
+ /** Groups a teardown is still taking down: how many hold each, and whether its runner let it go. */
134
+ const holds = new Map<number, { count: number; released: boolean }>()
135
+
136
+ /**
137
+ * Keep `children`'s groups on the guard's list until the returned
138
+ * function runs. A teardown signals a group and waits out a grace for it,
139
+ * and the runner lets a group go when its LEADER exits: a shell that died
140
+ * on the signal while a child that ignores it ran on released the group
141
+ * mid-grace, and a `kill -9` of vx there left that child to nobody (item
142
+ * 865). The caller lets go once its SIGKILL sweep has settled.
143
+ */
144
+ export function holdGroups(children: readonly Child[]): () => void {
145
+ const pids = children.map((c) => c.pid).filter((pid) => pid > 0)
146
+ for (const pid of pids) {
147
+ const hold = holds.get(pid)
148
+ if (hold === undefined) holds.set(pid, { count: 1, released: false })
149
+ else hold.count++
150
+ }
151
+ return () => {
152
+ for (const pid of pids) {
153
+ const hold = holds.get(pid)
154
+ if (hold === undefined) continue
155
+ if (--hold.count > 0) continue
156
+ holds.delete(pid)
157
+ if (hold.released) guardWrite(`-${pid}\n`)
158
+ }
159
+ }
160
+ }
161
+
162
+ /**
163
+ * Children whose group was empty when their leader exited. Its number is
164
+ * free from then on, and the kernel hands it to the next process that
165
+ * needs it: a persistent server stays in the run's registry after it
166
+ * exits (keep-alive reports it), and a teardown that signalled `-pid`
167
+ * there could reach whatever group holds the number now (item 874). A
168
+ * group that still had a member keeps its number reserved, and is
169
+ * signalled as before.
170
+ */
171
+ const goneGroups = new WeakSet<Child>()
172
+
173
+ /** Note, as `child`'s leader exits, whether its group went with it. */
174
+ export function markGroupIfGone(child: Child): void {
175
+ if (!groupAlive(child)) goneGroups.add(child)
176
+ }
177
+
178
+ export function killTree(child: Child, signal: 'SIGINT' | 'SIGTERM' | 'SIGKILL'): void {
179
+ // A pid of 0 would name OUR group (kill(0)): a child that never
180
+ // spawned has nothing to kill.
181
+ if (!(child.pid > 0) || goneGroups.has(child)) return
182
+ const fd = signal === 'SIGKILL' ? undefined : channels.get(child)
183
+ if (fd !== undefined) {
184
+ try {
185
+ writeSync(fd, `${signal.slice(3)}\n`)
186
+ return
187
+ } catch {
188
+ // The reader is gone (EPIPE): so is the command. The group signal
189
+ // below finds what is left, or nothing.
190
+ }
191
+ }
192
+ try {
193
+ process.kill(-child.pid, signal)
194
+ } catch (err) {
195
+ // ESRCH: the whole group is gone. Anything else (a group that is not
196
+ // ours to signal): the child itself, as before.
197
+ if ((err as NodeJS.ErrnoException).code === 'ESRCH') return
198
+ child.kill(signal)
199
+ }
200
+ }
201
+
202
+ /**
203
+ * How often a group whose leader has exited is asked again whether any
204
+ * member is left. Only a group that outlived its leader is polled.
205
+ */
206
+ const GROUP_POLL_MS = 20
207
+
208
+ /**
209
+ * Wait until every child's process group is gone, or `graceMs` passes.
210
+ * Resolves with the children whose group still has a live member: the
211
+ * ones to SIGKILL. The leader's exit is not the end of the tree — a
212
+ * `server & wait` shell dies on SIGTERM while a server that ignores it
213
+ * keeps the group, and the task's pipe, alive — so after the leaders the
214
+ * groups themselves are polled, and only while one is left.
215
+ */
216
+ export async function untilGroupsGone(
217
+ children: readonly Child[],
218
+ graceMs: number,
219
+ ): Promise<Child[]> {
220
+ const deadline = Date.now() + graceMs
221
+ let timer: ReturnType<typeof setTimeout> | undefined
222
+ // Not unref'd: it is what guarantees progress when every other handle
223
+ // has drained, and the grace is bounded either way.
224
+ await Promise.race([
225
+ Promise.allSettled(children.map((c) => c.exited)),
226
+ new Promise<void>((resolve) => {
227
+ timer = setTimeout(resolve, graceMs)
228
+ }),
229
+ ])
230
+ if (timer !== undefined) clearTimeout(timer)
231
+ let left = children.filter(groupAlive)
232
+ while (left.length > 0 && Date.now() < deadline) {
233
+ await Bun.sleep(Math.min(GROUP_POLL_MS, deadline - Date.now()))
234
+ left = left.filter(groupAlive)
235
+ }
236
+ return left
237
+ }
238
+
239
+ /**
240
+ * Does any process of the child's group still run? `kill(-pgid, 0)`
241
+ * answers for the group but counts a zombie, and an orphaned member that
242
+ * died on the SIGTERM waits as one until init reaps it — 1 to 2 s under a
243
+ * container's init (measured 2026-09-24) — so on Linux a group the kernel
244
+ * still knows is read from /proc, where a zombie says so — when /proc is
245
+ * this namespace's. One mounted for another reads as no member at all,
246
+ * and a group that ignored the SIGTERM went un-killed; there the group
247
+ * signal is the answer and the grace ends the wait.
248
+ */
249
+ function groupAlive(child: Child): boolean {
250
+ if (!(child.pid > 0) || goneGroups.has(child)) return false
251
+ try {
252
+ process.kill(-child.pid, 0)
253
+ } catch (err) {
254
+ return (err as NodeJS.ErrnoException).code === 'EPERM'
255
+ }
256
+ return !procfsIsOwn() || groupHasLiveMember(child.pid)
257
+ }
258
+
259
+ function groupHasLiveMember(pgid: number): boolean {
260
+ let entries: string[]
261
+ try {
262
+ entries = readdirSync('/proc')
263
+ } catch {
264
+ return true
265
+ }
266
+ for (const entry of entries) {
267
+ const first = entry.charCodeAt(0)
268
+ if (first < 48 || first > 57) continue
269
+ let stat: string
270
+ try {
271
+ stat = readFileSync(`/proc/${entry}/stat`, 'utf8')
272
+ } catch {
273
+ continue
274
+ }
275
+ // `<pid> (<comm>) <state> <ppid> <pgrp> …` — comm may hold spaces and
276
+ // parens, so the fields start after the LAST ')'.
277
+ const fields = stat.slice(stat.lastIndexOf(')') + 2).split(' ', 3)
278
+ if (Number(fields[2]) === pgid && fields[0] !== 'Z' && fields[0] !== 'X') return true
279
+ }
280
+ return false
281
+ }
@@ -0,0 +1,41 @@
1
+ // Core's own local executor: spawn the task's command here, in this
2
+ // process. Not a plugin — "run it on this machine" is not a capability
3
+ // someone else supplies differently, it is what running MEANS when no
4
+ // plugin claims the task, so it sits at the TAIL of every executor list
5
+ // (owner, 2026-09-05). An executor plugin places work ELSEWHERE; its
6
+ // absence is "here".
7
+ import { runCommand } from './runner.js'
8
+ import { runSandboxed } from './sandbox-runtime.js'
9
+ import type { TaskExecutor } from './executor.js'
10
+
11
+ /** The local executor. Accepts every task. */
12
+ export function localExecutor(): TaskExecutor {
13
+ return {
14
+ name: 'local',
15
+ async execute(req) {
16
+ const common = {
17
+ command: req.command,
18
+ cwd: req.cwd,
19
+ env: req.env,
20
+ forwardArgs: req.forwardArgs,
21
+ onStdout: req.onStdout,
22
+ onStderr: req.onStderr,
23
+ capture: req.capture,
24
+ ...(req.liveChildren !== undefined ? { liveChildren: req.liveChildren } : {}),
25
+ ...(req.timeoutMs !== undefined ? { timeoutMs: req.timeoutMs } : {}),
26
+ }
27
+ if (req.sandbox === undefined) {
28
+ const res = await runCommand(common)
29
+ return { ...res, violations: [] }
30
+ }
31
+ return runSandboxed({
32
+ ...common,
33
+ baseAllowRead: req.sandbox.baseAllowRead,
34
+ baseDenyRead: req.sandbox.baseDenyRead,
35
+ reportWithin: req.sandbox.reportWithin,
36
+ reportLinked: req.sandbox.reportLinked,
37
+ config: req.sandbox.config,
38
+ })
39
+ },
40
+ }
41
+ }