@vzn/vx 0.0.597 → 0.0.598
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/package.json +5 -5
- package/src/bin.ts +9 -5
- package/src/cache/cache.ts +32 -19
- package/src/cache/layered-cache.ts +14 -9
- package/src/cache/output-index.ts +12 -7
- package/src/cache/run-history.ts +16 -8
- package/src/cli/plan-format.ts +23 -17
- package/src/cli/select.ts +13 -2
- package/src/cli/show.ts +13 -25
- package/src/cli/watch-cycle.ts +43 -0
- package/src/cli/watch.ts +37 -5
- package/src/exec/kill-tree.ts +34 -4
- package/src/exec/runner.ts +150 -12
- package/src/exec/sandbox-runtime.ts +23 -9
- package/src/graph/priorities.ts +1 -2
- package/src/index.ts +3 -1
- package/src/orchestrator/doctor.ts +3 -1
- package/src/orchestrator/execute-task.ts +21 -2
- package/src/orchestrator/hit-restore.ts +1 -1
- package/src/orchestrator/lockfile-claim.ts +14 -7
- package/src/orchestrator/logger.ts +7 -4
- package/src/orchestrator/plugin-host.ts +7 -2
- package/src/orchestrator/plugin.ts +5 -2
- package/src/orchestrator/prepare.ts +4 -2
- package/src/orchestrator/projects.ts +1 -1
- package/src/orchestrator/run-context.ts +1 -1
- package/src/orchestrator/run-lock.ts +10 -1
- package/src/workspace/config-cache.ts +15 -1
- package/src/workspace/config-eval.ts +20 -1
- package/src/workspace/project-loader.ts +18 -4
package/src/exec/runner.ts
CHANGED
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
// process exits. cpuMs / peakRssBytes are then surfaced on RunResult and
|
|
5
5
|
// folded into the v11 `runs` table by the orchestrator.
|
|
6
6
|
|
|
7
|
-
import {
|
|
7
|
+
import type { Pointer } from 'bun:ffi'
|
|
8
|
+
import { closeSync, existsSync, readFileSync } from 'node:fs'
|
|
8
9
|
import { constants as osConstants } from 'node:os'
|
|
9
10
|
import {
|
|
10
11
|
shellArgv,
|
|
@@ -98,11 +99,134 @@ export interface RunOptions {
|
|
|
98
99
|
terminal?: boolean
|
|
99
100
|
}
|
|
100
101
|
|
|
102
|
+
type Fd = 'inherit' | 'ignore' | 'pipe' | number
|
|
103
|
+
|
|
101
104
|
/** A child's descriptors 0–2: vx's own for a task that holds the terminal. */
|
|
102
|
-
function taskStdio(
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
105
|
+
function taskStdio(
|
|
106
|
+
terminal: boolean | undefined,
|
|
107
|
+
stdin: 'ignore' | 'pipe',
|
|
108
|
+
pipes: TaskPipes,
|
|
109
|
+
): [Fd, Fd, Fd] {
|
|
110
|
+
return terminal === true ? ['inherit', 'inherit', 'inherit'] : [stdin, ...pipes.stdio]
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const O_NONBLOCK = 0o4000
|
|
114
|
+
const O_CLOEXEC = 0o2000000
|
|
115
|
+
const F_SETFL = 4
|
|
116
|
+
const F_SETPIPE_SZ = 1031
|
|
117
|
+
/**
|
|
118
|
+
* A pipe holds 64 KiB by default, and a 200 MB stdout drained 13% slower
|
|
119
|
+
* through one than through Bun's socketpair (min of 7: 293 vs 259 ms);
|
|
120
|
+
* at 256 KiB it drained in 230. Past the user's pipe quota the kernel
|
|
121
|
+
* refuses the resize and the pipe keeps its 64 KiB.
|
|
122
|
+
*/
|
|
123
|
+
const PIPE_BYTES = 256 * 1024
|
|
124
|
+
|
|
125
|
+
type PipeCalls = {
|
|
126
|
+
pipe2: (fds: Pointer, flags: number) => number
|
|
127
|
+
fcntl: (fd: number, cmd: number, arg: number) => number
|
|
128
|
+
ptr: (view: Int32Array, byteOffset?: number) => Pointer
|
|
129
|
+
}
|
|
130
|
+
let pipeCalls: PipeCalls | null | undefined
|
|
131
|
+
|
|
132
|
+
/** libc's `pipe2` and `fcntl`, bound once; null off Linux or where libc will not load. */
|
|
133
|
+
function linuxPipeCalls(): PipeCalls | null {
|
|
134
|
+
if (pipeCalls !== undefined) return pipeCalls
|
|
135
|
+
pipeCalls = null
|
|
136
|
+
if (process.platform !== 'linux') return null
|
|
137
|
+
try {
|
|
138
|
+
// Loaded at the first spawn, not with the module: a static import cost
|
|
139
|
+
// every vx start 0.3 ms, a warm run that spawns nothing included.
|
|
140
|
+
const { dlopen, FFIType, ptr } = require('bun:ffi') as typeof import('bun:ffi')
|
|
141
|
+
// glibc's soname; musl's loader answers any `libc.` name with itself
|
|
142
|
+
// (ldso/dynlink.c's reserved names).
|
|
143
|
+
const { symbols } = dlopen('libc.so.6', {
|
|
144
|
+
pipe2: { args: [FFIType.ptr, FFIType.i32], returns: FFIType.i32 },
|
|
145
|
+
fcntl: { args: [FFIType.i32, FFIType.i32, FFIType.i32], returns: FFIType.i32 },
|
|
146
|
+
})
|
|
147
|
+
pipeCalls = { pipe2: symbols.pipe2, fcntl: symbols.fcntl, ptr }
|
|
148
|
+
} catch {
|
|
149
|
+
// Bun's socketpair, as before: only `/dev/stdout` by path fails on it.
|
|
150
|
+
}
|
|
151
|
+
return pipeCalls
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Two pipes, `[out read, out write, err read, err write]`, or undefined
|
|
156
|
+
* when libc is not bound or a descriptor is not to be had (Bun's own
|
|
157
|
+
* socketpair then fails the spawn with the reason).
|
|
158
|
+
*/
|
|
159
|
+
function realPipes(): Int32Array | undefined {
|
|
160
|
+
const calls = linuxPipeCalls()
|
|
161
|
+
if (calls === null) return undefined
|
|
162
|
+
const fds = new Int32Array(4)
|
|
163
|
+
// Bun's spawn closes every descriptor it is not handed (probed, Bun
|
|
164
|
+
// 1.4.2); O_CLOEXEC keeps any other exec from holding a write end open.
|
|
165
|
+
if (calls.pipe2(calls.ptr(fds), O_CLOEXEC) !== 0) return undefined
|
|
166
|
+
if (calls.pipe2(calls.ptr(fds, 8), O_CLOEXEC) !== 0) {
|
|
167
|
+
closeSync(fds[0]!)
|
|
168
|
+
closeSync(fds[1]!)
|
|
169
|
+
return undefined
|
|
170
|
+
}
|
|
171
|
+
// vx's ends only: a task whose stdout is non-blocking fails its writes with EAGAIN.
|
|
172
|
+
calls.fcntl(fds[0]!, F_SETFL, O_NONBLOCK)
|
|
173
|
+
calls.fcntl(fds[2]!, F_SETFL, O_NONBLOCK)
|
|
174
|
+
calls.fcntl(fds[0]!, F_SETPIPE_SZ, PIPE_BYTES)
|
|
175
|
+
calls.fcntl(fds[2]!, F_SETPIPE_SZ, PIPE_BYTES)
|
|
176
|
+
return fds
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* A task's stdout and stderr. Bun's `'pipe'` is a socketpair, and Linux
|
|
181
|
+
* opens `/dev/stdout` through `/proc/self/fd/1`, which a socket refuses
|
|
182
|
+
* (ENXIO): `echo x > /dev/stdout` and `cmd | tee /dev/stderr` failed in
|
|
183
|
+
* every task, where a terminal or Turbo hands real pipes (X-113). So on
|
|
184
|
+
* Linux vx makes the pipes (`pipe2` through `bun:ffi`) and hands the child
|
|
185
|
+
* the write ends. macOS opens `/dev/fd/N` as a dup of the descriptor,
|
|
186
|
+
* which a socket allows, so Bun's pipe stays there.
|
|
187
|
+
*/
|
|
188
|
+
export class TaskPipes {
|
|
189
|
+
/** Descriptors 1 and 2 for `Bun.spawn`'s `stdio`. */
|
|
190
|
+
readonly stdio: readonly [number | 'pipe', number | 'pipe']
|
|
191
|
+
readonly #read: readonly number[]
|
|
192
|
+
|
|
193
|
+
constructor(terminal?: boolean) {
|
|
194
|
+
const fds = terminal === true ? undefined : realPipes()
|
|
195
|
+
this.stdio = fds === undefined ? ['pipe', 'pipe'] : [fds[1]!, fds[3]!]
|
|
196
|
+
this.#read = fds === undefined ? [] : [fds[0]!, fds[2]!]
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Close vx's copies of the write ends once the spawn has returned or
|
|
201
|
+
* thrown: the child holds its own, and one left here is a writer the
|
|
202
|
+
* reader's EOF would wait for.
|
|
203
|
+
*/
|
|
204
|
+
spawned(): void {
|
|
205
|
+
for (const fd of this.stdio) if (typeof fd === 'number') closeSync(fd)
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** The child's stdout and stderr. */
|
|
209
|
+
streams(
|
|
210
|
+
child: ReturnType<typeof Bun.spawn>,
|
|
211
|
+
): [
|
|
212
|
+
ReadableStream<Uint8Array> | number | undefined,
|
|
213
|
+
ReadableStream<Uint8Array> | number | undefined,
|
|
214
|
+
] {
|
|
215
|
+
const [out, err] = this.#read
|
|
216
|
+
return out === undefined
|
|
217
|
+
? [child.stdout, child.stderr]
|
|
218
|
+
: [Bun.file(out).stream(), Bun.file(err!).stream()]
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Close the read ends, once both streams have ended or been cancelled.
|
|
223
|
+
* Bun reads a dup of each, but takes it on the stream's first pull: a
|
|
224
|
+
* number closed before then was reused by the next spawn's pipe, and
|
|
225
|
+
* the stream read THAT (probed, Bun 1.4.2).
|
|
226
|
+
*/
|
|
227
|
+
close(): void {
|
|
228
|
+
for (const fd of this.#read) closeSync(fd)
|
|
229
|
+
}
|
|
106
230
|
}
|
|
107
231
|
|
|
108
232
|
/**
|
|
@@ -598,6 +722,7 @@ export function runPersistent(opts: PersistentOptions): PersistentSpawn {
|
|
|
598
722
|
const readyRe = opts.readyWhen !== undefined ? new RegExp(opts.readyWhen) : undefined
|
|
599
723
|
|
|
600
724
|
let child: ReturnType<typeof Bun.spawn>
|
|
725
|
+
const pipes = new TaskPipes(opts.terminal)
|
|
601
726
|
const restoreModes = opts.terminal === true ? keepTerminalModes() : undefined
|
|
602
727
|
try {
|
|
603
728
|
const signalFd = opts.signalChannel === true
|
|
@@ -619,7 +744,7 @@ export function runPersistent(opts: PersistentOptions): PersistentSpawn {
|
|
|
619
744
|
// The one-shot spawn below keeps 'ignore', so a task that reads
|
|
620
745
|
// stdin can never hang CI.
|
|
621
746
|
stdio: [
|
|
622
|
-
...taskStdio(opts.terminal, 'pipe'),
|
|
747
|
+
...taskStdio(opts.terminal, 'pipe', pipes),
|
|
623
748
|
...(signalFd ? ['pipe' as const] : []),
|
|
624
749
|
...(guard === undefined ? [] : [guard]),
|
|
625
750
|
],
|
|
@@ -637,6 +762,7 @@ export function runPersistent(opts: PersistentOptions): PersistentSpawn {
|
|
|
637
762
|
void spawned.exited.then(() => closeSignalChannel(spawned))
|
|
638
763
|
}
|
|
639
764
|
} catch (err) {
|
|
765
|
+
pipes.close()
|
|
640
766
|
const message = err instanceof Error ? err.message : String(err)
|
|
641
767
|
return {
|
|
642
768
|
child: undefined as unknown as ReturnType<typeof Bun.spawn>,
|
|
@@ -645,6 +771,8 @@ export function runPersistent(opts: PersistentOptions): PersistentSpawn {
|
|
|
645
771
|
),
|
|
646
772
|
readyMs: () => Date.now() - start,
|
|
647
773
|
}
|
|
774
|
+
} finally {
|
|
775
|
+
pipes.spawned()
|
|
648
776
|
}
|
|
649
777
|
|
|
650
778
|
let resolveReady!: () => void
|
|
@@ -738,8 +866,10 @@ export function runPersistent(opts: PersistentOptions): PersistentSpawn {
|
|
|
738
866
|
|
|
739
867
|
// Wire up readers. We deliberately don't await them — they run for
|
|
740
868
|
// the child's lifetime. The `ready` promise resolves out-of-band.
|
|
741
|
-
|
|
742
|
-
void consumeChunks(
|
|
869
|
+
const [stdout, stderr] = pipes.streams(child)
|
|
870
|
+
void Promise.all([consumeChunks(stdout, false), consumeChunks(stderr, true)]).finally(() =>
|
|
871
|
+
pipes.close(),
|
|
872
|
+
)
|
|
743
873
|
|
|
744
874
|
opts.liveChildren?.add(child)
|
|
745
875
|
opts.onSpawn?.(child.pid)
|
|
@@ -834,6 +964,7 @@ export async function runCommand(opts: RunOptions): Promise<RunResult> {
|
|
|
834
964
|
const fullCommand = withForwardArgs(opts.command, opts.forwardArgs)
|
|
835
965
|
|
|
836
966
|
let proc: ReturnType<typeof Bun.spawn>
|
|
967
|
+
const pipes = new TaskPipes(opts.terminal)
|
|
837
968
|
const restoreModes = opts.terminal === true ? keepTerminalModes() : undefined
|
|
838
969
|
try {
|
|
839
970
|
proc = spawnGuarded((guard) =>
|
|
@@ -841,7 +972,10 @@ export async function runCommand(opts: RunOptions): Promise<RunResult> {
|
|
|
841
972
|
...SH_ARGV0,
|
|
842
973
|
cwd: opts.cwd,
|
|
843
974
|
env: opts.env as Record<string, string>,
|
|
844
|
-
stdio: [
|
|
975
|
+
stdio: [
|
|
976
|
+
...taskStdio(opts.terminal, 'ignore', pipes),
|
|
977
|
+
...(guard === undefined ? [] : [guard]),
|
|
978
|
+
],
|
|
845
979
|
// Its own session and process group, so a kill reaches what it
|
|
846
980
|
// forked (kill-tree.ts). A terminal handed over is not this
|
|
847
981
|
// session's controlling one, so no read stops the group.
|
|
@@ -849,19 +983,23 @@ export async function runCommand(opts: RunOptions): Promise<RunResult> {
|
|
|
849
983
|
}),
|
|
850
984
|
)
|
|
851
985
|
} catch (err) {
|
|
986
|
+
pipes.close()
|
|
852
987
|
const stderr = spawnFailureText(err, opts.cwd)
|
|
853
988
|
opts.onStderr?.(stderr)
|
|
854
989
|
return { exitCode: 127, durationMs: Date.now() - start, stdout: '', stderr, spawnFailed: true }
|
|
990
|
+
} finally {
|
|
991
|
+
pipes.spawned()
|
|
855
992
|
}
|
|
856
993
|
|
|
857
994
|
opts.liveChildren?.add(proc)
|
|
858
995
|
opts.onSpawn?.(proc.pid)
|
|
859
996
|
const timeout = armTimeout(proc, opts.timeoutMs)
|
|
860
997
|
const ac = new AbortController()
|
|
998
|
+
const [out, err] = pipes.streams(proc)
|
|
861
999
|
const streams = Promise.all([
|
|
862
|
-
streamToString(
|
|
863
|
-
streamToString(
|
|
864
|
-
])
|
|
1000
|
+
streamToString(out, opts.onStdout, ac.signal, opts.capture?.stdout ?? true),
|
|
1001
|
+
streamToString(err, opts.onStderr, ac.signal, opts.capture?.stderr ?? true),
|
|
1002
|
+
]).finally(() => pipes.close())
|
|
865
1003
|
// Gate on the child's own exit, not on stream EOF: an orphaned grandchild
|
|
866
1004
|
// can keep the pipe open past the child's exit (a timeout SIGTERM leaves the
|
|
867
1005
|
// grandchild alive; a NORMAL exit of `server & echo up` does too), so EOF may
|
|
@@ -55,6 +55,7 @@ import {
|
|
|
55
55
|
spawnFailureText,
|
|
56
56
|
streamToString,
|
|
57
57
|
resourceUsageToCpuRss,
|
|
58
|
+
TaskPipes,
|
|
58
59
|
type CaptureConfig,
|
|
59
60
|
type RunResult,
|
|
60
61
|
} from './runner.js'
|
|
@@ -1281,6 +1282,11 @@ async function wrapIn(
|
|
|
1281
1282
|
customConfig!.filesystem!.denyRead!.push(baselines.cwd)
|
|
1282
1283
|
}
|
|
1283
1284
|
customConfig!.filesystem!.allowWrite!.push(toRealPath(tmp))
|
|
1285
|
+
// Seatbelt judges `/dev/stdout` by the path the kernel resolves it to,
|
|
1286
|
+
// `/dev/fd/1`, which SRT's default `/dev/stdout` grant does not name, so
|
|
1287
|
+
// `echo x > /dev/stdout` failed EPERM (X-113). `/dev/fd/N` reopens only a
|
|
1288
|
+
// descriptor the task already holds.
|
|
1289
|
+
if (process.platform === 'darwin') customConfig!.filesystem!.allowWrite!.push('/dev/fd')
|
|
1284
1290
|
if (scopedDenyScan) {
|
|
1285
1291
|
customConfig!.filesystem!.denyWrite!.push(
|
|
1286
1292
|
...scopedMandatoryDenies(
|
|
@@ -1545,7 +1551,9 @@ function literalReadPaths(
|
|
|
1545
1551
|
* baseline reads: a linked dependency under `packages/[legacy]/` was a
|
|
1546
1552
|
* class too, and as an exact regex even `[[]` grants the directory's entry
|
|
1547
1553
|
* and none of its files, so its subtree is granted beside it (a trailing
|
|
1548
|
-
* `/**` is stripped before the compile).
|
|
1554
|
+
* `/**` is stripped before the compile). A task's read grant whose only
|
|
1555
|
+
* brackets are escaped names one path too, and Linux binds it whole, so
|
|
1556
|
+
* it gets the same subtree: `out/\[id\]` read `out/[id]` and none of it.
|
|
1549
1557
|
*/
|
|
1550
1558
|
export function seatbeltBrackets(
|
|
1551
1559
|
config: Parameters<SrtModule['SandboxManager']['wrapWithSandbox']>[2],
|
|
@@ -1556,8 +1564,9 @@ export function seatbeltBrackets(
|
|
|
1556
1564
|
const literal = (p: string): string => p.replaceAll('\\[', '[[]').replaceAll('\\]', ']')
|
|
1557
1565
|
const bracketed = new Set(names.filter((n) => /[[\]]/.test(n)))
|
|
1558
1566
|
const read = (p: string): string[] => {
|
|
1559
|
-
|
|
1560
|
-
|
|
1567
|
+
const name = bracketed.has(p) ? p : /\\[[\]]/.test(p) ? namedPath(p) : undefined
|
|
1568
|
+
if (name === undefined) return [literal(p)]
|
|
1569
|
+
const at = name.replaceAll('[', '[[]')
|
|
1561
1570
|
return [at, `${at}/**/*`]
|
|
1562
1571
|
}
|
|
1563
1572
|
return {
|
|
@@ -1566,7 +1575,9 @@ export function seatbeltBrackets(
|
|
|
1566
1575
|
...fs,
|
|
1567
1576
|
denyRead: fs.denyRead.map((p) => p.replaceAll('[', '[[]')),
|
|
1568
1577
|
allowWrite: fs.allowWrite.map(literal),
|
|
1569
|
-
...(fs.allowRead !== undefined
|
|
1578
|
+
...(fs.allowRead !== undefined
|
|
1579
|
+
? { allowRead: [...new Set(fs.allowRead.flatMap(read))] }
|
|
1580
|
+
: {}),
|
|
1570
1581
|
},
|
|
1571
1582
|
}
|
|
1572
1583
|
}
|
|
@@ -1977,6 +1988,7 @@ async function runSandboxedOnce(
|
|
|
1977
1988
|
if (straceLog) unlinkOnExit(straceLog)
|
|
1978
1989
|
let proc: ReturnType<typeof Bun.spawn>
|
|
1979
1990
|
let traceFd: number | undefined
|
|
1991
|
+
const pipes = new TaskPipes()
|
|
1980
1992
|
try {
|
|
1981
1993
|
// Resolved on vx's own PATH (util/which.ts), not the task's, where a
|
|
1982
1994
|
// project's node_modules/.bin comes first.
|
|
@@ -1991,8 +2003,7 @@ async function runSandboxedOnce(
|
|
|
1991
2003
|
env: args.env as Record<string, string>,
|
|
1992
2004
|
stdio: [
|
|
1993
2005
|
'ignore',
|
|
1994
|
-
|
|
1995
|
-
'pipe',
|
|
2006
|
+
...pipes.stdio,
|
|
1996
2007
|
forwardsSignals ? 'pipe' : 'ignore',
|
|
1997
2008
|
guard ?? 'ignore',
|
|
1998
2009
|
...(traceFd === undefined ? [] : [traceFd]),
|
|
@@ -2003,6 +2014,7 @@ async function runSandboxedOnce(
|
|
|
2003
2014
|
)
|
|
2004
2015
|
if (forwardsSignals) signalThrough(proc, proc.stdio[3] as number)
|
|
2005
2016
|
} catch (err) {
|
|
2017
|
+
pipes.close()
|
|
2006
2018
|
if (straceLog) {
|
|
2007
2019
|
rmSync(straceLog, { force: true })
|
|
2008
2020
|
liveTempFiles.delete(straceLog)
|
|
@@ -2025,6 +2037,7 @@ async function runSandboxedOnce(
|
|
|
2025
2037
|
} finally {
|
|
2026
2038
|
// The child holds its own copy; ours would keep nothing but a descriptor.
|
|
2027
2039
|
if (traceFd !== undefined) closeSync(traceFd)
|
|
2040
|
+
pipes.spawned()
|
|
2028
2041
|
}
|
|
2029
2042
|
|
|
2030
2043
|
args.liveChildren?.add(proc)
|
|
@@ -2035,10 +2048,11 @@ async function runSandboxedOnce(
|
|
|
2035
2048
|
// The unfinished last line of stderr, and whether a line was strace's.
|
|
2036
2049
|
let partial = ''
|
|
2037
2050
|
let straceSpoke = false
|
|
2051
|
+
const [out, err] = pipes.streams(proc)
|
|
2038
2052
|
const streams = Promise.all([
|
|
2039
|
-
streamToString(
|
|
2053
|
+
streamToString(out, args.onStdout, ac.signal, args.capture?.stdout ?? true),
|
|
2040
2054
|
streamToString(
|
|
2041
|
-
|
|
2055
|
+
err,
|
|
2042
2056
|
(chunk) => {
|
|
2043
2057
|
// Read whatever the capture setting: a line of strace's own says
|
|
2044
2058
|
// the trace stopped short.
|
|
@@ -2050,7 +2064,7 @@ async function runSandboxedOnce(
|
|
|
2050
2064
|
ac.signal,
|
|
2051
2065
|
args.capture?.stderr ?? true,
|
|
2052
2066
|
),
|
|
2053
|
-
])
|
|
2067
|
+
]).finally(() => pipes.close())
|
|
2054
2068
|
// See runCommand: gate on child exit; a lingering grandchild pipe (timeout
|
|
2055
2069
|
// OR a clean exit that backgrounds a process) can't hang the run — timeout
|
|
2056
2070
|
// aborts at once, otherwise drainOrAbort bounds the post-exit drain.
|
package/src/graph/priorities.ts
CHANGED
|
@@ -152,8 +152,7 @@ export function mergePriorities(
|
|
|
152
152
|
// it sorts above the baseline for any node it covers, and add the
|
|
153
153
|
// baseline as a tie-break for parity within the override set.
|
|
154
154
|
const SCALE = 1 << 20
|
|
155
|
-
const out = new Map
|
|
156
|
-
for (const [id, w] of baseline) out.set(id, w)
|
|
155
|
+
const out = new Map(baseline)
|
|
157
156
|
for (const [id, w] of overrides) {
|
|
158
157
|
const b = baseline.get(id) ?? 0
|
|
159
158
|
out.set(id, w * SCALE + b)
|
package/src/index.ts
CHANGED
|
@@ -34,7 +34,9 @@ export { isLiteralPattern, normalizeGlob } from './util/index.js'
|
|
|
34
34
|
// A task's command as vx prints it: values of its `exec.env.secret` names
|
|
35
35
|
// masked. `vx show` and telemetry show this; a plugin that hands a command
|
|
36
36
|
// to someone else (`@vzn/vx-mcp`'s `listTasks`, an AI agent) does too (L-26).
|
|
37
|
-
|
|
37
|
+
// `maskedLine` is the same for a line no one task owns: the error `vx mcp`
|
|
38
|
+
// hands an agent (L-11).
|
|
39
|
+
export { maskedCommand, maskedLine } from './util/index.js'
|
|
38
40
|
|
|
39
41
|
// Schema types and helpers (used by user vx.config files and presets).
|
|
40
42
|
export type {
|
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
isUnsupportedBun,
|
|
17
17
|
machineMemoryBytes,
|
|
18
18
|
machineParallelism,
|
|
19
|
+
maskedLine,
|
|
19
20
|
relPosix,
|
|
20
21
|
} from '../util/index.js'
|
|
21
22
|
import { VERSION } from '../version.js'
|
|
@@ -397,7 +398,8 @@ async function countLoadableTasks(
|
|
|
397
398
|
: raw
|
|
398
399
|
errors.push({
|
|
399
400
|
path: relPosix(root, where),
|
|
400
|
-
|
|
401
|
+
// A config's own throw is its author's text, a secret too (L-11).
|
|
402
|
+
message: maskedLine(message),
|
|
401
403
|
})
|
|
402
404
|
}
|
|
403
405
|
}),
|
|
@@ -462,7 +462,7 @@ async function executePersistentTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
462
462
|
const after = noticeMs < 1000 ? `${noticeMs} ms` : `${noticeMs / 1000} s`
|
|
463
463
|
const unbounded = effectiveTimeout === undefined ? ', with no exec.timeout' : ''
|
|
464
464
|
log.status(
|
|
465
|
-
`vx: ${node.id} not ready after ${after}: waiting for a line matching /${readyWhen}/ (readyWhen)${unbounded}`,
|
|
465
|
+
`vx: ${node.id} not ready after ${after}: waiting for a line matching /${serverSecrets?.mask(readyWhen) ?? readyWhen}/ (readyWhen)${unbounded}`,
|
|
466
466
|
)
|
|
467
467
|
}, noticeMs)
|
|
468
468
|
if (notice !== undefined) {
|
|
@@ -867,6 +867,24 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
867
867
|
for (const c of captured)
|
|
868
868
|
if (c.kind === 'env' && named.includes(c.name) && !secretNamed(c.name))
|
|
869
869
|
c.hash = MASKED + c.hash
|
|
870
|
+
// A runtime probe's row is named by its command, which a TS config may
|
|
871
|
+
// build from `process.env`: the run history stored it and `vx why`
|
|
872
|
+
// printed it (L-11).
|
|
873
|
+
const nameSecrets =
|
|
874
|
+
captured.length > 0 ? secretMask([process.env, env, step.env?.define], step.env?.secret) : null
|
|
875
|
+
if (nameSecrets !== null) {
|
|
876
|
+
// Two probes that differ only by a secret mask to one name, and a row
|
|
877
|
+
// is stored once per name: numbered, the second's change still shows.
|
|
878
|
+
const seen = new Set(captured.map((c) => `${c.kind}\0${c.name}`))
|
|
879
|
+
for (const c of captured) {
|
|
880
|
+
const masked = nameSecrets.mask(c.name)
|
|
881
|
+
if (masked === c.name) continue
|
|
882
|
+
let name = masked
|
|
883
|
+
for (let n = 2; seen.has(`${c.kind}\0${name}`); n++) name = `${masked} (${n})`
|
|
884
|
+
seen.add(`${c.kind}\0${name}`)
|
|
885
|
+
c.name = name
|
|
886
|
+
}
|
|
887
|
+
}
|
|
870
888
|
const inputs: TaskInputs | undefined = described?.inputs
|
|
871
889
|
const inputChanges = described !== undefined ? args.explainMiss?.(node.id, captured) : undefined
|
|
872
890
|
// A plugin may keep the request past the run (the cache closed): it gets
|
|
@@ -1493,7 +1511,8 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
1493
1511
|
? {
|
|
1494
1512
|
// vx's own notes ride with the lines and are no denial (B-20).
|
|
1495
1513
|
sandboxViolations: finalViolations.filter((v) => v.hint !== true).length,
|
|
1496
|
-
|
|
1514
|
+
// A denied path is the task's own spelling, a secret in it too.
|
|
1515
|
+
sandboxViolationLines: finalViolations.map((v) => secrets?.mask(v.line) ?? v.line),
|
|
1497
1516
|
}
|
|
1498
1517
|
: {}),
|
|
1499
1518
|
}
|
|
@@ -130,7 +130,7 @@ export async function restoreHit(restore: RestoreHitArgs): Promise<TaskOutcome>
|
|
|
130
130
|
// set that shrank would otherwise vouch for a tree it never saw,
|
|
131
131
|
// and the failure it would let through is a skipped restore.
|
|
132
132
|
const covers = dirPrefixes.every((pre) => dirRows.some((r) => r.path === pre))
|
|
133
|
-
setKnown = covers && (await args.cache.outputDirsCurrent
|
|
133
|
+
setKnown = covers && (await args.cache.outputDirsCurrent(node.projectDir, dirRows))
|
|
134
134
|
endDirs()
|
|
135
135
|
}
|
|
136
136
|
let treeMatches: boolean
|
|
@@ -71,6 +71,7 @@ interface Digests {
|
|
|
71
71
|
|
|
72
72
|
interface Memo {
|
|
73
73
|
version: number
|
|
74
|
+
claimant: string
|
|
74
75
|
lock: string
|
|
75
76
|
/** `extraFiles` → content hash when the digests were computed. */
|
|
76
77
|
extras?: Record<string, string>
|
|
@@ -120,6 +121,11 @@ export function lockfileClaim(options: LockfileClaimOptions): LockfileClaimHooks
|
|
|
120
121
|
const { file, digest, version } = options
|
|
121
122
|
const scope = options.scope ?? 'project'
|
|
122
123
|
const part = options.part ?? 'deps'
|
|
124
|
+
// `version` is one claimant's counter, and another claimant of the file
|
|
125
|
+
// at the same number read this one's memo: one that named no extra file
|
|
126
|
+
// left the other's patch edits out of its key (X-130). The functions'
|
|
127
|
+
// source stands for the claimant; a rebuild that changes it is a miss.
|
|
128
|
+
const claimant = xxh3hex([part, String(digest), String(options.extraFiles ?? '')].join('\0'))
|
|
123
129
|
if (scope !== 'project' && scope !== 'workspace') {
|
|
124
130
|
throw new Error(`scope must be 'project' or 'workspace', not ${JSON.stringify(scope)}`)
|
|
125
131
|
}
|
|
@@ -159,7 +165,7 @@ export function lockfileClaim(options: LockfileClaimOptions): LockfileClaimHooks
|
|
|
159
165
|
const memoFile = path.join(cacheDir, 'lockfile-claims', `${file}.json`)
|
|
160
166
|
// The extra files the last digest read, and their hashes now: a warm run
|
|
161
167
|
// re-hashes the files the memo names and never parses to learn them.
|
|
162
|
-
const memo = await readMemo(memoFile, version, lockHash)
|
|
168
|
+
const memo = await readMemo(memoFile, version, claimant, lockHash)
|
|
163
169
|
let extras: Record<string, string> = {}
|
|
164
170
|
let extraStats: Record<string, string> = {}
|
|
165
171
|
let importers: ReadonlyMap<string, string> | undefined
|
|
@@ -182,8 +188,7 @@ export function lockfileClaim(options: LockfileClaimOptions): LockfileClaimHooks
|
|
|
182
188
|
? new Map<string, string>()
|
|
183
189
|
: await computeAndMemo(
|
|
184
190
|
memoFile,
|
|
185
|
-
version,
|
|
186
|
-
lockHash,
|
|
191
|
+
{ version, claimant, lock: lockHash },
|
|
187
192
|
extras,
|
|
188
193
|
digest(text, new Map(Object.entries(extras))),
|
|
189
194
|
)
|
|
@@ -273,11 +278,14 @@ function importerOf(workspaceRoot: string, projectDir: string): string {
|
|
|
273
278
|
async function readMemo(
|
|
274
279
|
memoFile: string,
|
|
275
280
|
version: number,
|
|
281
|
+
claimant: string,
|
|
276
282
|
lock: string,
|
|
277
283
|
): Promise<{ extras: Record<string, string>; importers: Map<string, string> } | undefined> {
|
|
278
284
|
try {
|
|
279
285
|
const memo = (await Bun.file(memoFile).json()) as Memo
|
|
280
|
-
if (memo.version !== version || memo.lock !== lock)
|
|
286
|
+
if (memo.version !== version || memo.claimant !== claimant || memo.lock !== lock) {
|
|
287
|
+
return undefined
|
|
288
|
+
}
|
|
281
289
|
return { extras: memo.extras ?? {}, importers: new Map(Object.entries(memo.importers)) }
|
|
282
290
|
} catch {
|
|
283
291
|
return undefined
|
|
@@ -286,12 +294,11 @@ async function readMemo(
|
|
|
286
294
|
|
|
287
295
|
async function computeAndMemo(
|
|
288
296
|
memoFile: string,
|
|
289
|
-
|
|
290
|
-
lock: string,
|
|
297
|
+
identity: Pick<Memo, 'version' | 'claimant' | 'lock'>,
|
|
291
298
|
extras: Record<string, string>,
|
|
292
299
|
importers: ReadonlyMap<string, string>,
|
|
293
300
|
): Promise<ReadonlyMap<string, string>> {
|
|
294
|
-
const memo: Memo = {
|
|
301
|
+
const memo: Memo = { ...identity, extras, importers: Object.fromEntries(importers) }
|
|
295
302
|
// Write-then-rename: a reader never sees a half-written memo, and two
|
|
296
303
|
// concurrent runs each land a whole one.
|
|
297
304
|
const tmp = `${memoFile}.tmp-${process.pid}-${Date.now()}`
|
|
@@ -518,9 +518,11 @@ export function defaultLogger(
|
|
|
518
518
|
killStatus()
|
|
519
519
|
// Persistent tails first: they are context for whatever ran against
|
|
520
520
|
// the server, so they read above the failures they explain. `none`
|
|
521
|
-
// and `errors-only`
|
|
522
|
-
//
|
|
523
|
-
//
|
|
521
|
+
// prints no per-task output and `errors-only` only failed tasks: a
|
|
522
|
+
// server that died on its own before the run stopped it is failed by
|
|
523
|
+
// now (run.ts sets its outcome in place), and under `errors-only` its
|
|
524
|
+
// tail was dropped with the healthy ones', so the crash read only as a
|
|
525
|
+
// count (WD-25). The rest is still captured and bounded. Guarded
|
|
524
526
|
// like the failures below: run() calls runEnd twice on the success
|
|
525
527
|
// path (once before the summary, once in its finally), and though the
|
|
526
528
|
// bus delivers `run:end` once (`busLogger`), a renderer an embedder
|
|
@@ -528,12 +530,13 @@ export function defaultLogger(
|
|
|
528
530
|
if (flushedPersistent) flushKept()
|
|
529
531
|
if (!flushedPersistent) {
|
|
530
532
|
flushedPersistent = true
|
|
531
|
-
if (view.mode !== 'none'
|
|
533
|
+
if (view.mode !== 'none') {
|
|
532
534
|
for (const t of persistentTails.values()) {
|
|
533
535
|
// No outcome means the task never completed — it is still
|
|
534
536
|
// pre-ready, so there is no "since ready" window to render and
|
|
535
537
|
// its buffered output belongs to the frame it never got.
|
|
536
538
|
if (t.outcome === undefined) continue
|
|
539
|
+
if (view.mode === 'errors-only' && t.outcome.status !== 'failed') continue
|
|
537
540
|
emitBlock(
|
|
538
541
|
fenced(
|
|
539
542
|
formatPersistentTailBlock(
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
import path from 'node:path'
|
|
11
11
|
import { ChainedCache, type CacheLayer } from '../cache/index.js'
|
|
12
12
|
import { localExecutor, type TaskExecutor } from '../exec/index.js'
|
|
13
|
-
import { settleWithin, teardownTimeoutMs, UserError } from '../util/index.js'
|
|
13
|
+
import { maskedLine, settleWithin, teardownTimeoutMs, UserError } from '../util/index.js'
|
|
14
14
|
import type { ProjectConfig, WorkspaceConfig, WorkspaceRules } from '../config.js'
|
|
15
15
|
import { checkGraph, type TaskNode } from '../graph/index.js'
|
|
16
16
|
import type {
|
|
@@ -86,8 +86,13 @@ async function safe<T>(plugin: VxPlugin, hook: string, fn: () => T | Promise<T>)
|
|
|
86
86
|
try {
|
|
87
87
|
return await fn()
|
|
88
88
|
} catch (err) {
|
|
89
|
+
// Masked here, not by whoever prints it: an embedder of `run()` and
|
|
90
|
+
// `vx mcp` print it as they get it, and the throw quotes what the
|
|
91
|
+
// plugin was told (a remote's reply, a header) (L-11).
|
|
89
92
|
throw new UserError(
|
|
90
|
-
|
|
93
|
+
maskedLine(
|
|
94
|
+
`plugin '${plugin.name}' failed in ${hook}: ${err instanceof Error ? err.message : String(err)}`,
|
|
95
|
+
),
|
|
91
96
|
)
|
|
92
97
|
}
|
|
93
98
|
}
|
|
@@ -15,7 +15,7 @@ import type { Cache, CacheLayer, CachePolicy } from '../cache/index.js'
|
|
|
15
15
|
import { PLUGIN_PACKAGE, type ProjectConfig, type WorkspaceConfig } from '../config.js'
|
|
16
16
|
import type { TaskExecutor } from '../exec/index.js'
|
|
17
17
|
import type { TaskNode, TaskOutcome } from '../graph/index.js'
|
|
18
|
-
import { nearest, UserError } from '../util/index.js'
|
|
18
|
+
import { maskedLine, nearest, UserError } from '../util/index.js'
|
|
19
19
|
import type { ProjectMeta } from '../workspace/index.js'
|
|
20
20
|
import type { EventBus, RunStartInfo } from './events.js'
|
|
21
21
|
import type { TelemetryContext, TelemetrySink } from './telemetry.js'
|
|
@@ -716,7 +716,10 @@ export async function installPlugins(args: InstallPluginsArgs): Promise<() => vo
|
|
|
716
716
|
for (const d of disposers) d()
|
|
717
717
|
throw new PluginSetupError(
|
|
718
718
|
plugin,
|
|
719
|
-
|
|
719
|
+
// Masked at the source, as plugin-host.ts's `safe()` does (L-11).
|
|
720
|
+
maskedLine(
|
|
721
|
+
`plugin '${plugin.name}' failed in setup: ${err instanceof Error ? err.message : String(err)}`,
|
|
722
|
+
),
|
|
720
723
|
)
|
|
721
724
|
}
|
|
722
725
|
}
|
|
@@ -379,9 +379,11 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
|
|
|
379
379
|
// it, and the run said no project did (C-3). Only what is still
|
|
380
380
|
// unjudged pays for the rest of the workspace.
|
|
381
381
|
unresolvedTasks = undeclaredIn(unresolvedTasks, projects)
|
|
382
|
+
// By name, not by count: a config-less project in the scope counts as
|
|
383
|
+
// loaded, and a changed one hid an unloaded `test` declarer (X-129).
|
|
382
384
|
if (
|
|
383
|
-
|
|
384
|
-
|
|
385
|
+
unresolvedTasks.some((t) => !t.includes('#')) &&
|
|
386
|
+
projectsWithConfigs.some((m) => !projects.has(m.name))
|
|
385
387
|
) {
|
|
386
388
|
unresolvedTasks = await declaredNowhere(unresolvedTasks, () =>
|
|
387
389
|
loadProjects({ ...loadArgs, seeds: 'all', closure: false, staged: projects }),
|
|
@@ -321,7 +321,7 @@ const DEFAULT_BUILD: TaskConfig = Object.freeze({
|
|
|
321
321
|
inputs: Object.freeze({ files: Object.freeze(['**']) }),
|
|
322
322
|
outputs: Object.freeze({ files: Object.freeze([]) }),
|
|
323
323
|
}),
|
|
324
|
-
})
|
|
324
|
+
})
|
|
325
325
|
|
|
326
326
|
/** The default `build`, which a menu of the tasks a user declared leaves out. */
|
|
327
327
|
export function isDefaultBuild(task: TaskConfig | undefined): boolean {
|
|
@@ -379,7 +379,7 @@ function ciLinks(provider: string, env: Record<string, string | undefined>): CiL
|
|
|
379
379
|
default:
|
|
380
380
|
return {}
|
|
381
381
|
}
|
|
382
|
-
return Object.fromEntries(Object.entries(links).filter(([, x]) => x !== undefined))
|
|
382
|
+
return Object.fromEntries(Object.entries(links).filter(([, x]) => x !== undefined))
|
|
383
383
|
}
|
|
384
384
|
|
|
385
385
|
/** Host name (null on failure) + platform + arch. */
|
|
@@ -66,7 +66,8 @@ import { rmdirSync, unlinkSync } from 'node:fs'
|
|
|
66
66
|
import { mkdir, readdir, readFile, rename, rm, rmdir, unlink, writeFile } from 'node:fs/promises'
|
|
67
67
|
import os from 'node:os'
|
|
68
68
|
import path from 'node:path'
|
|
69
|
-
import {
|
|
69
|
+
import { VX_RUN_TASK_ENV, VX_RUN_WORKSPACE_ENV } from '../exec/index.js'
|
|
70
|
+
import { isTmpdirRefusal, procfsIsOwn, TMPDIR_HINT, UserError, xxh3hex } from '../util/index.js'
|
|
70
71
|
|
|
71
72
|
/** Runs in this process currently holding the lock, per lock directory. */
|
|
72
73
|
const heldHere = new Map<string, number>()
|
|
@@ -329,6 +330,14 @@ export async function acquireRunLock(
|
|
|
329
330
|
opts: RunLockOptions,
|
|
330
331
|
): Promise<() => Promise<void>> {
|
|
331
332
|
const lockDir = runLockPath(workspaceRoot, opts.dir)
|
|
333
|
+
// A task's own process (`vx cache prune` in a task) would wait for the
|
|
334
|
+
// run that started it, which holds the lock until that task ends.
|
|
335
|
+
const outer = process.env[VX_RUN_WORKSPACE_ENV]
|
|
336
|
+
if (outer !== undefined && runLockPath(outer, opts.dir) === lockDir) {
|
|
337
|
+
throw new UserError(
|
|
338
|
+
`this vx runs inside task ${process.env[VX_RUN_TASK_ENV] ?? '<unknown>'} of a vx run on this workspace, which holds its run lock until it ends — run it before or after that run, not from one of its tasks`,
|
|
339
|
+
)
|
|
340
|
+
}
|
|
332
341
|
const started = Date.now()
|
|
333
342
|
let said = false
|
|
334
343
|
const release = async (): Promise<void> => {
|