@vzn/vx 0.0.650 → 0.0.652

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 (146) hide show
  1. package/README.md +7 -23
  2. package/package.json +5 -5
  3. package/schemas/last.json +15 -0
  4. package/schemas/show.json +14 -20
  5. package/src/bin.ts +21 -31
  6. package/src/cache/archive.ts +1 -1
  7. package/src/cache/cache.ts +221 -30
  8. package/src/cache/file-hashes.ts +3 -0
  9. package/src/cache/git-inputs.ts +188 -39
  10. package/src/cache/history-stream.ts +134 -0
  11. package/src/cache/history-writer.ts +76 -0
  12. package/src/cache/index.ts +3 -0
  13. package/src/cache/inputs.ts +77 -14
  14. package/src/cache/layer.ts +17 -6
  15. package/src/cache/layered-cache.ts +4 -0
  16. package/src/cache/linked-inputs.ts +3 -0
  17. package/src/cache/lockfile-memo.ts +5 -3
  18. package/src/cache/output-index.ts +5 -2
  19. package/src/cache/remote-lane.ts +2 -1
  20. package/src/cache/run-history.ts +56 -17
  21. package/src/cache/schema.ts +15 -2
  22. package/src/cli/cache.ts +28 -18
  23. package/src/cli/completions.ts +4 -2
  24. package/src/cli/docs.ts +10 -11
  25. package/src/cli/help.ts +14 -25
  26. package/src/cli/index.ts +28 -9
  27. package/src/cli/info.ts +16 -8
  28. package/src/cli/init.ts +23 -15
  29. package/src/cli/last.ts +246 -204
  30. package/src/cli/lock.ts +55 -5
  31. package/src/cli/plan-format.ts +100 -118
  32. package/src/cli/plugin-commands.ts +8 -2
  33. package/src/cli/plugin-templates.ts +36 -45
  34. package/src/cli/run.ts +46 -27
  35. package/src/cli/select.ts +43 -5
  36. package/src/cli/show.ts +45 -52
  37. package/src/cli/task-verb.ts +22 -0
  38. package/src/cli/upgrade.ts +3 -1
  39. package/src/cli/watch-fs.ts +6 -1
  40. package/src/cli/watch-judge.ts +3 -2
  41. package/src/cli/watch-set.ts +4 -4
  42. package/src/cli/watch.ts +94 -52
  43. package/src/cli/why.ts +123 -89
  44. package/src/cli/workspace-config.ts +2 -1
  45. package/src/config.ts +47 -30
  46. package/src/exec/executor.ts +42 -2
  47. package/src/exec/index.ts +1 -0
  48. package/src/exec/kill-tree.ts +58 -5
  49. package/src/exec/local-executor.ts +1 -1
  50. package/src/exec/pipes.ts +152 -0
  51. package/src/exec/runner.ts +199 -182
  52. package/src/exec/sandbox-binds.ts +1 -1
  53. package/src/exec/sandbox-paths.ts +3 -1
  54. package/src/exec/sandbox-runtime.ts +317 -23
  55. package/src/exec/spawn-off.ts +154 -0
  56. package/src/exec/task-proxy.ts +93 -0
  57. package/src/exec/warm-shell.ts +146 -0
  58. package/src/graph/critical-path.ts +40 -28
  59. package/src/graph/definition.ts +16 -3
  60. package/src/graph/priorities.ts +98 -44
  61. package/src/graph/scheduler.ts +17 -11
  62. package/src/graph/task-graph.ts +51 -24
  63. package/src/handoff.ts +10 -7
  64. package/src/history/failure-mode.ts +24 -1
  65. package/src/history/history.ts +5 -0
  66. package/src/history/index.ts +1 -0
  67. package/src/history/metrics.ts +14 -6
  68. package/src/index.ts +7 -3
  69. package/src/orchestrator/admission.ts +1 -1
  70. package/src/orchestrator/command-windows.ts +1 -1
  71. package/src/orchestrator/deferred-outputs.ts +1 -1
  72. package/src/orchestrator/doctor.ts +1 -1
  73. package/src/orchestrator/download-policy.ts +5 -7
  74. package/src/orchestrator/excluded-keys.ts +1 -1
  75. package/src/orchestrator/execute-task.ts +67 -20
  76. package/src/orchestrator/graph-memo.ts +50 -1
  77. package/src/orchestrator/graph-stage.ts +6 -0
  78. package/src/orchestrator/hit-restore.ts +5 -6
  79. package/src/orchestrator/index.ts +3 -0
  80. package/src/orchestrator/keyed-projects.ts +28 -0
  81. package/src/orchestrator/live-usage.ts +16 -11
  82. package/src/orchestrator/local-shortcircuit.ts +2 -2
  83. package/src/orchestrator/locked-digests.ts +63 -0
  84. package/src/orchestrator/lockfile-claim.ts +108 -29
  85. package/src/orchestrator/miss-save.ts +58 -11
  86. package/src/orchestrator/persistent-task.ts +5 -1
  87. package/src/orchestrator/placement.ts +1 -1
  88. package/src/orchestrator/plan.ts +7 -3
  89. package/src/orchestrator/plugin-host.ts +67 -2
  90. package/src/orchestrator/plugin.ts +53 -2
  91. package/src/orchestrator/prepare.ts +16 -1
  92. package/src/orchestrator/projects.ts +4 -1
  93. package/src/orchestrator/remote-prefetch.ts +1 -1
  94. package/src/orchestrator/run-context.ts +29 -10
  95. package/src/orchestrator/run-failures.ts +62 -13
  96. package/src/orchestrator/run-keys.ts +1 -1
  97. package/src/orchestrator/run-records.ts +88 -59
  98. package/src/orchestrator/run-report.ts +1 -1
  99. package/src/orchestrator/run.ts +145 -41
  100. package/src/orchestrator/sandbox-request.ts +52 -9
  101. package/src/orchestrator/shell-verdict.ts +9 -0
  102. package/src/orchestrator/signals.ts +8 -5
  103. package/src/orchestrator/stable-keys.ts +17 -18
  104. package/src/orchestrator/task-artifacts.ts +1 -1
  105. package/src/orchestrator/task-hash.ts +21 -7
  106. package/src/orchestrator/telemetry.ts +5 -1
  107. package/src/report/events.ts +6 -14
  108. package/src/report/forecast.ts +10 -32
  109. package/src/report/framed-output.ts +283 -175
  110. package/src/report/index.ts +8 -3
  111. package/src/report/logger.ts +74 -39
  112. package/src/report/plain-output.ts +3 -1
  113. package/src/report/status-line.ts +49 -357
  114. package/src/report/summary.ts +105 -136
  115. package/src/settings.ts +8 -8
  116. package/src/ui/ansi.ts +48 -0
  117. package/src/ui/components.ts +506 -0
  118. package/src/ui/index.ts +79 -0
  119. package/src/ui/kit.ts +37 -0
  120. package/src/ui/live-region.ts +363 -0
  121. package/src/{util/refusal-notice.ts → ui/refusal.ts} +7 -5
  122. package/src/ui/terminal.ts +51 -0
  123. package/src/ui/theme.ts +224 -0
  124. package/src/ui/version-line.ts +28 -0
  125. package/src/util/config-frame.ts +191 -14
  126. package/src/util/edit-distance.ts +6 -1
  127. package/src/util/index.ts +2 -15
  128. package/src/util/paths.ts +21 -3
  129. package/src/util/secret-mask.ts +39 -3
  130. package/src/util/settle.ts +33 -1
  131. package/src/util/tail.ts +4 -1
  132. package/src/util/timing.ts +4 -16
  133. package/src/workspace/affected.ts +89 -30
  134. package/src/workspace/bracket-literal.ts +2 -2
  135. package/src/workspace/config-schema.ts +70 -63
  136. package/src/workspace/dependencies-lock.ts +191 -0
  137. package/src/workspace/filter.ts +36 -1
  138. package/src/workspace/index.ts +9 -0
  139. package/src/workspace/lockfile.ts +159 -6
  140. package/src/workspace/migration.ts +16 -9
  141. package/src/workspace/workspace.ts +3 -0
  142. package/src/util/colors.ts +0 -133
  143. package/src/util/report.ts +0 -44
  144. package/src/util/ui.ts +0 -258
  145. /package/src/{report/path-links.ts → ui/links.ts} +0 -0
  146. /package/src/{report → ui}/terminal-signals.ts +0 -0
