@vzn/vx 0.0.595 → 0.0.597
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 +1 -1
- package/launcher.cjs +7 -3
- package/package.json +5 -5
- package/src/cache/archive.ts +35 -6
- package/src/cache/cache.ts +170 -99
- package/src/cache/chained-cache.ts +25 -7
- package/src/cache/git-inputs.ts +23 -0
- package/src/cache/index.ts +3 -0
- package/src/cache/inputs.ts +99 -44
- package/src/cache/key-fold.ts +1 -1
- package/src/cache/layer.ts +9 -6
- package/src/cache/policy.ts +5 -0
- package/src/cache/zstd.ts +10 -4
- package/src/cli/cache.ts +12 -6
- package/src/cli/foreign-flags.ts +57 -3
- package/src/cli/info.ts +1 -1
- package/src/cli/init.ts +2 -2
- package/src/cli/last.ts +10 -4
- package/src/cli/lock.ts +5 -3
- package/src/cli/plan-format.ts +4 -4
- package/src/cli/run.ts +73 -30
- package/src/cli/select.ts +6 -3
- package/src/cli/show.ts +8 -2
- package/src/cli/upgrade.ts +1 -1
- package/src/cli/watch-filter.ts +63 -12
- package/src/cli/watch-fs.ts +11 -3
- package/src/cli/watch-judge.ts +15 -8
- package/src/cli/watch.ts +18 -11
- package/src/cli/workspace-config.ts +3 -1
- package/src/config.ts +42 -21
- package/src/exec/executor.ts +1 -1
- package/src/exec/kill-tree.ts +89 -18
- package/src/exec/proc-sample.ts +1 -1
- package/src/exec/runner.ts +74 -36
- package/src/exec/sandbox-paths.ts +12 -1
- package/src/exec/sandbox-runtime.ts +283 -88
- package/src/exec/sandbox-violations.ts +115 -15
- package/src/graph/scheduler.ts +30 -14
- package/src/graph/task-graph.ts +265 -44
- package/src/orchestrator/admission.ts +12 -3
- package/src/orchestrator/affected-tasks.ts +6 -2
- package/src/orchestrator/deferred-outputs.ts +3 -2
- package/src/orchestrator/doctor.ts +2 -2
- package/src/orchestrator/events.ts +10 -1
- package/src/orchestrator/excluded-keys.ts +2 -13
- package/src/orchestrator/execute-task.ts +85 -39
- package/src/orchestrator/failure-mode.ts +43 -28
- package/src/orchestrator/framed-output.ts +8 -8
- package/src/orchestrator/index.ts +0 -1
- package/src/orchestrator/keyed-projects.ts +6 -3
- package/src/orchestrator/logger.ts +21 -9
- package/src/orchestrator/metrics.ts +19 -8
- package/src/orchestrator/miss-save.ts +9 -13
- package/src/orchestrator/options.ts +7 -0
- package/src/orchestrator/plan.ts +7 -23
- package/src/orchestrator/plugin.ts +37 -16
- package/src/orchestrator/prepare.ts +13 -4
- package/src/orchestrator/run-artifacts.ts +4 -3
- package/src/orchestrator/run-lock.ts +25 -24
- package/src/orchestrator/run.ts +22 -5
- package/src/orchestrator/sandbox-request.ts +96 -18
- package/src/orchestrator/save-lane.ts +5 -5
- package/src/orchestrator/signals.ts +22 -4
- package/src/orchestrator/stable-keys.ts +3 -11
- package/src/orchestrator/summary.ts +7 -13
- package/src/orchestrator/task-hash.ts +13 -3
- package/src/orchestrator/telemetry.ts +16 -7
- package/src/orchestrator/upstream.ts +11 -0
- package/src/util/hangup.ts +42 -0
- package/src/util/index.ts +2 -0
- package/src/util/paths.ts +24 -4
- package/src/workspace/affected.ts +79 -42
- package/src/workspace/config-eval.ts +27 -6
- package/src/workspace/config-imports.ts +8 -53
- package/src/workspace/config-schema.ts +56 -9
- package/src/workspace/filter.ts +19 -0
- package/src/workspace/lockfile.ts +2 -1
- package/src/workspace/migrate-scripts.ts +5 -3
- package/src/workspace/migration.ts +1 -1
- package/src/workspace/project-loader.ts +19 -4
- package/src/workspace/repo-id.ts +42 -4
- package/src/workspace/workspace.ts +53 -24
|
@@ -271,7 +271,7 @@ export async function sandboxRequestFor(
|
|
|
271
271
|
// could copy a key into an output the cache shares (L-41).
|
|
272
272
|
baseDenyRead: [
|
|
273
273
|
...(process.platform === 'darwin' ? [workspaceRoot, ...walls] : [workspaceRoot]),
|
|
274
|
-
...credentialStores(workspaceRoot),
|
|
274
|
+
...(await credentialStores(workspaceRoot, config.allowWrite)),
|
|
275
275
|
],
|
|
276
276
|
// …but only denials INSIDE the project are worth reporting. A task
|
|
277
277
|
// bumping into the wall is the sandbox working, not a finding: the
|
|
@@ -326,8 +326,11 @@ export interface WithheldLink {
|
|
|
326
326
|
* task is granted and what it is not.
|
|
327
327
|
*
|
|
328
328
|
* One level deep, plus one level inside a `@scope/` directory — the shape
|
|
329
|
-
* a package manager writes
|
|
330
|
-
*
|
|
329
|
+
* a package manager writes — and then the same in the `node_modules` of
|
|
330
|
+
* each granted target, so a dependency's own workspace dependencies are
|
|
331
|
+
* reached as the runtime reaches them. A target inside the granted
|
|
332
|
+
* `node_modules` or inside the package holding the link is dropped; what
|
|
333
|
+
* is left is a sibling project's real path.
|
|
331
334
|
*
|
|
332
335
|
* A link to the task's OWN project, or to a directory holding it, is
|
|
333
336
|
* dropped too: npm and Yarn classic link every workspace package at the
|
|
@@ -371,20 +374,39 @@ async function linkedDeps(
|
|
|
371
374
|
}
|
|
372
375
|
return found
|
|
373
376
|
}
|
|
377
|
+
const keyedDirs =
|
|
378
|
+
keyed === undefined ? undefined : new Set(await Promise.all([...keyed].map((d) => realpath(d))))
|
|
379
|
+
const grants = (target: string): boolean =>
|
|
380
|
+
keyedDirs === undefined || !atOrUnder(target, root) || keyedDirs.has(target)
|
|
374
381
|
// Merged in `dirs` order, so the link a hint names is the project's own
|
|
375
|
-
// when both have one.
|
|
382
|
+
// when both have one. A granted package is scanned in turn: pnpm and
|
|
383
|
+
// Bun link a dependency's own workspace dependencies only under ITS
|
|
384
|
+
// `node_modules`, so `app -> ui -> core` left core hidden and the import
|
|
385
|
+
// of ui failed with ENOENT, unreported.
|
|
376
386
|
const targets = new Map<string, { link: string; name: string }>()
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
387
|
+
let frontier: Array<[nodeModules: string, owner: string | undefined]> = dirs.map((d) => [
|
|
388
|
+
d,
|
|
389
|
+
undefined,
|
|
390
|
+
])
|
|
391
|
+
while (frontier.length > 0) {
|
|
392
|
+
const next: typeof frontier = []
|
|
393
|
+
const scans = await Promise.all(frontier.map(([d]) => scan(d, '')))
|
|
394
|
+
for (const [i, found] of scans.entries()) {
|
|
395
|
+
const owner = frontier[i]![1]
|
|
396
|
+
for (const [target, link, name] of found) {
|
|
397
|
+
if (targets.has(target) || (owner !== undefined && atOrUnder(target, owner))) continue
|
|
398
|
+
targets.set(target, { link, name })
|
|
399
|
+
if (grants(target)) {
|
|
400
|
+
next.push([path.join(target, 'node_modules'), target])
|
|
401
|
+
}
|
|
402
|
+
}
|
|
380
403
|
}
|
|
404
|
+
frontier = next
|
|
381
405
|
}
|
|
382
|
-
if (keyed === undefined) return { granted: [...targets.keys()], withheld: [] }
|
|
383
|
-
const keyedDirs = new Set(await Promise.all([...keyed].map((d) => realpath(d))))
|
|
384
406
|
const granted: string[] = []
|
|
385
407
|
const withheld: WithheldLink[] = []
|
|
386
408
|
for (const [target, { link, name }] of targets) {
|
|
387
|
-
if (
|
|
409
|
+
if (grants(target)) granted.push(target)
|
|
388
410
|
else
|
|
389
411
|
withheld.push({
|
|
390
412
|
dir: target,
|
|
@@ -464,11 +486,13 @@ async function prepareOutputsForBind(
|
|
|
464
486
|
await mkdir(abs, { recursive: true }).catch(() => undefined)
|
|
465
487
|
continue
|
|
466
488
|
}
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
489
|
+
const dir = hasWildcard || g.endsWith('/')
|
|
490
|
+
const abs = path.join(projectDir, hasWildcard ? grantPrefix(g) : g)
|
|
491
|
+
try {
|
|
492
|
+
if (dir) {
|
|
493
|
+
await mkdir(abs, { recursive: true })
|
|
494
|
+
continue
|
|
495
|
+
}
|
|
472
496
|
// Whatever is already there is what the task meant — a grant on the
|
|
473
497
|
// project dir itself is a directory, and touching it as a file is
|
|
474
498
|
// an EISDIR, not a missing bind. `lstat`, and an exclusive create: a
|
|
@@ -478,11 +502,33 @@ async function prepareOutputsForBind(
|
|
|
478
502
|
await mkdir(path.dirname(abs), { recursive: true })
|
|
479
503
|
await writeFile(abs, '', { flag: 'wx' })
|
|
480
504
|
placeholders.push({ path: abs, mtimeMs: (await lstat(abs)).mtimeMs })
|
|
505
|
+
} catch (err) {
|
|
506
|
+
await sweepPlaceholders(placeholders)
|
|
507
|
+
const code = (err as NodeJS.ErrnoException).code
|
|
508
|
+
if (code !== 'EEXIST' && code !== 'ENOTDIR') throw err
|
|
509
|
+
// A file stands where the grant needs a directory: `out.txt/` over a
|
|
510
|
+
// file, or `a.txt/x/*.js`. It reached the user as an internal error.
|
|
511
|
+
const blocker = await fileOnPath(projectDir, dir ? abs : path.dirname(abs))
|
|
512
|
+
throw new UserError(
|
|
513
|
+
`exec.sandbox.allow.write: "${g}" needs a directory at ${blocker}, and a file is ` +
|
|
514
|
+
`there — remove the file, or grant a path beside it`,
|
|
515
|
+
)
|
|
481
516
|
}
|
|
482
517
|
}
|
|
483
518
|
return placeholders
|
|
484
519
|
}
|
|
485
520
|
|
|
521
|
+
/** The first path from `from` down to `to` that exists and is not a directory. */
|
|
522
|
+
async function fileOnPath(from: string, to: string): Promise<string> {
|
|
523
|
+
let at = from
|
|
524
|
+
for (const part of path.relative(from, to).split(path.sep)) {
|
|
525
|
+
at = path.join(at, part)
|
|
526
|
+
const st = await lstat(at).catch(() => undefined)
|
|
527
|
+
if (st !== undefined && !st.isDirectory()) return at
|
|
528
|
+
}
|
|
529
|
+
return to
|
|
530
|
+
}
|
|
531
|
+
|
|
486
532
|
/**
|
|
487
533
|
* The walls a project's grants stop at: its nested projects, the
|
|
488
534
|
* repository and vx's directory (item 1010). A read grant around one is
|
|
@@ -589,13 +635,35 @@ const CREDENTIAL_STORES = [
|
|
|
589
635
|
'.npmrc',
|
|
590
636
|
'.yarnrc.yml',
|
|
591
637
|
'.pypirc',
|
|
638
|
+
// Registry and service tokens of the same kind: Bun's global bunfig
|
|
639
|
+
// (`[install.scopes]` tokens) at either of its homes, Cargo's, RubyGems'.
|
|
640
|
+
'.bunfig.toml',
|
|
641
|
+
'.config/.bunfig.toml',
|
|
642
|
+
'.cargo/credentials',
|
|
643
|
+
'.cargo/credentials.toml',
|
|
644
|
+
'.gem/credentials',
|
|
645
|
+
'.config/hub',
|
|
646
|
+
'.config/containers/auth.json',
|
|
647
|
+
'.terraform.d/credentials.tfrc.json',
|
|
648
|
+
'.vault-token',
|
|
649
|
+
'.pgpass',
|
|
592
650
|
]
|
|
593
651
|
|
|
594
652
|
/** The stores present on this host, learned once per home. */
|
|
595
653
|
let presentStores: { home: string; paths: string[] } | undefined
|
|
596
654
|
|
|
597
|
-
/**
|
|
598
|
-
|
|
655
|
+
/**
|
|
656
|
+
* The credential stores to deny, less any that holds the workspace, and
|
|
657
|
+
* punched around the task's write grants: a grant names a store as a read
|
|
658
|
+
* does. Denied whole, the store's mask landed on the grant's bind (a file
|
|
659
|
+
* grant binds its directory on Linux), so `write: ['~/.npmrc']` wrote to
|
|
660
|
+
* /dev/null with exit 0, and `~/.aws/config` left `~/.aws/credentials`
|
|
661
|
+
* readable. A grant above a store does not name it, so it stays denied.
|
|
662
|
+
*/
|
|
663
|
+
async function credentialStores(
|
|
664
|
+
workspaceRoot: string,
|
|
665
|
+
writes: readonly string[],
|
|
666
|
+
): Promise<string[]> {
|
|
599
667
|
const home = homedir()
|
|
600
668
|
if (presentStores?.home !== home) {
|
|
601
669
|
presentStores = {
|
|
@@ -603,9 +671,19 @@ function credentialStores(workspaceRoot: string): string[] {
|
|
|
603
671
|
paths: CREDENTIAL_STORES.map((rel) => path.join(home, rel)).filter((p) => existsSync(p)),
|
|
604
672
|
}
|
|
605
673
|
}
|
|
606
|
-
|
|
674
|
+
const stores = presentStores.paths.filter(
|
|
607
675
|
(p) => workspaceRoot !== p && !workspaceRoot.startsWith(p + path.sep),
|
|
608
676
|
)
|
|
677
|
+
return (await Promise.all(stores.map((p) => denyAround(toRealPath(p), writes)))).flat()
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
/** `dir` denied, or its entries no write grant lies under or at when one is inside it. */
|
|
681
|
+
async function denyAround(dir: string, writes: readonly string[]): Promise<string[]> {
|
|
682
|
+
const inside = writes.filter((w) => atOrUnder(w, dir))
|
|
683
|
+
if (inside.length === 0) return [dir]
|
|
684
|
+
if (inside.includes(dir)) return []
|
|
685
|
+
const entries = await readdir(dir).catch(() => [])
|
|
686
|
+
return (await Promise.all(entries.map((e) => denyAround(path.join(dir, e), inside)))).flat()
|
|
609
687
|
}
|
|
610
688
|
|
|
611
689
|
/** `~/x` against the user's home; anything else unchanged. */
|
|
@@ -21,22 +21,22 @@ export interface SaveLane {
|
|
|
21
21
|
|
|
22
22
|
export function createSaveLane(cap: number, onError: (err: unknown) => void): SaveLane {
|
|
23
23
|
const queue: Array<{ save: () => Promise<void>; settle: () => void }> = []
|
|
24
|
-
|
|
24
|
+
let running = 0
|
|
25
25
|
const start = (save: () => Promise<void>, settle: () => void): void => {
|
|
26
|
-
|
|
26
|
+
void save()
|
|
27
27
|
.catch(onError)
|
|
28
28
|
.then(() => {
|
|
29
|
-
running
|
|
29
|
+
running--
|
|
30
30
|
settle()
|
|
31
31
|
const next = queue.shift()
|
|
32
32
|
if (next !== undefined) start(next.save, next.settle)
|
|
33
33
|
})
|
|
34
|
-
running
|
|
34
|
+
running++
|
|
35
35
|
}
|
|
36
36
|
return {
|
|
37
37
|
defer(save) {
|
|
38
38
|
return new Promise<void>((settle) => {
|
|
39
|
-
if (running
|
|
39
|
+
if (running < cap) start(save, settle)
|
|
40
40
|
else queue.push({ save, settle })
|
|
41
41
|
})
|
|
42
42
|
},
|
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
// — the scheduler dispatches nothing more, and the signal is forwarded to
|
|
3
3
|
// everything live, SIGKILLed after a bounded grace — then waits, bounded,
|
|
4
4
|
// for run() to leave its own end-of-run path (telemetry flush, each
|
|
5
|
-
// plugin's teardown, the cache close), and
|
|
6
|
-
// second signal
|
|
5
|
+
// plugin's teardown, the cache close), and dies of the signal (130/143 to
|
|
6
|
+
// its parent). A second signal does so at once. Without the forward, a
|
|
7
7
|
// programmatic signal to the vx process alone (CI cancellation,
|
|
8
8
|
// `kill <pid>`) orphans every running child — terminal Ctrl-C only worked
|
|
9
9
|
// via process-group propagation. Without the escalation (added
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
// them to the runner around every spawn.
|
|
15
15
|
|
|
16
16
|
import { signalExitCode } from '../exec/index.js'
|
|
17
|
-
import { claimExitForSignal, killGraceMs, settleWithin } from '../util/index.js'
|
|
17
|
+
import { claimExitForSignal, hangupIgnored, killGraceMs, settleWithin } from '../util/index.js'
|
|
18
18
|
import { holdGroups, killTree, untilGroupsGone } from '../exec/index.js'
|
|
19
19
|
import type { Logger } from './logger.js'
|
|
20
20
|
|
|
@@ -111,6 +111,13 @@ export function forwardSignals(args: {
|
|
|
111
111
|
done: Promise<void>
|
|
112
112
|
/** How long a signal waits for `done` before it exits anyway. */
|
|
113
113
|
boundMs: number
|
|
114
|
+
/**
|
|
115
|
+
* A task may be handed the terminal (`exec.interactive` on a TTY) and may
|
|
116
|
+
* leave it raw or without echo. Bun puts back the terminal it started
|
|
117
|
+
* with when it exits, not when a signal kills it, so this run exits with
|
|
118
|
+
* the signal's code rather than dying of the signal.
|
|
119
|
+
*/
|
|
120
|
+
handsTerminal?: boolean
|
|
114
121
|
/** In-flight children; the runner adds and removes each around its spawn. */
|
|
115
122
|
liveChildren: ReadonlySet<Child>
|
|
116
123
|
/** Ready persistent tasks the orchestrator owns until the graph finishes. */
|
|
@@ -124,6 +131,16 @@ export function forwardSignals(args: {
|
|
|
124
131
|
} catch {
|
|
125
132
|
// double-close race with the normal path; we're exiting anyway
|
|
126
133
|
}
|
|
134
|
+
// Die of the signal once the exit hooks have run: a shell running vx
|
|
135
|
+
// in a script stops it on Ctrl-C only when its child died of SIGINT,
|
|
136
|
+
// and a 130 exit ran the script's next line. The code stays the
|
|
137
|
+
// fallback should the signal not land.
|
|
138
|
+
if (args.handsTerminal !== true) {
|
|
139
|
+
process.once('exit', () => {
|
|
140
|
+
process.removeAllListeners(signal)
|
|
141
|
+
process.kill(process.pid, signal)
|
|
142
|
+
})
|
|
143
|
+
}
|
|
127
144
|
process.exit(signalExitCode(signal))
|
|
128
145
|
}
|
|
129
146
|
let stopping: StopSignal | undefined
|
|
@@ -158,7 +175,8 @@ export function forwardSignals(args: {
|
|
|
158
175
|
if (args.enabled) {
|
|
159
176
|
process.on('SIGINT', onSigint)
|
|
160
177
|
process.on('SIGTERM', onSigterm)
|
|
161
|
-
|
|
178
|
+
// A hang-up vx was started deaf to (nohup) stays ignored.
|
|
179
|
+
if (!hangupIgnored()) process.on('SIGHUP', onSighup)
|
|
162
180
|
}
|
|
163
181
|
return {
|
|
164
182
|
remove: () => {
|
|
@@ -17,7 +17,7 @@ import { isLiteralPattern, normalizeGlob, relPosix } from '../util/index.js'
|
|
|
17
17
|
import { commandWriteReach, mayWriteFingerprint, undeclaredWriteReach } from './sandbox-request.js'
|
|
18
18
|
import { foldedDeps } from './keyed-projects.js'
|
|
19
19
|
import { computeGroupKey, computeTaskHash, type HashCache } from './task-hash.js'
|
|
20
|
-
import { filterUpstreamHashes, keyUpstream } from './upstream.js'
|
|
20
|
+
import { filterUpstreamHashes, keyedOutcome, keyUpstream } from './upstream.js'
|
|
21
21
|
|
|
22
22
|
export interface DeriveStableKeysArgs {
|
|
23
23
|
nodes: Map<string, TaskNode>
|
|
@@ -382,9 +382,7 @@ function foldedBy(
|
|
|
382
382
|
}
|
|
383
383
|
|
|
384
384
|
/**
|
|
385
|
-
* Synthetic upstream outcomes for key derivation
|
|
386
|
-
* are read by filterUpstreamHashes / computeGroupHash; the rest of
|
|
387
|
-
* TaskOutcome is irrelevant here, so we cast a minimal shape.
|
|
385
|
+
* Synthetic upstream outcomes for key derivation (`keyedOutcome`).
|
|
388
386
|
*/
|
|
389
387
|
function synthUpstream(
|
|
390
388
|
node: TaskNode,
|
|
@@ -395,13 +393,7 @@ function synthUpstream(
|
|
|
395
393
|
for (const dep of node.deps) {
|
|
396
394
|
const depNode = nodes.get(dep)
|
|
397
395
|
if (!depNode) continue
|
|
398
|
-
out.push(
|
|
399
|
-
node: depNode,
|
|
400
|
-
status: 'success',
|
|
401
|
-
exitCode: 0,
|
|
402
|
-
durationMs: 0,
|
|
403
|
-
hash: keyById.get(dep),
|
|
404
|
-
} as TaskOutcome)
|
|
396
|
+
out.push(keyedOutcome(depNode, keyById.get(dep)))
|
|
405
397
|
}
|
|
406
398
|
return keyUpstream(node, out)
|
|
407
399
|
}
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
import type { TaskOutcome } from '../graph/index.js'
|
|
7
7
|
import { paint, type ColorSupport } from './colors.js'
|
|
8
8
|
import { tallyOutcomes } from './tally.js'
|
|
9
|
-
import { outcomeLabel, projectOutcome } from './events.js'
|
|
9
|
+
import { outcomeLabel, projectOutcome, ranNoCache } from './events.js'
|
|
10
10
|
import { isGroupTask } from '../graph/index.js'
|
|
11
11
|
import { formatElapsed } from '../util/index.js'
|
|
12
12
|
|
|
@@ -264,7 +264,7 @@ export function formatSummarySection(
|
|
|
264
264
|
let spread = ''
|
|
265
265
|
if (stats.spread !== null && stats.spread.count > 0) {
|
|
266
266
|
const { maxMs, minMs, sumMs, count } = stats.spread
|
|
267
|
-
spread = ` ${dim(`\u00b7 max ${
|
|
267
|
+
spread = ` ${dim(`\u00b7 max ${formatElapsed(maxMs)} \u00b7 avg ${formatElapsed(sumMs / count)} \u00b7 min ${formatElapsed(minMs)}`)}`
|
|
268
268
|
}
|
|
269
269
|
// Run-shape footer (final summary only): worker pool + cache mode,
|
|
270
270
|
// grouped with time under a blank line below the meters.
|
|
@@ -280,15 +280,15 @@ export function formatSummarySection(
|
|
|
280
280
|
// (2026-09-15), so the line says it is a sum.
|
|
281
281
|
if (stats.held !== undefined && stats.held.count > 0) {
|
|
282
282
|
info.push(
|
|
283
|
-
`admit held ${stats.held.count} task${stats.held.count === 1 ? '' : 's'}, ${
|
|
283
|
+
`admit held ${stats.held.count} task${stats.held.count === 1 ? '' : 's'}, ${formatElapsed(stats.held.sumMs)} in all`,
|
|
284
284
|
)
|
|
285
285
|
}
|
|
286
|
-
lines.push('', row('info', join(info)), row('time', `${
|
|
286
|
+
lines.push('', row('info', join(info)), row('time', `${formatElapsed(totalMs)}${spread}`))
|
|
287
287
|
// The run in one line, last, where the eye lands: what a Turbo user
|
|
288
288
|
// reads first in its own summary (tasks, cached, time), in vx's words.
|
|
289
289
|
if (stats.total > 0) lines.push(row('result', resultLine(stats, hits, totalMs, colors)))
|
|
290
290
|
} else {
|
|
291
|
-
lines.push('', row('time', `${
|
|
291
|
+
lines.push('', row('time', `${formatElapsed(totalMs)}${spread}`))
|
|
292
292
|
}
|
|
293
293
|
return lines
|
|
294
294
|
}
|
|
@@ -320,7 +320,7 @@ function resultLine(
|
|
|
320
320
|
: `${hits} cached (${Math.floor((hits / cacheable) * 100)}%)`,
|
|
321
321
|
)
|
|
322
322
|
if (noCache > 0) parts.push(paint('', `${noCache} no-cache`, colors, { dim: true }))
|
|
323
|
-
parts.push(
|
|
323
|
+
parts.push(formatElapsed(totalMs))
|
|
324
324
|
return parts.join(` ${paint('', '\u00b7', colors, { dim: true })} `)
|
|
325
325
|
}
|
|
326
326
|
|
|
@@ -365,9 +365,7 @@ export function formatRunSummary(
|
|
|
365
365
|
// distinction — it never consulted the cache, so it is not a miss.
|
|
366
366
|
const noCache = outcomes.filter(
|
|
367
367
|
(o) =>
|
|
368
|
-
!isGroupTask(o.node) &&
|
|
369
|
-
(o.status === 'success' || o.status === 'failed') &&
|
|
370
|
-
o.node.config.cache === undefined,
|
|
368
|
+
!isGroupTask(o.node) && (o.status === 'success' || o.status === 'failed') && ranNoCache(o),
|
|
371
369
|
).length
|
|
372
370
|
const t = tallyOutcomes(outcomes)
|
|
373
371
|
// Spread over executed tasks only — `success`/`failed` statuses ran
|
|
@@ -428,7 +426,3 @@ export function neverStarted(
|
|
|
428
426
|
): boolean {
|
|
429
427
|
return o.status === 'aborted' && o.wallclockStartNs === undefined
|
|
430
428
|
}
|
|
431
|
-
|
|
432
|
-
export function formatDuration(ms: number): string {
|
|
433
|
-
return formatElapsed(ms)
|
|
434
|
-
}
|
|
@@ -16,8 +16,8 @@ import {
|
|
|
16
16
|
WORKSPACE_OUTPUT_PREFIX,
|
|
17
17
|
} from '../cache/index.js'
|
|
18
18
|
import type { InputFile, TaskInputs } from '../exec/index.js'
|
|
19
|
-
import type
|
|
20
|
-
import {
|
|
19
|
+
import { isGroupTask, type TaskNode, type TaskOutcome } from '../graph/index.js'
|
|
20
|
+
import { isUserError, relPosix, secretMask, span, UserError, xxh3hex } from '../util/index.js'
|
|
21
21
|
import { expandGroupUpstream, filterUpstreamHashes } from './upstream.js'
|
|
22
22
|
|
|
23
23
|
/**
|
|
@@ -296,6 +296,13 @@ async function resolveKeyInput(
|
|
|
296
296
|
projectFilesCache: args.hashCache.projectFiles,
|
|
297
297
|
}
|
|
298
298
|
: {}),
|
|
299
|
+
}).catch((err: unknown) => {
|
|
300
|
+
// A failed probe's message quotes its command and output, and the probe
|
|
301
|
+
// runs in vx's own environment: `echo $NPM_TOKEN` printed the token in
|
|
302
|
+
// the task's stream and `--dry`'s stderr.
|
|
303
|
+
if (!isUserError(err)) throw err
|
|
304
|
+
const secrets = secretMask([process.env, cfg.exec?.env?.define], cfg.exec?.env?.secret)
|
|
305
|
+
throw secrets === null ? err : new UserError(secrets.mask(err.message))
|
|
299
306
|
})
|
|
300
307
|
|
|
301
308
|
const upstreamPairs = filterUpstreamHashes(
|
|
@@ -338,7 +345,10 @@ async function resolveKeyInput(
|
|
|
338
345
|
fileHashes,
|
|
339
346
|
)
|
|
340
347
|
|
|
341
|
-
|
|
348
|
+
// Args after `--` reach a requested command; the default build (a keyed
|
|
349
|
+
// group) runs none, and folded, they moved every key that folds its own.
|
|
350
|
+
const effectiveForwardArgs =
|
|
351
|
+
args.node.requested && !isGroupTask(args.node) ? (args.forwardArgs ?? []) : []
|
|
342
352
|
|
|
343
353
|
const input: CacheKeyInput = {
|
|
344
354
|
taskId: args.node.id,
|
|
@@ -481,6 +481,15 @@ export interface TelemetrySource {
|
|
|
481
481
|
/** How often a running task's process tree is sampled. */
|
|
482
482
|
const SAMPLE_MS = 1000
|
|
483
483
|
|
|
484
|
+
/** Call `fn` every `ms` until the returned stop is called. */
|
|
485
|
+
type Every = (ms: number, fn: () => Promise<void>) => () => void
|
|
486
|
+
|
|
487
|
+
const everyInterval: Every = (ms, fn) => {
|
|
488
|
+
const timer = setInterval(() => void fn(), ms)
|
|
489
|
+
timer.unref()
|
|
490
|
+
return () => clearInterval(timer)
|
|
491
|
+
}
|
|
492
|
+
|
|
484
493
|
const DEFAULT_KINDS: ReadonlyArray<TelemetryRecord['kind']> = [
|
|
485
494
|
'run.start',
|
|
486
495
|
'task.start',
|
|
@@ -508,6 +517,8 @@ export function createTelemetrySource(args: {
|
|
|
508
517
|
owners?: ReadonlyMap<TelemetrySink, string>
|
|
509
518
|
/** The run's task graph, to see a group dependency through to the tasks behind it. */
|
|
510
519
|
nodes?: ReadonlyMap<string, TaskNode>
|
|
520
|
+
/** The sampler's clock; a test ticks it by hand instead of waiting on the wall. */
|
|
521
|
+
every?: Every
|
|
511
522
|
}): TelemetrySource {
|
|
512
523
|
const { sinks, run, warn } = args
|
|
513
524
|
const runId = run.runId
|
|
@@ -543,12 +554,13 @@ export function createTelemetrySource(args: {
|
|
|
543
554
|
// only while one runs; a tick still reading when the next is due is not
|
|
544
555
|
// doubled.
|
|
545
556
|
const tracked = new Map<string, number>()
|
|
546
|
-
|
|
557
|
+
const every = args.every ?? everyInterval
|
|
558
|
+
let stopEvery: (() => void) | undefined
|
|
547
559
|
let sampling = false
|
|
548
560
|
let ended = false
|
|
549
561
|
const stopTimer = (): void => {
|
|
550
|
-
|
|
551
|
-
|
|
562
|
+
stopEvery?.()
|
|
563
|
+
stopEvery = undefined
|
|
552
564
|
}
|
|
553
565
|
const untrack = (taskId: string, pid: number): void => {
|
|
554
566
|
if (tracked.get(taskId) === pid) tracked.delete(taskId)
|
|
@@ -587,10 +599,7 @@ export function createTelemetrySource(args: {
|
|
|
587
599
|
const track = (taskId: string, pid: number): (() => void) => {
|
|
588
600
|
if (ended) return () => {}
|
|
589
601
|
tracked.set(taskId, pid)
|
|
590
|
-
|
|
591
|
-
timer = setInterval(() => void tick(), SAMPLE_MS)
|
|
592
|
-
timer.unref()
|
|
593
|
-
}
|
|
602
|
+
stopEvery ??= every(SAMPLE_MS, tick)
|
|
594
603
|
return () => untrack(taskId, pid)
|
|
595
604
|
}
|
|
596
605
|
|
|
@@ -28,6 +28,17 @@ export function keyUpstream(node: TaskNode, upstream: TaskOutcome[]): TaskOutcom
|
|
|
28
28
|
return node.excludedUpstream === undefined ? keyed : [...keyed, ...node.excludedUpstream]
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
+
/** A dependency as a key reads it: only `node` and `hash` matter. */
|
|
32
|
+
export function keyedOutcome(node: TaskNode, hash: string | undefined): TaskOutcome {
|
|
33
|
+
return {
|
|
34
|
+
node,
|
|
35
|
+
status: 'success',
|
|
36
|
+
exitCode: 0,
|
|
37
|
+
durationMs: 0,
|
|
38
|
+
...(hash !== undefined ? { hash } : {}),
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
31
42
|
/** `node.deps` less its ordering-only edges: the dependencies a key may fold. */
|
|
32
43
|
export function keyedDeps(node: TaskNode): readonly string[] {
|
|
33
44
|
const order = node.orderOnly
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// Did this process start with SIGHUP ignored? `nohup` and a supervisor
|
|
2
|
+
// that wants a job to outlive its terminal say so through the inherited
|
|
3
|
+
// disposition, and a `process.on('SIGHUP')` replaces it: vx under nohup
|
|
4
|
+
// stopped its run and exited 129 when the terminal closed. The POSIX idiom
|
|
5
|
+
// is to ask before installing; Node has no API for it, so Linux reads
|
|
6
|
+
// `/proc/self/status` (self is this process in any pid namespace's procfs)
|
|
7
|
+
// and macOS asks `sigaction` itself. Read once, before the first handler.
|
|
8
|
+
|
|
9
|
+
import { readFileSync } from 'node:fs'
|
|
10
|
+
import { dlopen, FFIType, ptr } from 'bun:ffi'
|
|
11
|
+
|
|
12
|
+
let ignored: boolean | undefined
|
|
13
|
+
|
|
14
|
+
export function hangupIgnored(): boolean {
|
|
15
|
+
ignored ??= read()
|
|
16
|
+
return ignored
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function read(): boolean {
|
|
20
|
+
try {
|
|
21
|
+
if (process.platform === 'linux') {
|
|
22
|
+
const mask = /^SigIgn:\s*([0-9a-f]+)$/m.exec(readFileSync('/proc/self/status', 'utf8'))
|
|
23
|
+
return mask !== null && (BigInt(`0x${mask[1]}`) & 1n) === 1n
|
|
24
|
+
}
|
|
25
|
+
if (process.platform === 'darwin') return darwinIgnored()
|
|
26
|
+
} catch {}
|
|
27
|
+
return false
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function darwinIgnored(): boolean {
|
|
31
|
+
// Opened only here: bun:ffi compiles a trampoline per call site, ~1 ms.
|
|
32
|
+
const lib = dlopen('/usr/lib/libSystem.B.dylib', {
|
|
33
|
+
sigaction: { args: [FFIType.i32, FFIType.ptr, FFIType.ptr], returns: FFIType.i32 },
|
|
34
|
+
})
|
|
35
|
+
try {
|
|
36
|
+
// `struct sigaction` opens with the handler; SIG_IGN is 1. SIGHUP is 1.
|
|
37
|
+
const old = new BigUint64Array(4)
|
|
38
|
+
return lib.symbols.sigaction(1, null, ptr(old)) === 0 && old[0] === 1n
|
|
39
|
+
} finally {
|
|
40
|
+
lib.close()
|
|
41
|
+
}
|
|
42
|
+
}
|
package/src/util/index.ts
CHANGED
|
@@ -43,6 +43,7 @@ export {
|
|
|
43
43
|
relPosix,
|
|
44
44
|
splitNegations,
|
|
45
45
|
staticPrefix,
|
|
46
|
+
stripTrailingSlash,
|
|
46
47
|
anyTaskGlob,
|
|
47
48
|
isInstalledPath,
|
|
48
49
|
taskGlob,
|
|
@@ -72,6 +73,7 @@ export { CORE_VERBS, MOVED_VERBS } from './verbs.js'
|
|
|
72
73
|
export { isUnsupportedBun, MIN_BUN, unsupportedBunMessage } from './bun-version.js'
|
|
73
74
|
export { executablePath, shellArgv, taskShell } from './which.js'
|
|
74
75
|
export { procfsIsOwn } from './procfs.js'
|
|
76
|
+
export { hangupIgnored } from './hangup.js'
|
|
75
77
|
export { realPath } from './real-path.js'
|
|
76
78
|
export {
|
|
77
79
|
maskedCommand,
|
package/src/util/paths.ts
CHANGED
|
@@ -198,6 +198,12 @@ export function taskGlob(pattern: string): Bun.Glob {
|
|
|
198
198
|
// A `]` with no `[` before it is already literal to `Bun.Glob`.
|
|
199
199
|
const source = pattern.includes('[') ? pattern.replace(/(?<!\\)[[\]]/g, '\\$&') : pattern
|
|
200
200
|
const glob = new Bun.Glob(source)
|
|
201
|
+
const alternatives = braceAlternatives(pattern)
|
|
202
|
+
if (alternatives.length > 1) {
|
|
203
|
+
const any = anyTaskGlob(alternatives)
|
|
204
|
+
glob.match = (rel: string): boolean => any(rel)
|
|
205
|
+
return glob
|
|
206
|
+
}
|
|
201
207
|
const re = regExpSource(pattern)
|
|
202
208
|
if (re === null) return glob
|
|
203
209
|
const compiled = new RegExp(`^${re}$`, 'u')
|
|
@@ -216,8 +222,13 @@ export function taskGlob(pattern: string): Bun.Glob {
|
|
|
216
222
|
* RegExp for the lot when every pattern has one, else a glob each. An input
|
|
217
223
|
* file was matched against a dozen globs (its positives, the always-ignored
|
|
218
224
|
* list, its negatives) one native call at a time.
|
|
225
|
+
*
|
|
226
|
+
* A brace is expanded first, never matched by `Bun.Glob`: its match reads a
|
|
227
|
+
* `**` that ends an alternative as one name, so `{src/**,lib/**}` took
|
|
228
|
+
* `src/a.ts` and never `src/deep/a.ts`, and an edit there was a stale hit.
|
|
219
229
|
*/
|
|
220
|
-
export function anyTaskGlob(
|
|
230
|
+
export function anyTaskGlob(rawPatterns: readonly string[]): (rel: string) => boolean {
|
|
231
|
+
const patterns = rawPatterns.flatMap(braceAlternatives)
|
|
221
232
|
if (patterns.length === 0) return () => false
|
|
222
233
|
const sources = patterns.map(regExpSource)
|
|
223
234
|
const globs = patterns.map(taskGlob)
|
|
@@ -261,7 +272,7 @@ function regExpSource(pattern: string): string | null {
|
|
|
261
272
|
return out
|
|
262
273
|
}
|
|
263
274
|
|
|
264
|
-
function stripTrailingSlash(p: string): string {
|
|
275
|
+
export function stripTrailingSlash(p: string): string {
|
|
265
276
|
return p.replace(/\/+$/, '')
|
|
266
277
|
}
|
|
267
278
|
|
|
@@ -339,6 +350,15 @@ export function outputMatcher(globs: readonly string[]): (rel: string) => boolea
|
|
|
339
350
|
* is left to the glob.
|
|
340
351
|
*/
|
|
341
352
|
export function slashBraceExpansions(pattern: string): string[] {
|
|
353
|
+
return expandBraces(pattern, true)
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/** `pattern` with every brace group of two or more alternatives expanded. */
|
|
357
|
+
function braceAlternatives(pattern: string): string[] {
|
|
358
|
+
return expandBraces(pattern, false)
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function expandBraces(pattern: string, slashOnly: boolean): string[] {
|
|
342
362
|
if (pattern.includes('\\')) return [pattern]
|
|
343
363
|
for (let open = pattern.indexOf('{'); open !== -1; open = pattern.indexOf('{', open + 1)) {
|
|
344
364
|
let depth = 0
|
|
@@ -352,14 +372,14 @@ export function slashBraceExpansions(pattern: string): string[] {
|
|
|
352
372
|
}
|
|
353
373
|
if (close === -1) return [pattern]
|
|
354
374
|
const body = pattern.slice(open + 1, close)
|
|
355
|
-
if (cuts.length === 0 || !body.includes('/')) continue
|
|
375
|
+
if (cuts.length === 0 || (slashOnly && !body.includes('/'))) continue
|
|
356
376
|
const head = pattern.slice(0, open)
|
|
357
377
|
const tail = pattern.slice(close + 1)
|
|
358
378
|
const bounds = [open, ...cuts, close]
|
|
359
379
|
const out: string[] = []
|
|
360
380
|
for (let k = 0; k + 1 < bounds.length; k++) {
|
|
361
381
|
const alt = pattern.slice(bounds[k]! + 1, bounds[k + 1])
|
|
362
|
-
out.push(...
|
|
382
|
+
out.push(...expandBraces(head + alt + tail, slashOnly))
|
|
363
383
|
}
|
|
364
384
|
return [...new Set(out)]
|
|
365
385
|
}
|