@vzn/vx 0.0.646 → 0.0.649
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/README.md +29 -22
- package/launcher.cjs +9 -2
- package/launcher.sh +2 -2
- package/package.json +5 -5
- package/schemas/cache.json +5 -5
- package/schemas/error.json +1 -1
- package/schemas/info.json +16 -2
- package/schemas/last.json +112 -5
- package/schemas/plan.json +28 -2
- package/schemas/show.json +10 -1
- package/skills/vx/SKILL.md +18 -2
- package/src/bin.ts +53 -12
- package/src/cache/archive.ts +324 -23
- package/src/cache/cache.ts +268 -53
- package/src/cache/git-inputs.ts +219 -87
- package/src/cache/index.ts +3 -2
- package/src/cache/inputs.ts +460 -78
- package/src/cache/key-fold.ts +15 -6
- package/src/cache/layer.ts +49 -9
- package/src/cache/layered-cache.ts +123 -58
- package/src/cache/linked-inputs.ts +1 -1
- package/src/cache/output-index.ts +19 -3
- package/src/cache/plugin-cache.ts +49 -23
- package/src/cache/remote-hits.ts +72 -0
- package/src/cache/remote-lane.ts +90 -14
- package/src/cache/run-history.ts +70 -23
- package/src/cache/run-store.ts +11 -10
- package/src/cache/schema.ts +25 -6
- package/src/cache/tar-stream.ts +5 -0
- package/src/cache/umask.ts +1 -2
- package/src/cli/cache.ts +58 -27
- package/src/cli/completions.ts +12 -5
- package/src/cli/docs.ts +42 -16
- package/src/cli/help.ts +58 -19
- package/src/cli/index.ts +79 -33
- package/src/cli/info.ts +12 -6
- package/src/cli/init.ts +186 -31
- package/src/cli/last.ts +439 -45
- package/src/cli/lock.ts +197 -52
- package/src/cli/plan-format.ts +91 -49
- package/src/cli/plugin-commands.ts +70 -14
- package/src/cli/plugin-templates.ts +13 -13
- package/src/cli/registry.ts +209 -0
- package/src/cli/run.ts +109 -34
- package/src/cli/select.ts +143 -46
- package/src/cli/show.ts +59 -16
- package/src/cli/task-verb.ts +16 -6
- package/src/cli/upgrade.ts +151 -49
- package/src/cli/watch-cycle.ts +10 -5
- package/src/cli/watch-filter.ts +71 -3
- package/src/cli/watch-fs.ts +22 -6
- package/src/cli/watch-judge.ts +48 -6
- package/src/cli/watch.ts +163 -42
- package/src/cli/why.ts +211 -73
- package/src/cli/workspace-config.ts +54 -15
- package/src/config.ts +13 -1
- package/src/exec/env.ts +7 -2
- package/src/exec/executor.ts +154 -10
- package/src/exec/index.ts +6 -2
- package/src/exec/kill-tree.ts +15 -0
- package/src/exec/local-executor.ts +1 -1
- package/src/exec/proc-sample.ts +3 -1
- package/src/exec/runner.ts +253 -54
- package/src/exec/sandbox-binds.ts +7 -3
- package/src/exec/sandbox-paths.ts +1 -1
- package/src/exec/sandbox-runtime.ts +211 -24
- package/src/exec/sandbox-violations.ts +52 -20
- package/src/graph/critical-path.ts +79 -0
- package/src/graph/default-build.ts +19 -0
- package/src/graph/definition.ts +38 -0
- package/src/graph/folds.ts +38 -5
- package/src/graph/index.ts +4 -1
- package/src/graph/priorities.ts +74 -8
- package/src/graph/scheduler.ts +270 -60
- package/src/graph/task-graph.ts +228 -35
- package/src/handoff.ts +97 -9
- package/src/history/failure-mode.ts +6 -3
- package/src/history/history.ts +167 -61
- package/src/history/index.ts +2 -0
- package/src/history/metrics.ts +90 -30
- package/src/index.ts +30 -13
- package/src/orchestrator/admission.ts +10 -6
- package/src/orchestrator/affected-tasks.ts +11 -6
- package/src/orchestrator/deferred-outputs.ts +2 -0
- package/src/orchestrator/doctor.ts +134 -42
- package/src/orchestrator/execute-args.ts +4 -1
- package/src/orchestrator/execute-task.ts +130 -38
- package/src/orchestrator/fingerprint-watch.ts +1 -1
- package/src/orchestrator/graph-memo.ts +140 -0
- package/src/orchestrator/graph-stage.ts +18 -1
- package/src/orchestrator/held-servers.ts +1 -1
- package/src/orchestrator/hit-restore.ts +217 -119
- package/src/orchestrator/index.ts +4 -3
- package/src/orchestrator/local-floor.ts +119 -25
- package/src/orchestrator/local-shortcircuit.ts +28 -4
- package/src/orchestrator/lockfile-claim.ts +96 -16
- package/src/orchestrator/miss-save.ts +42 -8
- package/src/orchestrator/options.ts +8 -6
- package/src/orchestrator/output-host.ts +43 -14
- package/src/orchestrator/output-log.ts +90 -10
- package/src/orchestrator/persistent-task.ts +84 -29
- package/src/orchestrator/persistent.ts +16 -4
- package/src/orchestrator/placement.ts +185 -17
- package/src/orchestrator/plan.ts +64 -7
- package/src/orchestrator/plugin-host.ts +214 -42
- package/src/orchestrator/plugin.ts +113 -37
- package/src/orchestrator/prepare.ts +168 -30
- package/src/orchestrator/projects.ts +65 -40
- package/src/orchestrator/remote-prefetch.ts +119 -25
- package/src/orchestrator/run-artifacts.ts +95 -32
- package/src/orchestrator/run-context.ts +39 -41
- package/src/orchestrator/run-failures.ts +65 -23
- package/src/orchestrator/run-id.ts +6 -3
- package/src/orchestrator/run-keys.ts +149 -13
- package/src/orchestrator/run-live.ts +294 -0
- package/src/orchestrator/run-lock.ts +13 -7
- package/src/orchestrator/run-records.ts +35 -29
- package/src/orchestrator/run-report.ts +3 -3
- package/src/orchestrator/run-steps.ts +58 -22
- package/src/orchestrator/run-stop.ts +22 -5
- package/src/orchestrator/run.ts +331 -88
- package/src/orchestrator/sandbox-fix.ts +7 -5
- package/src/orchestrator/sandbox-request.ts +70 -20
- package/src/orchestrator/shell-verdict.ts +5 -5
- package/src/orchestrator/signals.ts +26 -1
- package/src/orchestrator/stable-keys.ts +92 -8
- package/src/orchestrator/steps.ts +2 -2
- package/src/orchestrator/task-artifacts.ts +90 -26
- package/src/orchestrator/task-hash.ts +78 -13
- package/src/orchestrator/task-log-buffer.ts +72 -36
- package/src/orchestrator/task-process.ts +7 -1
- package/src/orchestrator/telemetry-host.ts +22 -2
- package/src/orchestrator/telemetry.ts +53 -21
- package/src/orchestrator/upstream.ts +15 -0
- package/src/report/events.ts +32 -13
- package/src/report/forecast.ts +31 -9
- package/src/report/framed-output.ts +141 -50
- package/src/report/index.ts +1 -0
- package/src/report/logger.ts +170 -179
- package/src/report/path-links.ts +12 -4
- package/src/report/plain-output.ts +13 -5
- package/src/report/status-line.ts +81 -53
- package/src/report/summary.ts +114 -32
- package/src/report/terminal-signals.ts +13 -2
- package/src/settings.ts +7 -6
- package/src/util/bun-version.ts +4 -1
- package/src/util/colors.ts +4 -2
- package/src/util/config-frame.ts +41 -4
- package/src/{graph → util}/dependency-spec.ts +22 -13
- package/src/util/edit-distance.ts +13 -1
- package/src/util/env-flag.ts +9 -0
- package/src/util/errors.ts +59 -3
- package/src/util/essential-env.ts +57 -0
- package/src/util/git-ignored.ts +1 -1
- package/src/util/hash.ts +35 -0
- package/src/util/hide-env.ts +45 -5
- package/src/util/index.ts +21 -5
- package/src/util/num.ts +8 -0
- package/src/util/paths.ts +91 -21
- package/src/util/report.ts +6 -1
- package/src/util/secret-mask.ts +62 -1
- package/src/util/secrets.ts +7 -2
- package/src/util/size.ts +11 -3
- package/src/util/tail.ts +6 -1
- package/src/util/timing.ts +13 -1
- package/src/util/ui.ts +61 -3
- package/src/util/which.ts +1 -1
- package/src/util/write-file.ts +67 -0
- package/src/workspace/affected.ts +710 -107
- package/src/workspace/bracket-literal.ts +84 -0
- package/src/workspace/canonical-dir.ts +37 -0
- package/src/workspace/config-bytecode.ts +17 -11
- package/src/workspace/config-eval.ts +29 -8
- package/src/workspace/config-imports.ts +199 -58
- package/src/workspace/config-schema.ts +242 -39
- package/src/workspace/filter.ts +37 -8
- package/src/workspace/index.ts +11 -1
- package/src/workspace/json-data.ts +3 -4
- package/src/workspace/lockfile.ts +57 -8
- package/src/workspace/migration.ts +69 -18
- package/src/workspace/package-graph.ts +5 -4
- package/src/workspace/project-loader.ts +226 -37
- package/src/workspace/repo-id.ts +1 -1
- package/src/workspace/workspace.ts +176 -33
package/src/exec/runner.ts
CHANGED
|
@@ -8,11 +8,13 @@ import type { Pointer } from 'bun:ffi'
|
|
|
8
8
|
import { closeSync, existsSync, readFileSync } from 'node:fs'
|
|
9
9
|
import { constants as osConstants } from 'node:os'
|
|
10
10
|
import {
|
|
11
|
+
formatBytes,
|
|
11
12
|
shellArgv,
|
|
12
13
|
isExecutableMissing,
|
|
13
14
|
isOutOfFds,
|
|
14
15
|
killGraceMs,
|
|
15
16
|
OUT_OF_FDS_HINT,
|
|
17
|
+
helperEnv,
|
|
16
18
|
type SecretMask,
|
|
17
19
|
} from '../util/index.js'
|
|
18
20
|
import {
|
|
@@ -35,10 +37,11 @@ export interface RunResult {
|
|
|
35
37
|
durationMs: number
|
|
36
38
|
stdout: string
|
|
37
39
|
stderr: string
|
|
38
|
-
/** The signal that killed the child, if any (Bun's `signalCode`)
|
|
40
|
+
/** The signal that killed the child, if any (Bun's `signalCode`): its
|
|
41
|
+
* name, or its number when it has none (Linux's real-time signals).
|
|
39
42
|
* SIGINT/SIGTERM here means a Ctrl-C / shutdown teardown — the
|
|
40
43
|
* orchestrator reverts such a task to aborted, not failed. */
|
|
41
|
-
signal?: string
|
|
44
|
+
signal?: string | number
|
|
42
45
|
/** True when vx's own `timeout` timer fired and SIGTERMed the child.
|
|
43
46
|
* Distinguishes a timeout (a real `failed`) from a Ctrl-C shutdown
|
|
44
47
|
* SIGTERM (which the orchestrator reverts to `aborted`). */
|
|
@@ -106,12 +109,8 @@ export interface RunOptions {
|
|
|
106
109
|
type Fd = 'inherit' | 'ignore' | 'pipe' | number
|
|
107
110
|
|
|
108
111
|
/** A child's descriptors 0–2: vx's own for a task that holds the terminal. */
|
|
109
|
-
function taskStdio(
|
|
110
|
-
terminal
|
|
111
|
-
stdin: 'ignore' | 'pipe',
|
|
112
|
-
pipes: TaskPipes,
|
|
113
|
-
): [Fd, Fd, Fd] {
|
|
114
|
-
return terminal === true ? ['inherit', 'inherit', 'inherit'] : [stdin, ...pipes.stdio]
|
|
112
|
+
function taskStdio(terminal: boolean | undefined, pipes: TaskPipes): [Fd, Fd, Fd] {
|
|
113
|
+
return terminal === true ? ['inherit', 'inherit', 'inherit'] : [pipes.stdin, ...pipes.stdio]
|
|
115
114
|
}
|
|
116
115
|
|
|
117
116
|
const O_NONBLOCK = 0o4000
|
|
@@ -180,6 +179,14 @@ function realPipes(): Int32Array | undefined {
|
|
|
180
179
|
return fds
|
|
181
180
|
}
|
|
182
181
|
|
|
182
|
+
/** One pipe, `[read, write]`, both close-on-exec, or undefined as {@link realPipes}. */
|
|
183
|
+
function heldPipe(): Int32Array | undefined {
|
|
184
|
+
const calls = linuxPipeCalls()
|
|
185
|
+
if (calls === null) return undefined
|
|
186
|
+
const fds = new Int32Array(2)
|
|
187
|
+
return calls.pipe2(calls.ptr(fds), O_CLOEXEC) === 0 ? fds : undefined
|
|
188
|
+
}
|
|
189
|
+
|
|
183
190
|
/**
|
|
184
191
|
* A task's stdout and stderr. Bun's `'pipe'` is a socketpair, and Linux
|
|
185
192
|
* opens `/dev/stdout` through `/proc/self/fd/1`, which a socket refuses
|
|
@@ -188,25 +195,35 @@ function realPipes(): Int32Array | undefined {
|
|
|
188
195
|
* Linux vx makes the pipes (`pipe2` through `bun:ffi`) and hands the child
|
|
189
196
|
* the write ends. macOS opens `/dev/fd/N` as a dup of the descriptor,
|
|
190
197
|
* which a socket allows, so Bun's pipe stays there.
|
|
198
|
+
*
|
|
199
|
+
* `held`: stdin is a pipe vx keeps open and never writes (a persistent
|
|
200
|
+
* task's, schema.md), for the same reason: `cat /dev/stdin` on Bun's
|
|
201
|
+
* socketpair failed with ENXIO instead of blocking. Otherwise `'ignore'`.
|
|
191
202
|
*/
|
|
192
203
|
export class TaskPipes {
|
|
204
|
+
/** Descriptor 0 for `Bun.spawn`'s `stdio`. */
|
|
205
|
+
readonly stdin: number | 'pipe' | 'ignore'
|
|
193
206
|
/** Descriptors 1 and 2 for `Bun.spawn`'s `stdio`. */
|
|
194
207
|
readonly stdio: readonly [number | 'pipe', number | 'pipe']
|
|
195
208
|
readonly #read: readonly number[]
|
|
209
|
+
readonly #stdinWrite: number | undefined
|
|
196
210
|
|
|
197
|
-
constructor(terminal?: boolean) {
|
|
211
|
+
constructor(terminal?: boolean, held?: boolean) {
|
|
198
212
|
const fds = terminal === true ? undefined : realPipes()
|
|
199
213
|
this.stdio = fds === undefined ? ['pipe', 'pipe'] : [fds[1]!, fds[3]!]
|
|
200
214
|
this.#read = fds === undefined ? [] : [fds[0]!, fds[2]!]
|
|
215
|
+
const inFds = held === true && terminal !== true ? heldPipe() : undefined
|
|
216
|
+
this.stdin = held !== true ? 'ignore' : inFds === undefined ? 'pipe' : inFds[0]!
|
|
217
|
+
this.#stdinWrite = inFds?.[1]
|
|
201
218
|
}
|
|
202
219
|
|
|
203
220
|
/**
|
|
204
|
-
* Close vx's copies of the
|
|
205
|
-
* thrown: the child holds its own, and
|
|
206
|
-
* reader's EOF would wait for.
|
|
221
|
+
* Close vx's copies of the child's ends once the spawn has returned or
|
|
222
|
+
* thrown: the child holds its own, and a write end left here is a writer
|
|
223
|
+
* the reader's EOF would wait for.
|
|
207
224
|
*/
|
|
208
225
|
spawned(): void {
|
|
209
|
-
for (const fd of this.stdio) if (typeof fd === 'number') closeSync(fd)
|
|
226
|
+
for (const fd of [this.stdin, ...this.stdio]) if (typeof fd === 'number') closeSync(fd)
|
|
210
227
|
}
|
|
211
228
|
|
|
212
229
|
/** The child's stdout and stderr. */
|
|
@@ -231,6 +248,16 @@ export class TaskPipes {
|
|
|
231
248
|
close(): void {
|
|
232
249
|
for (const fd of this.#read) closeSync(fd)
|
|
233
250
|
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Close the held stdin's write end, once the child has exited and its
|
|
254
|
+
* output has ended. Not with the output alone: a server that sends it
|
|
255
|
+
* to a log (`exec >log 2>&1`) ended its streams at once, read stdin EOF
|
|
256
|
+
* and exited.
|
|
257
|
+
*/
|
|
258
|
+
closeStdin(): void {
|
|
259
|
+
if (this.#stdinWrite !== undefined) closeSync(this.#stdinWrite)
|
|
260
|
+
}
|
|
234
261
|
}
|
|
235
262
|
|
|
236
263
|
/**
|
|
@@ -245,14 +272,30 @@ export class TaskPipes {
|
|
|
245
272
|
function keepTerminalModes(): (() => void) | undefined {
|
|
246
273
|
const saved = stty('-g')
|
|
247
274
|
if (saved === undefined) return undefined
|
|
248
|
-
|
|
275
|
+
const restore = (): void => {
|
|
276
|
+
if (!owed.delete(restore)) return
|
|
249
277
|
if (stty('-g') !== saved) stty(saved)
|
|
250
278
|
}
|
|
279
|
+
owed.add(restore)
|
|
280
|
+
return restore
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** The modes of every task still holding the terminal, owed back on its exit. */
|
|
284
|
+
const owed = new Set<() => void>()
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Put the terminal back now for every task still holding it: vx is
|
|
288
|
+
* exiting without waiting for them (a second signal, signals.ts), so the
|
|
289
|
+
* restore on each task's exit never runs.
|
|
290
|
+
*/
|
|
291
|
+
export function restoreTerminalModes(): void {
|
|
292
|
+
for (const restore of [...owed]) restore()
|
|
251
293
|
}
|
|
252
294
|
|
|
253
295
|
function stty(arg: string): string | undefined {
|
|
254
296
|
try {
|
|
255
297
|
const out = Bun.spawnSync(['stty', arg], {
|
|
298
|
+
env: helperEnv(),
|
|
256
299
|
stdio: ['inherit', 'pipe', 'ignore'],
|
|
257
300
|
detached: true,
|
|
258
301
|
})
|
|
@@ -279,10 +322,13 @@ export function shellQuote(arg: string): string {
|
|
|
279
322
|
* `<<X` in a comment or quotes opens no heredoc, and a quote in a body
|
|
280
323
|
* opens nothing (X-110).
|
|
281
324
|
*/
|
|
282
|
-
function shellShape(command: string): { comment: number; body: boolean[] } {
|
|
325
|
+
function shellShape(command: string): { comment: number; body: boolean[]; subshell: boolean } {
|
|
283
326
|
const lines = command.split('\n')
|
|
284
327
|
const body = lines.map(() => false)
|
|
285
328
|
const pending: Array<{ delim: string; tabs: boolean }> = []
|
|
329
|
+
// Per open `(`: does it open a subshell, not `$(`, `$((`, `<(` or `f()`.
|
|
330
|
+
const parens: boolean[] = []
|
|
331
|
+
let subshell = false
|
|
286
332
|
let quote = ''
|
|
287
333
|
let comment = -1
|
|
288
334
|
let atWordStart = true
|
|
@@ -330,17 +376,32 @@ function shellShape(command: string): { comment: number; body: boolean[] } {
|
|
|
330
376
|
continue
|
|
331
377
|
}
|
|
332
378
|
if (c === '<' && command[i - 1] !== '<') {
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
379
|
+
// The delimiter is a whole word, quotes removed: `<<END-OF` and
|
|
380
|
+
// `<<'a b'` end at `END-OF` and `a b`. One opening with a digit is
|
|
381
|
+
// left alone, for `$((x<<2))`.
|
|
382
|
+
const m = /^<<(-?)[ \t]*((?:[^\s;&|()<>'"\\]|\\.|'[^']*'|"(?:[^"\\]|\\.)*")+)/.exec(
|
|
383
|
+
command.slice(i, i + 256),
|
|
384
|
+
)
|
|
385
|
+
if (m !== null && !/^\d/.test(m[2]!)) {
|
|
386
|
+
const delim = m[2]!.replace(/'([^']*)'|"((?:[^"\\]|\\.)*)"|\\(.)/g, (_, s, d, e) =>
|
|
387
|
+
s !== undefined ? s : d !== undefined ? d.replace(/\\(.)/g, '$1') : e,
|
|
388
|
+
)
|
|
389
|
+
pending.push({ delim, tabs: m[1] === '-' })
|
|
336
390
|
i += m[0].length - 1
|
|
337
391
|
atWordStart = false
|
|
338
392
|
continue
|
|
339
393
|
}
|
|
340
394
|
}
|
|
395
|
+
if (c === '(') {
|
|
396
|
+
const prev = command[i - 1] ?? ''
|
|
397
|
+
parens.push(!/[$<>\w]/.test(prev) && !(prev === '(' && parens.at(-1) === false))
|
|
398
|
+
} else if (c === ')') {
|
|
399
|
+
const opened = parens.pop()
|
|
400
|
+
if (i === command.length - 1) subshell = opened === true
|
|
401
|
+
}
|
|
341
402
|
atWordStart = /[\s;&|()<>]/.test(c)
|
|
342
403
|
}
|
|
343
|
-
return { comment, body }
|
|
404
|
+
return { comment, body, subshell }
|
|
344
405
|
}
|
|
345
406
|
|
|
346
407
|
/**
|
|
@@ -372,6 +433,21 @@ export function withForwardArgs(command: string, args: readonly string[] | undef
|
|
|
372
433
|
// ran the args as a command of their own: `--watch: not found`. Kept
|
|
373
434
|
// when an odd run of backslashes ends it: that escapes what follows.
|
|
374
435
|
const escaped = (/\\+$/.exec(trimmed)?.[0].length ?? 0) % 2 === 1
|
|
436
|
+
// So did one that ends in a lone `;` or `&` (or a case arm's `;;`): the
|
|
437
|
+
// args go before it. An escaped one (find's `-exec … \;`) is a word,
|
|
438
|
+
// and takes them after.
|
|
439
|
+
const sep = /(?:^|[^;&|<>\\])(?:\\\\)*(;;|[;&])$/.exec(trimmed)?.[1]
|
|
440
|
+
// After a compound command's closer they were a syntax error
|
|
441
|
+
// (`(cmd) --fix`, `fi --fix`): they go to the last command inside it.
|
|
442
|
+
// A closer word counts only where a command starts, after a separator.
|
|
443
|
+
const closer = shape.subshell
|
|
444
|
+
? ')'
|
|
445
|
+
: /(?:^|[^\\])(?:\\\\)*[;&\n][ \t]*(fi|done|esac|\})$/.exec(trimmed)?.[1]
|
|
446
|
+
const end = sep ?? closer
|
|
447
|
+
if (end !== undefined) {
|
|
448
|
+
const head = trimmed.slice(0, -end.length).trimEnd()
|
|
449
|
+
return `${withForwardArgs(head, args)}${trimmed.slice(head.length)}`
|
|
450
|
+
}
|
|
375
451
|
return `${escaped ? command : trimmed} ${quoted}`
|
|
376
452
|
}
|
|
377
453
|
// A comment-only last line after a commented line: the args go before
|
|
@@ -433,6 +509,28 @@ const SHELL_BUILTINS = new Set([
|
|
|
433
509
|
'let',
|
|
434
510
|
'declare',
|
|
435
511
|
'typeset',
|
|
512
|
+
// The rest of bash's builtins, as `compgen -b` lists them.
|
|
513
|
+
'bg',
|
|
514
|
+
'bind',
|
|
515
|
+
'caller',
|
|
516
|
+
'compgen',
|
|
517
|
+
'complete',
|
|
518
|
+
'compopt',
|
|
519
|
+
'dirs',
|
|
520
|
+
'disown',
|
|
521
|
+
'enable',
|
|
522
|
+
'fc',
|
|
523
|
+
'fg',
|
|
524
|
+
'getopts',
|
|
525
|
+
'help',
|
|
526
|
+
'history',
|
|
527
|
+
'logout',
|
|
528
|
+
'mapfile',
|
|
529
|
+
'popd',
|
|
530
|
+
'pushd',
|
|
531
|
+
'readarray',
|
|
532
|
+
'shopt',
|
|
533
|
+
'suspend',
|
|
436
534
|
// bash's reserved words that may open a one-line command; macOS's `sh`
|
|
437
535
|
// is bash, and `exec [[ … ]]` there is "exec: [[: not found".
|
|
438
536
|
'[[',
|
|
@@ -484,11 +582,13 @@ export function execWord(command: string): string | undefined {
|
|
|
484
582
|
|
|
485
583
|
/**
|
|
486
584
|
* POSIX shells report signal death as 128 + signal number (SIGTERM →
|
|
487
|
-
* 143, SIGKILL → 137). Bun gives
|
|
488
|
-
*
|
|
489
|
-
* 130 (128 +
|
|
585
|
+
* 143, SIGKILL → 137). Bun gives the signal's NAME, or since 1.4.4 its
|
|
586
|
+
* number when the signal has no name; `os.constants` maps a name to
|
|
587
|
+
* the platform-correct number. Unknown names fall back to 130 (128 +
|
|
588
|
+
* SIGINT).
|
|
490
589
|
*/
|
|
491
|
-
export function signalExitCode(signal: string): number {
|
|
590
|
+
export function signalExitCode(signal: string | number): number {
|
|
591
|
+
if (typeof signal === 'number') return 128 + signal
|
|
492
592
|
const num = (osConstants.signals as Partial<Record<string, number>>)[signal]
|
|
493
593
|
return num === undefined ? 130 : 128 + num
|
|
494
594
|
}
|
|
@@ -607,9 +707,38 @@ const POST_EXIT_DRAIN_MS = 250
|
|
|
607
707
|
* run; the line makes the loss visible.
|
|
608
708
|
*/
|
|
609
709
|
export const POST_EXIT_CUT_LINE =
|
|
610
|
-
|
|
710
|
+
`[vx] output after the task's shell exited was cut: a process it left running ` +
|
|
611
711
|
`still held its stdout/stderr ${POST_EXIT_DRAIN_MS} ms later\n`
|
|
612
712
|
|
|
713
|
+
/**
|
|
714
|
+
* A task's two output callbacks, wrapped to learn whether its output so far
|
|
715
|
+
* ends mid-line, so a line vx adds after it (`POST_EXIT_CUT_LINE`) breaks
|
|
716
|
+
* the line only then: a newline always before it printed a blank line under
|
|
717
|
+
* the task's last line. An absent callback stays absent (nothing decodes).
|
|
718
|
+
*/
|
|
719
|
+
export function lineEnds(
|
|
720
|
+
onStdout?: (chunk: string) => void,
|
|
721
|
+
onStderr?: (chunk: string) => void,
|
|
722
|
+
): {
|
|
723
|
+
onStdout?: (chunk: string) => void
|
|
724
|
+
onStderr?: (chunk: string) => void
|
|
725
|
+
/** `text` on a line of its own. */
|
|
726
|
+
own(text: string): string
|
|
727
|
+
} {
|
|
728
|
+
let mid = false
|
|
729
|
+
const track =
|
|
730
|
+
(f: (chunk: string) => void) =>
|
|
731
|
+
(chunk: string): void => {
|
|
732
|
+
if (chunk.length > 0) mid = !chunk.endsWith('\n')
|
|
733
|
+
f(chunk)
|
|
734
|
+
}
|
|
735
|
+
return {
|
|
736
|
+
...(onStdout && { onStdout: track(onStdout) }),
|
|
737
|
+
...(onStderr && { onStderr: track(onStderr) }),
|
|
738
|
+
own: (text) => (mid ? `\n${text}` : text),
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
|
|
613
742
|
/**
|
|
614
743
|
* After the direct child has exited, wait for the stdout/stderr readers to
|
|
615
744
|
* reach EOF — but bound it: an orphaned grandchild holding the pipe open would
|
|
@@ -669,8 +798,11 @@ export class PersistentReadyError extends Error {
|
|
|
669
798
|
}
|
|
670
799
|
|
|
671
800
|
export interface PersistentSpawn {
|
|
672
|
-
/**
|
|
673
|
-
|
|
801
|
+
/**
|
|
802
|
+
* Underlying Bun subprocess so the orchestrator can SIGTERM it later;
|
|
803
|
+
* undefined when the spawn itself failed and `ready` has rejected.
|
|
804
|
+
*/
|
|
805
|
+
child: ReturnType<typeof Bun.spawn> | undefined
|
|
674
806
|
/**
|
|
675
807
|
* Resolves once the task is considered "ready":
|
|
676
808
|
* - immediately on successful spawn when no `readyWhen` is given,
|
|
@@ -726,7 +858,7 @@ export function runPersistent(opts: PersistentOptions): PersistentSpawn {
|
|
|
726
858
|
const readyRe = opts.readyWhen !== undefined ? new RegExp(opts.readyWhen) : undefined
|
|
727
859
|
|
|
728
860
|
let child: ReturnType<typeof Bun.spawn>
|
|
729
|
-
const pipes = new TaskPipes(opts.terminal)
|
|
861
|
+
const pipes = new TaskPipes(opts.terminal, true)
|
|
730
862
|
const restoreModes = opts.terminal === true ? keepTerminalModes() : undefined
|
|
731
863
|
try {
|
|
732
864
|
const signalFd = opts.signalChannel === true
|
|
@@ -748,7 +880,7 @@ export function runPersistent(opts: PersistentOptions): PersistentSpawn {
|
|
|
748
880
|
// The one-shot spawn below keeps 'ignore', so a task that reads
|
|
749
881
|
// stdin can never hang CI.
|
|
750
882
|
stdio: [
|
|
751
|
-
...taskStdio(opts.terminal,
|
|
883
|
+
...taskStdio(opts.terminal, pipes),
|
|
752
884
|
...(signalFd ? ['pipe' as const] : []),
|
|
753
885
|
...(guard === undefined ? [] : [guard]),
|
|
754
886
|
],
|
|
@@ -767,9 +899,15 @@ export function runPersistent(opts: PersistentOptions): PersistentSpawn {
|
|
|
767
899
|
}
|
|
768
900
|
} catch (err) {
|
|
769
901
|
pipes.close()
|
|
770
|
-
|
|
902
|
+
pipes.closeStdin()
|
|
903
|
+
// Bun names `sh` in the ENOENT a missing directory gives too.
|
|
904
|
+
// The command the shell was handed, a sandbox's wrap included.
|
|
905
|
+
const message =
|
|
906
|
+
isExecutableMissing(err) && !existsSync(opts.cwd)
|
|
907
|
+
? `its working directory ${opts.cwd} does not exist`
|
|
908
|
+
: spawnErrorReason(err, opts.signalChannel === true ? SANDBOXED_TASK : 'task', opts.command)
|
|
771
909
|
return {
|
|
772
|
-
child: undefined
|
|
910
|
+
child: undefined,
|
|
773
911
|
ready: Promise.reject(
|
|
774
912
|
new PersistentReadyError(`failed to spawn persistent task: ${message}`, 'spawn'),
|
|
775
913
|
),
|
|
@@ -874,6 +1012,7 @@ export function runPersistent(opts: PersistentOptions): PersistentSpawn {
|
|
|
874
1012
|
const readers = Promise.all([consumeChunks(stdout, false), consumeChunks(stderr, true)]).finally(
|
|
875
1013
|
() => pipes.close(),
|
|
876
1014
|
)
|
|
1015
|
+
void Promise.allSettled([readers, child.exited]).then(() => pipes.closeStdin())
|
|
877
1016
|
|
|
878
1017
|
opts.liveChildren?.add(child)
|
|
879
1018
|
opts.onSpawn?.(child.pid)
|
|
@@ -934,7 +1073,7 @@ export function runPersistent(opts: PersistentOptions): PersistentSpawn {
|
|
|
934
1073
|
}
|
|
935
1074
|
// A clean exit is ready: a setup step (migrate, seed, a `docker
|
|
936
1075
|
// compose up --wait`) has done its work, and what it started, if
|
|
937
|
-
// anything, is torn down with the run (
|
|
1076
|
+
// anything, is torn down with the run (2026-10-22).
|
|
938
1077
|
if (code === 0) markReady()
|
|
939
1078
|
if (readyAt === undefined && gaveUpAt === undefined) {
|
|
940
1079
|
rejectReady(
|
|
@@ -959,21 +1098,57 @@ export function runPersistent(opts: PersistentOptions): PersistentSpawn {
|
|
|
959
1098
|
}
|
|
960
1099
|
}
|
|
961
1100
|
|
|
1101
|
+
/** `spawnFailureText`'s `what` for a sandboxed task: its command is the sandbox's wrap of the task's. */
|
|
1102
|
+
export const SANDBOXED_TASK = 'sandboxed task'
|
|
1103
|
+
|
|
962
1104
|
/**
|
|
963
1105
|
* What a task's frame says when `Bun.spawn` itself threw. The text goes
|
|
964
1106
|
* through `onStderr` AND onto the result: the orchestrator retains no
|
|
965
1107
|
* stderr (execute-task's capture), so a reason that only sat on the result
|
|
966
1108
|
* reached nobody — a box without `sh` showed "failed (exit 127)" under a
|
|
967
1109
|
* bare `$ <command>` and nothing else (2026-09-16). An ENOENT with the
|
|
968
|
-
* working directory in place is the shell; Bun's
|
|
1110
|
+
* working directory in place is the shell, without it the directory; Bun's
|
|
1111
|
+
* own text names the rest.
|
|
969
1112
|
*/
|
|
970
|
-
export function spawnFailureText(
|
|
971
|
-
|
|
972
|
-
|
|
1113
|
+
export function spawnFailureText(
|
|
1114
|
+
err: unknown,
|
|
1115
|
+
cwd: string,
|
|
1116
|
+
what = 'task',
|
|
1117
|
+
command?: string,
|
|
1118
|
+
): string {
|
|
1119
|
+
if (isExecutableMissing(err)) {
|
|
1120
|
+
// Bun names `sh` in the ENOENT a missing directory gives too.
|
|
1121
|
+
return existsSync(cwd)
|
|
1122
|
+
? `[vx] vx runs each task with sh -c: failed to spawn 'sh' (working dir: ${cwd}). Install a POSIX sh and re-run.\n`
|
|
1123
|
+
: `[vx] failed to spawn ${what}: its working directory ${cwd} does not exist\n`
|
|
973
1124
|
}
|
|
1125
|
+
return `[vx] failed to spawn ${what}: ${spawnErrorReason(err, what, command)}\n`
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
/** Bun's text for a spawn that threw, and the way out when the limit hit is a known one. */
|
|
1129
|
+
function spawnErrorReason(err: unknown, what: string, command: string | undefined): string {
|
|
974
1130
|
const message = err instanceof Error ? err.message : String(err)
|
|
975
|
-
const hint = isOutOfFds(err)
|
|
976
|
-
|
|
1131
|
+
const hint = isOutOfFds(err)
|
|
1132
|
+
? ` — ${OUT_OF_FDS_HINT}`
|
|
1133
|
+
: command !== undefined && (err as NodeJS.ErrnoException | undefined)?.code === 'E2BIG'
|
|
1134
|
+
? ` — ${argTooLongHint(command, what === SANDBOXED_TASK)}`
|
|
1135
|
+
: ''
|
|
1136
|
+
return `${message}${hint}`
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
/**
|
|
1140
|
+
* A task's command, the args after `--` folded in, is ONE argument to
|
|
1141
|
+
* `sh -c`, and Linux caps one argument at 128 KiB (MAX_ARG_STRLEN), far
|
|
1142
|
+
* below the argv total: a file list forwarded to a linter failed with a
|
|
1143
|
+
* bare E2BIG, which reads as "too many files" and names no way out.
|
|
1144
|
+
*/
|
|
1145
|
+
function argTooLongHint(command: string, wrapped: boolean): string {
|
|
1146
|
+
const kib = Math.ceil(Buffer.byteLength(command) / 1024)
|
|
1147
|
+
const what = wrapped ? "with the args after -- and the sandbox's wrap" : 'with the args after --'
|
|
1148
|
+
return (
|
|
1149
|
+
`the command, ${what}, is ${kib} KiB, and one argument to sh -c may hold at ` +
|
|
1150
|
+
`most 128 KiB on Linux: pass a long list through a file the tool reads (xargs, a response file)`
|
|
1151
|
+
)
|
|
977
1152
|
}
|
|
978
1153
|
|
|
979
1154
|
export async function runCommand(opts: RunOptions): Promise<RunResult> {
|
|
@@ -989,10 +1164,7 @@ export async function runCommand(opts: RunOptions): Promise<RunResult> {
|
|
|
989
1164
|
...SH_ARGV0,
|
|
990
1165
|
cwd: opts.cwd,
|
|
991
1166
|
env: opts.env as Record<string, string>,
|
|
992
|
-
stdio: [
|
|
993
|
-
...taskStdio(opts.terminal, 'ignore', pipes),
|
|
994
|
-
...(guard === undefined ? [] : [guard]),
|
|
995
|
-
],
|
|
1167
|
+
stdio: [...taskStdio(opts.terminal, pipes), ...(guard === undefined ? [] : [guard])],
|
|
996
1168
|
// Its own session and process group, so a kill reaches what it
|
|
997
1169
|
// forked (kill-tree.ts). A terminal handed over is not this
|
|
998
1170
|
// session's controlling one, so no read stops the group.
|
|
@@ -1001,7 +1173,7 @@ export async function runCommand(opts: RunOptions): Promise<RunResult> {
|
|
|
1001
1173
|
)
|
|
1002
1174
|
} catch (err) {
|
|
1003
1175
|
pipes.close()
|
|
1004
|
-
const stderr = spawnFailureText(err, opts.cwd)
|
|
1176
|
+
const stderr = spawnFailureText(err, opts.cwd, 'task', fullCommand)
|
|
1005
1177
|
opts.onStderr?.(stderr)
|
|
1006
1178
|
return { exitCode: 127, durationMs: Date.now() - start, stdout: '', stderr, spawnFailed: true }
|
|
1007
1179
|
} finally {
|
|
@@ -1013,9 +1185,10 @@ export async function runCommand(opts: RunOptions): Promise<RunResult> {
|
|
|
1013
1185
|
const timeout = armTimeout(proc, opts.timeoutMs)
|
|
1014
1186
|
const ac = new AbortController()
|
|
1015
1187
|
const [out, err] = pipes.streams(proc)
|
|
1188
|
+
const ends = lineEnds(opts.onStdout, opts.onStderr)
|
|
1016
1189
|
const streams = Promise.all([
|
|
1017
|
-
streamToString(out,
|
|
1018
|
-
streamToString(err,
|
|
1190
|
+
streamToString(out, ends.onStdout, ac.signal, opts.capture?.stdout ?? true),
|
|
1191
|
+
streamToString(err, ends.onStderr, ac.signal, opts.capture?.stderr ?? true),
|
|
1019
1192
|
]).finally(() => pipes.close())
|
|
1020
1193
|
// Gate on the child's own exit, not on stream EOF: an orphaned grandchild
|
|
1021
1194
|
// can keep the pipe open past the child's exit (a timeout SIGTERM leaves the
|
|
@@ -1030,17 +1203,21 @@ export async function runCommand(opts: RunOptions): Promise<RunResult> {
|
|
|
1030
1203
|
if (timeout.timedOut()) ac.abort()
|
|
1031
1204
|
else cut = await drainOrAbort(streams, ac)
|
|
1032
1205
|
const [stdout, streamed] = await streams
|
|
1033
|
-
|
|
1034
|
-
|
|
1206
|
+
const cutLine = ends.own(POST_EXIT_CUT_LINE)
|
|
1207
|
+
if (cut) opts.onStderr?.(cutLine)
|
|
1208
|
+
const stderr = cut ? streamed + cutLine : streamed
|
|
1035
1209
|
opts.liveChildren?.delete(proc)
|
|
1036
1210
|
releaseGroup(proc)
|
|
1037
|
-
const
|
|
1211
|
+
const exited = proc.exitCode ?? (proc.signalCode ? signalExitCode(proc.signalCode) : 1)
|
|
1212
|
+
// A timeout is a failure, whatever the child said: one that traps the
|
|
1213
|
+
// TERM and exits 0 reported success to every executor built on this.
|
|
1214
|
+
const exitCode = exited === 0 && timeout.timedOut() ? signalExitCode('SIGTERM') : exited
|
|
1038
1215
|
return {
|
|
1039
1216
|
exitCode,
|
|
1040
1217
|
durationMs: Date.now() - start,
|
|
1041
1218
|
stdout,
|
|
1042
1219
|
stderr,
|
|
1043
|
-
...(proc.signalCode ? { signal: proc.signalCode } : {}),
|
|
1220
|
+
...(proc.signalCode != null ? { signal: proc.signalCode } : {}),
|
|
1044
1221
|
...(timeout.timedOut() ? { timedOut: true } : {}),
|
|
1045
1222
|
...(cut ? { leftRunning: true } : {}),
|
|
1046
1223
|
...resourceUsageToCpuRss(proc.resourceUsage(), rssFloorFor),
|
|
@@ -1105,9 +1282,6 @@ function rssFloorFor(peak: number): number {
|
|
|
1105
1282
|
export const CAPTURE_HEAD_CHARS = 8 * 1024 * 1024
|
|
1106
1283
|
export const CAPTURE_TAIL_CHARS = 8 * 1024 * 1024
|
|
1107
1284
|
|
|
1108
|
-
const mib = (n: number): string => `${(n / (1024 * 1024)).toFixed(1)} MiB`
|
|
1109
|
-
const size = (n: number): string => (n < 1024 * 1024 ? `${n / 1024} KiB` : mib(n))
|
|
1110
|
-
|
|
1111
1285
|
/** The line that stands where the dropped middle was. */
|
|
1112
1286
|
export function droppedOutputLine(
|
|
1113
1287
|
dropped: number,
|
|
@@ -1115,8 +1289,9 @@ export function droppedOutputLine(
|
|
|
1115
1289
|
tailChars = CAPTURE_TAIL_CHARS,
|
|
1116
1290
|
): string {
|
|
1117
1291
|
return (
|
|
1118
|
-
|
|
1119
|
-
|
|
1292
|
+
// In vx's own units: a fixed MiB tier printed a few KiB dropped as `0.0 MiB`.
|
|
1293
|
+
`\n[vx] ${formatBytes(dropped)} of output not kept — vx keeps the first ${formatBytes(headChars)} ` +
|
|
1294
|
+
`and the last ${formatBytes(tailChars)} of a task's output\n`
|
|
1120
1295
|
)
|
|
1121
1296
|
}
|
|
1122
1297
|
|
|
@@ -1135,7 +1310,12 @@ export function maskCaptured(text: string, secrets: SecretMask): string {
|
|
|
1135
1310
|
return head + text.slice(at, end) + tail
|
|
1136
1311
|
}
|
|
1137
1312
|
|
|
1138
|
-
const DROPPED_LINE = /^\n\[vx\] [\d.]+
|
|
1313
|
+
const DROPPED_LINE = /^\n\[vx\] [\d.]+ [KMGTP]?B of output not kept — /
|
|
1314
|
+
|
|
1315
|
+
/** Whether `chunk` is the line a `BoundedCapture` puts where its dropped middle was. */
|
|
1316
|
+
export function isDroppedOutputChunk(chunk: CapturedChunk): boolean {
|
|
1317
|
+
return !chunk.err && DROPPED_LINE.test(chunk.text)
|
|
1318
|
+
}
|
|
1139
1319
|
|
|
1140
1320
|
const isHighSurrogate = (code: number): boolean => code >= 0xd800 && code <= 0xdbff
|
|
1141
1321
|
|
|
@@ -1200,6 +1380,25 @@ export class BoundedCapture {
|
|
|
1200
1380
|
}
|
|
1201
1381
|
}
|
|
1202
1382
|
|
|
1383
|
+
/**
|
|
1384
|
+
* The same output under tighter bounds. What this capture already
|
|
1385
|
+
* dropped is counted in the new one's dropped line: pushed through
|
|
1386
|
+
* `chunks()`, the old line fell into the new middle with its count.
|
|
1387
|
+
* `through` maps each side's chunks before the new cut (the store's
|
|
1388
|
+
* mask), so the cut cannot split a value first.
|
|
1389
|
+
*/
|
|
1390
|
+
rebound(
|
|
1391
|
+
headChars: number,
|
|
1392
|
+
tailChars: number,
|
|
1393
|
+
through: (chunks: CapturedChunk[]) => CapturedChunk[] = (c) => c,
|
|
1394
|
+
): BoundedCapture {
|
|
1395
|
+
const to = new BoundedCapture(headChars, tailChars)
|
|
1396
|
+
for (const c of through(this.head)) to.push(c.text, c.err)
|
|
1397
|
+
for (const c of through(this.tail)) to.push(c.text, c.err)
|
|
1398
|
+
to.dropped += this.dropped
|
|
1399
|
+
return to
|
|
1400
|
+
}
|
|
1401
|
+
|
|
1203
1402
|
/** The kept chunks in arrival order, the dropped middle named on stdout. */
|
|
1204
1403
|
chunks(): CapturedChunk[] {
|
|
1205
1404
|
return this.dropped === 0
|
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
unique,
|
|
15
15
|
} from './sandbox-paths.js'
|
|
16
16
|
import type { SandboxedRunArgs } from './sandbox-runtime.js'
|
|
17
|
+
import { ui } from '../util/index.js'
|
|
17
18
|
|
|
18
19
|
type SrtModule = typeof import('@anthropic-ai/sandbox-runtime')
|
|
19
20
|
|
|
@@ -200,7 +201,8 @@ export function punchWritePaths(readPath: string, writePaths: readonly string[])
|
|
|
200
201
|
}
|
|
201
202
|
if (linked.length > 0 && !warnedSymlinkPunch.has(readPath)) {
|
|
202
203
|
warnedSymlinkPunch.add(readPath)
|
|
203
|
-
|
|
204
|
+
ui.say(
|
|
205
|
+
'warn',
|
|
204
206
|
`[vx] sandbox: a write grant under ${readPath} makes its ${linked.length} symlinked ` +
|
|
205
207
|
`entr${linked.length === 1 ? 'y' : 'ies'} (${linked
|
|
206
208
|
.slice(0, 3)
|
|
@@ -232,7 +234,8 @@ function holdsGlobChar(grant: string): boolean {
|
|
|
232
234
|
if (at === -1) return false
|
|
233
235
|
if (!warnedGlob.has(grant)) {
|
|
234
236
|
warnedGlob.add(grant)
|
|
235
|
-
|
|
237
|
+
ui.say(
|
|
238
|
+
'warn',
|
|
236
239
|
`[vx] sandbox: the write grant ${grant} holds ${globChar(grant[at]!)}, and the Linux ` +
|
|
237
240
|
`sandbox mounts no write path that does, so a write under it is refused. Grant the ` +
|
|
238
241
|
`directory above it instead: ${grant.slice(0, grant.lastIndexOf(path.sep, at) + 1)}\n`,
|
|
@@ -264,7 +267,8 @@ export function bindableReads(paths: readonly string[]): string[] {
|
|
|
264
267
|
if (at === -1) return true
|
|
265
268
|
if (!warnedGlob.has(grant)) {
|
|
266
269
|
warnedGlob.add(grant)
|
|
267
|
-
|
|
270
|
+
ui.say(
|
|
271
|
+
'warn',
|
|
268
272
|
`[vx] sandbox: the read grant ${grant} holds ${globChar(grant[at]!)}, which the Linux ` +
|
|
269
273
|
`sandbox ${grant[at] === '\\' ? 'cannot resolve' : 'reads as a pattern that also matches its siblings'}, so it is not granted. ` +
|
|
270
274
|
`Rename it, or grant the directory above it: ` +
|
|
@@ -12,7 +12,7 @@ import path from 'node:path'
|
|
|
12
12
|
* own set), which also counts `{}`: `write: ['g/{a,b}.txt']` is a
|
|
13
13
|
* LITERAL to the sandbox, gets a placeholder file, and is widened to its
|
|
14
14
|
* directory like any other file-shaped grant — measured ok, and pinned in
|
|
15
|
-
* `tests/sandbox-
|
|
15
|
+
* `tests/sandbox-lifecycle.unsafe.test.ts`. Counting the brace here would
|
|
16
16
|
* move that spelling into the scan, which finds nothing before the task has
|
|
17
17
|
* written, and turn a working grant into `Read-only file system`. The two
|
|
18
18
|
* predicates answer different questions: whether a declaration must be
|