@@ -98,6 +98,9 @@ let guardProc: Child | undefined
98
98
  */
99
99
  const GUARD_STALL_MS = 1_000
100
100
 
101
+ /** Bytes per write to the guard: under a socket's buffer on every platform. */
102
+ const GUARD_PIECE = 1024
103
+
101
104
  function startGuard(): void {
102
105
  if (guardFd !== undefined) return
103
106
  guardFd = null
@@ -114,6 +117,8 @@ function startGuard(): void {
114
117
  const fd = guard.stdio[3] as number
115
118
  guardFd = fd
116
119
  guardProc = guard
120
+ // A clean exit strikes what it released: the EOF kills what is listed.
121
+ process.once('exit', flushGuard)
117
122
  // A guard that has died (killed, the OOM killer) is handed to no later
118
123
  // spawn: its pipe is broken, and a shell whose printf buffers (bash as
119
124
  // macOS's sh) flushed the failed `+<pgid>` line into the task's own
@@ -134,6 +139,7 @@ function startGuard(): void {
134
139
  function stopGuard(): void {
135
140
  const fd = guardFd
136
141
  guardFd = null
142
+ pending = ''
137
143
  guardProc?.kill('SIGKILL')
138
144
  if (typeof fd !== 'number') return
139
145
  try {
@@ -145,11 +151,18 @@ function stopGuard(): void {
145
151
 
146
152
  function guardWrite(line: string): void {
147
153
  if (typeof guardFd !== 'number') return
148
- const deadline = Date.now() + GUARD_STALL_MS
154
+ let deadline = Date.now() + GUARD_STALL_MS
155
+ // A turn's batch can be more than the queue takes at once: it goes in
156
+ // pieces as the guard drains it, and only a guard that takes nothing for
157
+ // the whole stall is gone. With one deadline for the batch, 2000 strikes
158
+ // outlasted it on macOS and the guard was stopped under a live run.
159
+ let bytes = Buffer.from(line)
149
160
  for (;;) {
150
161
  try {
151
- writeSync(guardFd, line)
152
- return
162
+ const n = writeSync(guardFd, bytes, 0, Math.min(bytes.length, GUARD_PIECE))
163
+ if (n === bytes.length) return
164
+ bytes = bytes.subarray(n)
165
+ deadline = Date.now() + GUARD_STALL_MS
153
166
  } catch (err) {
154
167
  if ((err as NodeJS.ErrnoException).code !== 'EAGAIN' || Date.now() >= deadline) break
155
168
  Bun.sleepSync(1)
@@ -158,6 +171,43 @@ function guardWrite(line: string): void {
158
171
  stopGuard()
159
172
  }
160
173
 
174
+ /**
175
+ * Lines waiting for this turn's one write: strikes, and the listings of
176
+ * spare shells. Each line was a write of its own and a wake of the
177
+ * guard's shell: on a cold run of 500 tasks, 38 ms of vx's main thread
178
+ * for the strikes and 140 ms of the guard's CPU (2026-11-03). If vx dies
179
+ * in the turn a task ended, what that task left in the background dies
180
+ * with it, as when vx dies a moment before the task ends.
181
+ */
182
+ let pending = ''
183
+ let pendingTimer: ReturnType<typeof setImmediate> | undefined
184
+
185
+ function queueLine(line: string): void {
186
+ pending += line
187
+ pendingTimer ??= setImmediate(flushGuard)
188
+ }
189
+
190
+ /** Write the lines this turn queued now: before a listed child is handed its work. */
191
+ export function flushGuard(): void {
192
+ if (pendingTimer !== undefined) clearImmediate(pendingTimer)
193
+ pendingTimer = undefined
194
+ const lines = pending
195
+ pending = ''
196
+ if (lines !== '') guardWrite(lines)
197
+ }
198
+
199
+ /**
200
+ * List `pid`'s group from vx's side, for a child that runs nothing until
201
+ * vx hands it work (a spare shell waiting on its script): the line goes
202
+ * with the turn's others, and `flushGuard` before the hand-over. A spare
203
+ * vx dies before listing reads the end of its script's pipe and exits,
204
+ * having run nothing. Starts the guard.
205
+ */
206
+ export function listGroup(pid: number): void {
207
+ startGuard()
208
+ if (typeof guardFd === 'number' && pid > 0) queueLine(`+${pid}\n`)
209
+ }
210
+
161
211
  /**
162
212
  * Spawn a task child whose group the guard kills if vx dies holding it.
163
213
  * The CHILD lists its own group, before it runs anything: vx wrote the
@@ -170,8 +220,11 @@ function guardWrite(line: string): void {
170
220
  * races it. The guard starts BEFORE the spawn, so there is a pipe to hand.
171
221
  * `undefined` when the guard could not start: nothing lists anything.
172
222
  */
173
- export function spawnGuarded(spawn: (guard: number | undefined) => Child): Child {
223
+ export function spawnGuarded<C = Child>(spawn: (guard: number | undefined) => C): C {
174
224
  startGuard()
225
+ // The turn's lines go first, as they did one write each: a child's own
226
+ // listing behind them, not raced by a batch vx writes once it runs.
227
+ flushGuard()
175
228
  if (typeof guardFd !== 'number') return spawn(undefined)
176
229
  handingTo = guardProc?.pid
177
230
  try {
@@ -223,7 +276,7 @@ export function releaseGroup(child: Child): void {
223
276
  if (!(child.pid > 0)) return
224
277
  const hold = holds.get(child.pid)
225
278
  if (hold !== undefined) hold.released = true
226
- else guardWrite(`-${child.pid}\n`)
279
+ else queueLine(`-${child.pid}\n`)
227
280
  }
228
281
 
229
282
  /**
@@ -44,6 +44,7 @@ export function localExecutor(): TaskExecutor {
44
44
  ...(req.liveChildren !== undefined ? { liveChildren: req.liveChildren } : {}),
45
45
  ...(req.onSpawn !== undefined ? { onSpawn: req.onSpawn } : {}),
46
46
  ...(req.timeoutMs !== undefined ? { timeoutMs: req.timeoutMs } : {}),
47
+ ...(req.signal !== undefined ? { signal: req.signal } : {}),
47
48
  }
48
49
  if (req.sandbox === undefined) {
49
50
  const res = await runCommand(req.terminal === true ? { ...common, terminal: true } : common)
@@ -56,7 +57,6 @@ export function localExecutor(): TaskExecutor {
56
57
  reportWithin: req.sandbox.reportWithin,
57
58
  reportLinked: req.sandbox.reportLinked,
58
59
  config: req.sandbox.config,
59
- ...(req.signal !== undefined ? { signal: req.signal } : {}),
60
60
  })
61
61
  },
62
62
  }
@@ -0,0 +1,152 @@
1
+ // A task's stdout, stderr and held stdin as pipes vx makes itself (Linux),
2
+ // shared by the runner, the spare shells and the sandbox's spawn.
3
+
4
+ import type { Pointer } from 'bun:ffi'
5
+ import { closeSync } from 'node:fs'
6
+
7
+ const O_NONBLOCK = 0o4000
8
+ const O_CLOEXEC = 0o2000000
9
+ const F_SETFL = 4
10
+ const F_SETPIPE_SZ = 1031
11
+ /**
12
+ * A pipe holds 64 KiB by default, and a 200 MB stdout drained 13% slower
13
+ * through one than through Bun's socketpair (min of 7: 293 vs 259 ms);
14
+ * at 256 KiB it drained in 230. Past the user's pipe quota the kernel
15
+ * refuses the resize and the pipe keeps its 64 KiB.
16
+ */
17
+ const PIPE_BYTES = 256 * 1024
18
+
19
+ type PipeCalls = {
20
+ pipe2: (fds: Pointer, flags: number) => number
21
+ fcntl: (fd: number, cmd: number, arg: number) => number
22
+ ptr: (view: Int32Array, byteOffset?: number) => Pointer
23
+ }
24
+ let pipeCalls: PipeCalls | null | undefined
25
+
26
+ /** libc's `pipe2` and `fcntl`, bound once; null off Linux or where libc will not load. */
27
+ function linuxPipeCalls(): PipeCalls | null {
28
+ if (pipeCalls !== undefined) return pipeCalls
29
+ pipeCalls = null
30
+ if (process.platform !== 'linux') return null
31
+ try {
32
+ // Loaded at the first spawn, not with the module: a static import cost
33
+ // every vx start 0.3 ms, a warm run that spawns nothing included.
34
+ const { dlopen, FFIType, ptr } = require('bun:ffi') as typeof import('bun:ffi')
35
+ // glibc's soname; musl's loader answers any `libc.` name with itself
36
+ // (ldso/dynlink.c's reserved names).
37
+ const { symbols } = dlopen('libc.so.6', {
38
+ pipe2: { args: [FFIType.ptr, FFIType.i32], returns: FFIType.i32 },
39
+ fcntl: { args: [FFIType.i32, FFIType.i32, FFIType.i32], returns: FFIType.i32 },
40
+ })
41
+ pipeCalls = { pipe2: symbols.pipe2, fcntl: symbols.fcntl, ptr }
42
+ } catch {
43
+ // Bun's socketpair, as before: only `/dev/stdout` by path fails on it.
44
+ }
45
+ return pipeCalls
46
+ }
47
+
48
+ /**
49
+ * Two pipes, `[out read, out write, err read, err write]`, or undefined
50
+ * when libc is not bound or a descriptor is not to be had (Bun's own
51
+ * socketpair then fails the spawn with the reason).
52
+ */
53
+ function realPipes(): Int32Array | undefined {
54
+ const calls = linuxPipeCalls()
55
+ if (calls === null) return undefined
56
+ const fds = new Int32Array(4)
57
+ // Bun's spawn closes every descriptor it is not handed (probed, Bun
58
+ // 1.4.2); O_CLOEXEC keeps any other exec from holding a write end open.
59
+ if (calls.pipe2(calls.ptr(fds), O_CLOEXEC) !== 0) return undefined
60
+ if (calls.pipe2(calls.ptr(fds, 8), O_CLOEXEC) !== 0) {
61
+ closeSync(fds[0]!)
62
+ closeSync(fds[1]!)
63
+ return undefined
64
+ }
65
+ // vx's ends only: a task whose stdout is non-blocking fails its writes with EAGAIN.
66
+ calls.fcntl(fds[0]!, F_SETFL, O_NONBLOCK)
67
+ calls.fcntl(fds[2]!, F_SETFL, O_NONBLOCK)
68
+ calls.fcntl(fds[0]!, F_SETPIPE_SZ, PIPE_BYTES)
69
+ calls.fcntl(fds[2]!, F_SETPIPE_SZ, PIPE_BYTES)
70
+ return fds
71
+ }
72
+
73
+ /** One pipe, `[read, write]`, both close-on-exec, or undefined as {@link realPipes}. */
74
+ export function heldPipe(): Int32Array | undefined {
75
+ const calls = linuxPipeCalls()
76
+ if (calls === null) return undefined
77
+ const fds = new Int32Array(2)
78
+ return calls.pipe2(calls.ptr(fds), O_CLOEXEC) === 0 ? fds : undefined
79
+ }
80
+
81
+ /**
82
+ * A task's stdout and stderr. Bun's `'pipe'` is a socketpair, and Linux
83
+ * opens `/dev/stdout` through `/proc/self/fd/1`, which a socket refuses
84
+ * (ENXIO): `echo x > /dev/stdout` and `cmd | tee /dev/stderr` failed in
85
+ * every task, where a terminal or Turbo hands real pipes (X-113). So on
86
+ * Linux vx makes the pipes (`pipe2` through `bun:ffi`) and hands the child
87
+ * the write ends. macOS opens `/dev/fd/N` as a dup of the descriptor,
88
+ * which a socket allows, so Bun's pipe stays there.
89
+ *
90
+ * `held`: stdin is a pipe vx keeps open and never writes (a persistent
91
+ * task's, schema.md), for the same reason: `cat /dev/stdin` on Bun's
92
+ * socketpair failed with ENXIO instead of blocking. Otherwise `'ignore'`.
93
+ */
94
+ export class TaskPipes {
95
+ /** Descriptor 0 for `Bun.spawn`'s `stdio`. */
96
+ readonly stdin: number | 'pipe' | 'ignore'
97
+ /** Descriptors 1 and 2 for `Bun.spawn`'s `stdio`. */
98
+ readonly stdio: readonly [number | 'pipe', number | 'pipe']
99
+ readonly #read: readonly number[]
100
+ readonly #stdinWrite: number | undefined
101
+
102
+ constructor(terminal?: boolean, held?: boolean) {
103
+ const fds = terminal === true ? undefined : realPipes()
104
+ this.stdio = fds === undefined ? ['pipe', 'pipe'] : [fds[1]!, fds[3]!]
105
+ this.#read = fds === undefined ? [] : [fds[0]!, fds[2]!]
106
+ const inFds = held === true && terminal !== true ? heldPipe() : undefined
107
+ this.stdin = held !== true ? 'ignore' : inFds === undefined ? 'pipe' : inFds[0]!
108
+ this.#stdinWrite = inFds?.[1]
109
+ }
110
+
111
+ /**
112
+ * Close vx's copies of the child's ends once the spawn has returned or
113
+ * thrown: the child holds its own, and a write end left here is a writer
114
+ * the reader's EOF would wait for.
115
+ */
116
+ spawned(): void {
117
+ for (const fd of [this.stdin, ...this.stdio]) if (typeof fd === 'number') closeSync(fd)
118
+ }
119
+
120
+ /** The child's stdout and stderr. */
121
+ streams(
122
+ child: ReturnType<typeof Bun.spawn>,
123
+ ): [
124
+ ReadableStream<Uint8Array> | number | undefined,
125
+ ReadableStream<Uint8Array> | number | undefined,
126
+ ] {
127
+ const [out, err] = this.#read
128
+ return out === undefined
129
+ ? [child.stdout, child.stderr]
130
+ : [Bun.file(out).stream(), Bun.file(err!).stream()]
131
+ }
132
+
133
+ /**
134
+ * Close the read ends, once both streams have ended or been cancelled.
135
+ * Bun reads a dup of each, but takes it on the stream's first pull: a
136
+ * number closed before then was reused by the next spawn's pipe, and
137
+ * the stream read THAT (probed, Bun 1.4.2).
138
+ */
139
+ close(): void {
140
+ for (const fd of this.#read) closeSync(fd)
141
+ }
142
+
143
+ /**
144
+ * Close the held stdin's write end, once the child has exited and its
145
+ * output has ended. Not with the output alone: a server that sends it
146
+ * to a log (`exec >log 2>&1`) ended its streams at once, read stdin EOF
147
+ * and exited.
148
+ */
149
+ closeStdin(): void {
150
+ if (this.#stdinWrite !== undefined) closeSync(this.#stdinWrite)
151
+ }
152
+ }