@vzn/vx 0.0.0 → 0.0.12
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/LICENSE +21 -0
- package/README.md +332 -1
- package/launcher.mjs +84 -0
- package/package.json +44 -3
- package/src/bin.ts +25 -0
- package/src/cache/cache.ts +1832 -0
- package/src/cache/cas-backend.ts +97 -0
- package/src/cache/digest.ts +47 -0
- package/src/cache/index.ts +29 -0
- package/src/cache/inputs.ts +833 -0
- package/src/cache/layered-cache.ts +335 -0
- package/src/cache/remote-cache.ts +251 -0
- package/src/cache/tar.ts +343 -0
- package/src/cli/backend.ts +23 -0
- package/src/cli/cache.ts +101 -0
- package/src/cli/format.ts +15 -0
- package/src/cli/help.ts +123 -0
- package/src/cli/index.ts +85 -0
- package/src/cli/info.ts +83 -0
- package/src/cli/lock.ts +135 -0
- package/src/cli/mcp-rpc.ts +292 -0
- package/src/cli/mcp.ts +92 -0
- package/src/cli/migrate-nx.ts +450 -0
- package/src/cli/migrate-turbo.ts +364 -0
- package/src/cli/migrate.ts +219 -0
- package/src/cli/plan-format.ts +148 -0
- package/src/cli/run.ts +661 -0
- package/src/cli/show.ts +201 -0
- package/src/cli/upgrade.ts +103 -0
- package/src/cli/watch.ts +287 -0
- package/src/config.ts +421 -0
- package/src/exec/env.ts +74 -0
- package/src/exec/index.ts +13 -0
- package/src/exec/runner.ts +470 -0
- package/src/exec/sandbox-runtime.ts +578 -0
- package/src/graph/dependency-spec.ts +69 -0
- package/src/graph/index.ts +18 -0
- package/src/graph/scheduler.ts +482 -0
- package/src/graph/task-graph.ts +325 -0
- package/src/index.ts +197 -0
- package/src/orchestrator/colors.ts +63 -0
- package/src/orchestrator/devframe-surface.ts +74 -0
- package/src/orchestrator/events.ts +277 -0
- package/src/orchestrator/execute-task.ts +883 -0
- package/src/orchestrator/framed-output.ts +461 -0
- package/src/orchestrator/history.ts +148 -0
- package/src/orchestrator/index.ts +163 -0
- package/src/orchestrator/local-shortcircuit.ts +147 -0
- package/src/orchestrator/logger.ts +526 -0
- package/src/orchestrator/metrics.ts +1576 -0
- package/src/orchestrator/options.ts +176 -0
- package/src/orchestrator/plan.ts +129 -0
- package/src/orchestrator/plugin-host.ts +190 -0
- package/src/orchestrator/plugin.ts +283 -0
- package/src/orchestrator/predict.ts +106 -0
- package/src/orchestrator/prepare.ts +297 -0
- package/src/orchestrator/protocol.ts +142 -0
- package/src/orchestrator/remote-cache-setup.ts +60 -0
- package/src/orchestrator/remote-prefetch.ts +81 -0
- package/src/orchestrator/run-artifacts.ts +111 -0
- package/src/orchestrator/run-context.ts +205 -0
- package/src/orchestrator/run-report.ts +132 -0
- package/src/orchestrator/run-state.ts +145 -0
- package/src/orchestrator/run.ts +842 -0
- package/src/orchestrator/stable-keys.ts +177 -0
- package/src/orchestrator/status-line.ts +287 -0
- package/src/orchestrator/summary.ts +291 -0
- package/src/orchestrator/tally.ts +69 -0
- package/src/orchestrator/task-hash.ts +238 -0
- package/src/orchestrator/telemetry-host.ts +80 -0
- package/src/orchestrator/telemetry.ts +350 -0
- package/src/orchestrator/upstream.ts +93 -0
- package/src/orchestrator/verify.ts +141 -0
- package/src/orchestrator/wire-render.ts +92 -0
- package/src/orchestrator/wire.ts +212 -0
- package/src/util/errors.ts +11 -0
- package/src/util/hash.ts +25 -0
- package/src/util/index.ts +7 -0
- package/src/util/paths.ts +16 -0
- package/src/util/ulid.ts +15 -0
- package/src/version.ts +10 -0
- package/src/workspace/affected.ts +118 -0
- package/src/workspace/filter.ts +155 -0
- package/src/workspace/fingerprint.ts +41 -0
- package/src/workspace/index.ts +27 -0
- package/src/workspace/lockfile.ts +118 -0
- package/src/workspace/nested-dirs.ts +34 -0
- package/src/workspace/package-graph.ts +148 -0
- package/src/workspace/project-loader.ts +470 -0
- package/src/workspace/workspace.ts +176 -0
|
@@ -0,0 +1,470 @@
|
|
|
1
|
+
// Child-process invocation + resource accounting.
|
|
2
|
+
//
|
|
3
|
+
// Uses Bun.spawn so we get .resourceUsage() (cpuTime, maxRSS) after the
|
|
4
|
+
// process exits. cpuMs / peakRssBytes are then surfaced on RunResult and
|
|
5
|
+
// folded into the v11 `runs` table by the orchestrator.
|
|
6
|
+
|
|
7
|
+
import { constants as osConstants } from 'node:os'
|
|
8
|
+
|
|
9
|
+
export interface RunResult {
|
|
10
|
+
exitCode: number
|
|
11
|
+
durationMs: number
|
|
12
|
+
stdout: string
|
|
13
|
+
stderr: string
|
|
14
|
+
/** The signal that killed the child, if any (Bun's `signalCode`).
|
|
15
|
+
* SIGINT/SIGTERM here means a Ctrl-C / shutdown teardown — the
|
|
16
|
+
* orchestrator reverts such a task to aborted, not failed. */
|
|
17
|
+
signal?: string
|
|
18
|
+
/** True when vx's own `timeout` timer fired and SIGTERMed the child.
|
|
19
|
+
* Distinguishes a timeout (a real `failed`) from a Ctrl-C shutdown
|
|
20
|
+
* SIGTERM (which the orchestrator reverts to `aborted`). */
|
|
21
|
+
timedOut?: boolean
|
|
22
|
+
/** Total user+system CPU time for the child, in milliseconds. */
|
|
23
|
+
cpuMs?: number
|
|
24
|
+
/** Peak resident set size for the child, in bytes. */
|
|
25
|
+
peakRssBytes?: number
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface RunOptions {
|
|
29
|
+
command: string
|
|
30
|
+
cwd: string
|
|
31
|
+
env: NodeJS.ProcessEnv
|
|
32
|
+
/** Extra args appended to the command (shell-quoted). For arg forwarding. */
|
|
33
|
+
forwardArgs?: readonly string[] | undefined
|
|
34
|
+
/** Called for each chunk of stdout/stderr as it arrives, for live output. */
|
|
35
|
+
onStdout?: (chunk: string) => void
|
|
36
|
+
onStderr?: (chunk: string) => void
|
|
37
|
+
/**
|
|
38
|
+
* Upper bound (ms) on the child's run time. When it elapses the
|
|
39
|
+
* child is SIGTERMed and the result is flagged `timedOut`. Undefined
|
|
40
|
+
* → no limit.
|
|
41
|
+
*/
|
|
42
|
+
timeoutMs?: number
|
|
43
|
+
/**
|
|
44
|
+
* Run-scoped registry of in-flight subprocesses. The child is added
|
|
45
|
+
* on spawn and removed once it exits, so the orchestrator's signal
|
|
46
|
+
* handler can SIGTERM everything still alive mid-run.
|
|
47
|
+
*/
|
|
48
|
+
liveChildren?: Set<ReturnType<typeof Bun.spawn>>
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function shellQuote(arg: string): string {
|
|
52
|
+
if (arg === '') return `''`
|
|
53
|
+
if (/^[A-Za-z0-9_\-.,/=:@%+]+$/.test(arg)) return arg
|
|
54
|
+
return `'${arg.replace(/'/g, `'\\''`)}'`
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Any shell control/expansion character means the wrapping `sh` has real
|
|
58
|
+
// work to do (chaining, pipes, redirects, globbing, variables, subshells,
|
|
59
|
+
// backgrounding) and must stay resident.
|
|
60
|
+
const SHELL_CONTROL = /[&|;<>(){}$`\n\\!*?~]/
|
|
61
|
+
|
|
62
|
+
// Shell builtins run INSIDE sh — `exec <builtin>` fails (there's no
|
|
63
|
+
// external `exit`/`true`/`echo`). They also spawn no grandchild, so
|
|
64
|
+
// there's nothing to gain by exec-wrapping them.
|
|
65
|
+
const SHELL_BUILTINS = new Set([
|
|
66
|
+
'exit',
|
|
67
|
+
'true',
|
|
68
|
+
'false',
|
|
69
|
+
':',
|
|
70
|
+
'echo',
|
|
71
|
+
'cd',
|
|
72
|
+
'export',
|
|
73
|
+
'set',
|
|
74
|
+
'unset',
|
|
75
|
+
'read',
|
|
76
|
+
'test',
|
|
77
|
+
'[',
|
|
78
|
+
'printf',
|
|
79
|
+
'pwd',
|
|
80
|
+
'umask',
|
|
81
|
+
'wait',
|
|
82
|
+
'trap',
|
|
83
|
+
'eval',
|
|
84
|
+
'exec',
|
|
85
|
+
'source',
|
|
86
|
+
'.',
|
|
87
|
+
'alias',
|
|
88
|
+
'unalias',
|
|
89
|
+
'type',
|
|
90
|
+
'hash',
|
|
91
|
+
'jobs',
|
|
92
|
+
'kill',
|
|
93
|
+
'shift',
|
|
94
|
+
'return',
|
|
95
|
+
'break',
|
|
96
|
+
'continue',
|
|
97
|
+
'local',
|
|
98
|
+
'readonly',
|
|
99
|
+
'times',
|
|
100
|
+
'ulimit',
|
|
101
|
+
'command',
|
|
102
|
+
'builtin',
|
|
103
|
+
'let',
|
|
104
|
+
'declare',
|
|
105
|
+
'typeset',
|
|
106
|
+
])
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Prepend `exec ` to a command that is a single EXTERNAL program (no
|
|
110
|
+
* shell control characters, not a builtin, no leading env-assignment).
|
|
111
|
+
* `exec` REPLACES the wrapping `sh` with the program, so on a teardown
|
|
112
|
+
* SIGTERM there is no intermediate shell whose death would orphan the
|
|
113
|
+
* real process — the documented grandchild-orphan limitation, resolved
|
|
114
|
+
* for the common single-command case (dev servers: `astro dev`, `vite`,
|
|
115
|
+
* `next dev`; one-shot compilers). It also makes `resourceUsage` measure
|
|
116
|
+
* the program itself rather than the shell. Compound commands, builtins,
|
|
117
|
+
* and `FOO=bar cmd` forms keep the shell (compound-command grandchildren
|
|
118
|
+
* still orphan on a hard programmatic kill — the residual limit every
|
|
119
|
+
* non-cgroup runner shares).
|
|
120
|
+
*/
|
|
121
|
+
export function execWrap(command: string): string {
|
|
122
|
+
if (SHELL_CONTROL.test(command)) return command
|
|
123
|
+
const first = command.trimStart().split(/\s+/)[0] ?? ''
|
|
124
|
+
if (first === '' || first.includes('=') || SHELL_BUILTINS.has(first)) return command
|
|
125
|
+
return `exec ${command}`
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* POSIX shells report signal death as 128 + signal number (SIGTERM →
|
|
130
|
+
* 143, SIGKILL → 137). Bun gives us the signal NAME; `os.constants`
|
|
131
|
+
* maps it to the platform-correct number. Unknown names fall back to
|
|
132
|
+
* 130 (128 + SIGINT).
|
|
133
|
+
*/
|
|
134
|
+
export function signalExitCode(signal: string): number {
|
|
135
|
+
const num = (osConstants.signals as Partial<Record<string, number>>)[signal]
|
|
136
|
+
return num === undefined ? 130 : 128 + num
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Arm a SIGTERM timeout on a spawned child. Returns a handle whose
|
|
141
|
+
* `timedOut()` reports whether the timer fired — so the caller can
|
|
142
|
+
* classify the resulting SIGTERM as a real failure rather than a
|
|
143
|
+
* Ctrl-C abort — and `clear()` cancels the timer once the child exits
|
|
144
|
+
* on its own. A no-op (never fires, nothing to clear) when `timeoutMs`
|
|
145
|
+
* is undefined.
|
|
146
|
+
*/
|
|
147
|
+
export function armTimeout(
|
|
148
|
+
proc: ReturnType<typeof Bun.spawn>,
|
|
149
|
+
timeoutMs: number | undefined,
|
|
150
|
+
): { timedOut: () => boolean; clear: () => void } {
|
|
151
|
+
if (timeoutMs === undefined) return { timedOut: () => false, clear: () => {} }
|
|
152
|
+
let fired = false
|
|
153
|
+
const timer = setTimeout(() => {
|
|
154
|
+
fired = true
|
|
155
|
+
proc.kill('SIGTERM')
|
|
156
|
+
}, timeoutMs)
|
|
157
|
+
return { timedOut: () => fired, clear: () => clearTimeout(timer) }
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export interface PersistentSpawn {
|
|
161
|
+
/** Underlying Bun subprocess so the orchestrator can SIGTERM it later. */
|
|
162
|
+
child: ReturnType<typeof Bun.spawn>
|
|
163
|
+
/**
|
|
164
|
+
* Resolves once the task is considered "ready":
|
|
165
|
+
* - immediately on successful spawn when no `readyWhen` is given,
|
|
166
|
+
* - on the first stdout/stderr line that matches `readyWhen`.
|
|
167
|
+
* Rejects with the spawn error if the child fails to start.
|
|
168
|
+
*/
|
|
169
|
+
ready: Promise<void>
|
|
170
|
+
/** Captured stdout/stderr up to the moment ready resolved. */
|
|
171
|
+
bufferedStdout: () => string
|
|
172
|
+
bufferedStderr: () => string
|
|
173
|
+
/** ms elapsed from spawn to ready (or to current time if not yet ready). */
|
|
174
|
+
readyMs: () => number
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export interface PersistentOptions extends Omit<RunOptions, 'forwardArgs'> {
|
|
178
|
+
/**
|
|
179
|
+
* String regex. The first stdout/stderr line that matches signals
|
|
180
|
+
* "ready". Undefined → ready immediately on spawn.
|
|
181
|
+
*/
|
|
182
|
+
readyWhen?: string
|
|
183
|
+
/**
|
|
184
|
+
* Bound the readiness wait: if `readyWhen` hasn't matched within
|
|
185
|
+
* this window, the child is SIGTERMed and `ready` rejects. Only
|
|
186
|
+
* meaningful together with `readyWhen` (a ready-on-spawn task
|
|
187
|
+
* resolves before the timer can fire).
|
|
188
|
+
*/
|
|
189
|
+
timeoutMs?: number
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Spawn a long-running task. Unlike `runCommand`, this returns once
|
|
194
|
+
* the task is *ready* (per `readyWhen`) — not when it exits. The
|
|
195
|
+
* caller owns the returned `child` and must SIGTERM it during
|
|
196
|
+
* cleanup. Stdout/stderr keep streaming into the live `onStdout` /
|
|
197
|
+
* `onStderr` callbacks for the whole lifetime of the child.
|
|
198
|
+
*/
|
|
199
|
+
export function runPersistent(opts: PersistentOptions): PersistentSpawn {
|
|
200
|
+
const start = Date.now()
|
|
201
|
+
let bufferedStdout = ''
|
|
202
|
+
let bufferedStderr = ''
|
|
203
|
+
let readyAt: number | undefined
|
|
204
|
+
|
|
205
|
+
// Pattern compiled once; thrown errors surface synchronously so the
|
|
206
|
+
// caller can wrap with a user-facing message.
|
|
207
|
+
const readyRe = opts.readyWhen !== undefined ? new RegExp(opts.readyWhen) : undefined
|
|
208
|
+
|
|
209
|
+
let child: ReturnType<typeof Bun.spawn>
|
|
210
|
+
try {
|
|
211
|
+
child = Bun.spawn(['sh', '-c', execWrap(opts.command)], {
|
|
212
|
+
cwd: opts.cwd,
|
|
213
|
+
env: opts.env as Record<string, string>,
|
|
214
|
+
stdin: 'ignore',
|
|
215
|
+
stdout: 'pipe',
|
|
216
|
+
stderr: 'pipe',
|
|
217
|
+
})
|
|
218
|
+
} catch (err) {
|
|
219
|
+
const message = err instanceof Error ? err.message : String(err)
|
|
220
|
+
return {
|
|
221
|
+
child: undefined as unknown as ReturnType<typeof Bun.spawn>,
|
|
222
|
+
ready: Promise.reject(new Error(`failed to spawn persistent task: ${message}`)),
|
|
223
|
+
bufferedStdout: () => '',
|
|
224
|
+
bufferedStderr: () => `\n[vx] failed to spawn: ${message}\n`,
|
|
225
|
+
readyMs: () => Date.now() - start,
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
let resolveReady!: () => void
|
|
230
|
+
let rejectReady!: (err: Error) => void
|
|
231
|
+
const ready = new Promise<void>((resolve, reject) => {
|
|
232
|
+
resolveReady = resolve
|
|
233
|
+
rejectReady = reject
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
const markReady = (): void => {
|
|
237
|
+
if (readyAt === undefined) {
|
|
238
|
+
readyAt = Date.now()
|
|
239
|
+
if (readyTimer !== undefined) clearTimeout(readyTimer)
|
|
240
|
+
resolveReady()
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Stream readers. Each stream owns a pending fragment so a regex
|
|
245
|
+
// match isn't missed across chunk boundaries. The match runs over
|
|
246
|
+
// the WHOLE fragment, including a trailing partial line — prompt-
|
|
247
|
+
// style markers ("Listening on :3000" with no newline) would never
|
|
248
|
+
// resolve ready under line-by-line-only matching. Complete lines
|
|
249
|
+
// that didn't match are discarded after each test to bound memory.
|
|
250
|
+
const consumeChunks = async (
|
|
251
|
+
stream: ReadableStream<Uint8Array> | number | undefined,
|
|
252
|
+
isStderr: boolean,
|
|
253
|
+
): Promise<void> => {
|
|
254
|
+
if (!stream || typeof stream === 'number') return
|
|
255
|
+
let fragment = ''
|
|
256
|
+
const handleChunk = (chunk: string): void => {
|
|
257
|
+
if (chunk.length === 0) return
|
|
258
|
+
// Buffers capture "up to the moment ready resolved" (their
|
|
259
|
+
// documented contract) — a dev server kept alive for hours must
|
|
260
|
+
// not accrete its whole log history into vx's heap.
|
|
261
|
+
if (isStderr) {
|
|
262
|
+
if (readyAt === undefined) bufferedStderr += chunk
|
|
263
|
+
opts.onStderr?.(chunk)
|
|
264
|
+
} else {
|
|
265
|
+
if (readyAt === undefined) bufferedStdout += chunk
|
|
266
|
+
opts.onStdout?.(chunk)
|
|
267
|
+
}
|
|
268
|
+
if (readyRe && readyAt === undefined) {
|
|
269
|
+
fragment += chunk
|
|
270
|
+
if (readyRe.test(fragment)) {
|
|
271
|
+
markReady()
|
|
272
|
+
fragment = ''
|
|
273
|
+
} else {
|
|
274
|
+
const lastNl = fragment.lastIndexOf('\n')
|
|
275
|
+
if (lastNl >= 0) fragment = fragment.slice(lastNl + 1)
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
const reader = stream.getReader()
|
|
280
|
+
const decoder = new TextDecoder()
|
|
281
|
+
try {
|
|
282
|
+
while (true) {
|
|
283
|
+
const { value, done } = await reader.read()
|
|
284
|
+
if (done) break
|
|
285
|
+
handleChunk(decoder.decode(value, { stream: true }))
|
|
286
|
+
}
|
|
287
|
+
// Flush any undecoded multi-byte tail when the stream closes so
|
|
288
|
+
// the buffered text (and a final-fragment match) stay complete.
|
|
289
|
+
handleChunk(decoder.decode())
|
|
290
|
+
} finally {
|
|
291
|
+
reader.releaseLock()
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// Wire up readers. We deliberately don't await them — they run for
|
|
296
|
+
// the child's lifetime. The `ready` promise resolves out-of-band.
|
|
297
|
+
void consumeChunks(child.stdout, false)
|
|
298
|
+
void consumeChunks(child.stderr, true)
|
|
299
|
+
|
|
300
|
+
opts.liveChildren?.add(child)
|
|
301
|
+
|
|
302
|
+
// Readiness deadline. Reject FIRST so the failure reads as a
|
|
303
|
+
// timeout, then SIGTERM — the exit handler's later reject is a
|
|
304
|
+
// no-op on the settled promise. Cleared the moment ready fires so
|
|
305
|
+
// a healthy server is never killed by a stale timer.
|
|
306
|
+
let readyTimer: ReturnType<typeof setTimeout> | undefined
|
|
307
|
+
if (readyRe && opts.timeoutMs !== undefined) {
|
|
308
|
+
readyTimer = setTimeout(() => {
|
|
309
|
+
if (readyAt === undefined) {
|
|
310
|
+
rejectReady(
|
|
311
|
+
new Error(
|
|
312
|
+
`persistent task not ready within ${opts.timeoutMs}ms — ` +
|
|
313
|
+
`readyWhen pattern never matched; child killed`,
|
|
314
|
+
),
|
|
315
|
+
)
|
|
316
|
+
child.kill('SIGTERM')
|
|
317
|
+
}
|
|
318
|
+
}, opts.timeoutMs)
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// If the child exits BEFORE ready fires, that's a failure to start
|
|
322
|
+
// — reject the ready promise so the caller can surface it.
|
|
323
|
+
void child.exited.then((code) => {
|
|
324
|
+
opts.liveChildren?.delete(child)
|
|
325
|
+
if (readyTimer !== undefined) clearTimeout(readyTimer)
|
|
326
|
+
if (readyAt === undefined) {
|
|
327
|
+
rejectReady(
|
|
328
|
+
new Error(
|
|
329
|
+
`persistent task exited before becoming ready (exit ${code ?? '?'})` +
|
|
330
|
+
(readyRe ? ` — readyWhen pattern never matched` : ''),
|
|
331
|
+
),
|
|
332
|
+
)
|
|
333
|
+
}
|
|
334
|
+
})
|
|
335
|
+
|
|
336
|
+
// No readyWhen → ready immediately. We still wire the readers above
|
|
337
|
+
// so output streams during the task's lifetime.
|
|
338
|
+
if (!readyRe) markReady()
|
|
339
|
+
|
|
340
|
+
return {
|
|
341
|
+
child,
|
|
342
|
+
ready,
|
|
343
|
+
bufferedStdout: () => bufferedStdout,
|
|
344
|
+
bufferedStderr: () => bufferedStderr,
|
|
345
|
+
readyMs: () => (readyAt ?? Date.now()) - start,
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
export async function runCommand(opts: RunOptions): Promise<RunResult> {
|
|
350
|
+
const start = Date.now()
|
|
351
|
+
const fullCommand =
|
|
352
|
+
opts.forwardArgs && opts.forwardArgs.length > 0
|
|
353
|
+
? opts.command + ' ' + opts.forwardArgs.map(shellQuote).join(' ')
|
|
354
|
+
: opts.command
|
|
355
|
+
|
|
356
|
+
let proc: ReturnType<typeof Bun.spawn>
|
|
357
|
+
try {
|
|
358
|
+
proc = Bun.spawn(['sh', '-c', execWrap(fullCommand)], {
|
|
359
|
+
cwd: opts.cwd,
|
|
360
|
+
env: opts.env as Record<string, string>,
|
|
361
|
+
stdin: 'ignore',
|
|
362
|
+
stdout: 'pipe',
|
|
363
|
+
stderr: 'pipe',
|
|
364
|
+
})
|
|
365
|
+
} catch (err) {
|
|
366
|
+
const message = err instanceof Error ? err.message : String(err)
|
|
367
|
+
return {
|
|
368
|
+
exitCode: 127,
|
|
369
|
+
durationMs: Date.now() - start,
|
|
370
|
+
stdout: '',
|
|
371
|
+
stderr: `\n[vx] failed to spawn: ${message}\n`,
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
opts.liveChildren?.add(proc)
|
|
376
|
+
const timeout = armTimeout(proc, opts.timeoutMs)
|
|
377
|
+
const ac = new AbortController()
|
|
378
|
+
const streams = Promise.all([
|
|
379
|
+
streamToString(proc.stdout, opts.onStdout, ac.signal),
|
|
380
|
+
streamToString(proc.stderr, opts.onStderr, ac.signal),
|
|
381
|
+
])
|
|
382
|
+
// Gate on the child's own exit, not on stream EOF: a timeout SIGTERM
|
|
383
|
+
// kills `sh` but an orphaned grandchild can keep the pipe open, so we
|
|
384
|
+
// abort the readers once the child is gone rather than wait forever.
|
|
385
|
+
await proc.exited
|
|
386
|
+
timeout.clear()
|
|
387
|
+
if (timeout.timedOut()) ac.abort()
|
|
388
|
+
const [stdout, stderr] = await streams
|
|
389
|
+
opts.liveChildren?.delete(proc)
|
|
390
|
+
const exitCode = proc.exitCode ?? (proc.signalCode ? signalExitCode(proc.signalCode) : 1)
|
|
391
|
+
return {
|
|
392
|
+
exitCode,
|
|
393
|
+
durationMs: Date.now() - start,
|
|
394
|
+
stdout,
|
|
395
|
+
stderr,
|
|
396
|
+
...(proc.signalCode ? { signal: proc.signalCode } : {}),
|
|
397
|
+
...(timeout.timedOut() ? { timedOut: true } : {}),
|
|
398
|
+
...resourceUsageToCpuRss(proc.resourceUsage()),
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* Drain a `Bun.spawn` stdout/stderr stream while invoking the live
|
|
404
|
+
* callback per UTF-8 chunk. Returns the full accumulated string.
|
|
405
|
+
*/
|
|
406
|
+
export async function streamToString(
|
|
407
|
+
stream: ReadableStream<Uint8Array> | number | undefined,
|
|
408
|
+
onChunk?: (s: string) => void,
|
|
409
|
+
signal?: AbortSignal,
|
|
410
|
+
): Promise<string> {
|
|
411
|
+
// Bun.spawn types stdout/stderr as `ReadableStream | number | undefined`
|
|
412
|
+
// — the `number` is for inheritance modes, only present when the caller
|
|
413
|
+
// chose `'inherit'` instead of `'pipe'`. We only call this with `'pipe'`,
|
|
414
|
+
// so the runtime value is always a ReadableStream; the `number` branch
|
|
415
|
+
// is unreachable but typed.
|
|
416
|
+
if (!stream || typeof stream === 'number') return ''
|
|
417
|
+
let full = ''
|
|
418
|
+
const reader = stream.getReader()
|
|
419
|
+
const decoder = new TextDecoder()
|
|
420
|
+
// On abort, cancel the read so a pending `reader.read()` resolves
|
|
421
|
+
// `done` and we return whatever we captured. Needed for the timeout
|
|
422
|
+
// path: SIGTERMing `sh` doesn't close the pipe if an orphaned
|
|
423
|
+
// grandchild still holds the write end, so EOF never arrives — the
|
|
424
|
+
// abort breaks us out instead of hanging the run.
|
|
425
|
+
const onAbort = (): void => void reader.cancel().catch(() => {})
|
|
426
|
+
if (signal?.aborted) onAbort()
|
|
427
|
+
else signal?.addEventListener('abort', onAbort, { once: true })
|
|
428
|
+
try {
|
|
429
|
+
while (true) {
|
|
430
|
+
const { value, done } = await reader.read()
|
|
431
|
+
if (done) break
|
|
432
|
+
const chunk = decoder.decode(value, { stream: true })
|
|
433
|
+
full += chunk
|
|
434
|
+
onChunk?.(chunk)
|
|
435
|
+
}
|
|
436
|
+
const tail = decoder.decode()
|
|
437
|
+
if (tail.length > 0) {
|
|
438
|
+
full += tail
|
|
439
|
+
onChunk?.(tail)
|
|
440
|
+
}
|
|
441
|
+
} finally {
|
|
442
|
+
signal?.removeEventListener('abort', onAbort)
|
|
443
|
+
reader.releaseLock()
|
|
444
|
+
}
|
|
445
|
+
return full
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* Pull CPU + RSS out of Bun's resourceUsage() shape into our schema's
|
|
450
|
+
* shape (ms + bytes). Returns an empty object when usage isn't available
|
|
451
|
+
* (e.g., the platform didn't expose rusage), so the orchestrator records
|
|
452
|
+
* NULLs in the runs table for those tasks.
|
|
453
|
+
*/
|
|
454
|
+
export function resourceUsageToCpuRss(
|
|
455
|
+
usage: ReturnType<ReturnType<typeof Bun.spawn>['resourceUsage']>,
|
|
456
|
+
platform: NodeJS.Platform = process.platform,
|
|
457
|
+
): { cpuMs?: number; peakRssBytes?: number } {
|
|
458
|
+
if (!usage) return {}
|
|
459
|
+
// cpuTime.total is microseconds as a bigint → ms.
|
|
460
|
+
const cpuMs = Number(usage.cpuTime.total) / 1000
|
|
461
|
+
// maxRSS is the raw kernel `ru_maxrss`, whose UNIT IS PLATFORM-SPECIFIC and
|
|
462
|
+
// Bun does NOT normalize it:
|
|
463
|
+
// • Linux → kilobytes (multiply by 1024 for bytes)
|
|
464
|
+
// • macOS / BSD → bytes (already bytes; do NOT multiply)
|
|
465
|
+
// • Windows → bytes (PeakWorkingSetSize)
|
|
466
|
+
// Treating macOS's byte value as KB inflates peak RSS by 1024× — e.g. a
|
|
467
|
+
// real 460 MB showed up as 460 GB. Convert per platform.
|
|
468
|
+
const peakRssBytes = platform === 'linux' ? usage.maxRSS * 1024 : usage.maxRSS
|
|
469
|
+
return { cpuMs, peakRssBytes }
|
|
470
|
+
}
|