@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.
Files changed (82) hide show
  1. package/README.md +1 -1
  2. package/launcher.cjs +7 -3
  3. package/package.json +5 -5
  4. package/src/cache/archive.ts +35 -6
  5. package/src/cache/cache.ts +170 -99
  6. package/src/cache/chained-cache.ts +25 -7
  7. package/src/cache/git-inputs.ts +23 -0
  8. package/src/cache/index.ts +3 -0
  9. package/src/cache/inputs.ts +99 -44
  10. package/src/cache/key-fold.ts +1 -1
  11. package/src/cache/layer.ts +9 -6
  12. package/src/cache/policy.ts +5 -0
  13. package/src/cache/zstd.ts +10 -4
  14. package/src/cli/cache.ts +12 -6
  15. package/src/cli/foreign-flags.ts +57 -3
  16. package/src/cli/info.ts +1 -1
  17. package/src/cli/init.ts +2 -2
  18. package/src/cli/last.ts +10 -4
  19. package/src/cli/lock.ts +5 -3
  20. package/src/cli/plan-format.ts +4 -4
  21. package/src/cli/run.ts +73 -30
  22. package/src/cli/select.ts +6 -3
  23. package/src/cli/show.ts +8 -2
  24. package/src/cli/upgrade.ts +1 -1
  25. package/src/cli/watch-filter.ts +63 -12
  26. package/src/cli/watch-fs.ts +11 -3
  27. package/src/cli/watch-judge.ts +15 -8
  28. package/src/cli/watch.ts +18 -11
  29. package/src/cli/workspace-config.ts +3 -1
  30. package/src/config.ts +42 -21
  31. package/src/exec/executor.ts +1 -1
  32. package/src/exec/kill-tree.ts +89 -18
  33. package/src/exec/proc-sample.ts +1 -1
  34. package/src/exec/runner.ts +74 -36
  35. package/src/exec/sandbox-paths.ts +12 -1
  36. package/src/exec/sandbox-runtime.ts +283 -88
  37. package/src/exec/sandbox-violations.ts +115 -15
  38. package/src/graph/scheduler.ts +30 -14
  39. package/src/graph/task-graph.ts +265 -44
  40. package/src/orchestrator/admission.ts +12 -3
  41. package/src/orchestrator/affected-tasks.ts +6 -2
  42. package/src/orchestrator/deferred-outputs.ts +3 -2
  43. package/src/orchestrator/doctor.ts +2 -2
  44. package/src/orchestrator/events.ts +10 -1
  45. package/src/orchestrator/excluded-keys.ts +2 -13
  46. package/src/orchestrator/execute-task.ts +85 -39
  47. package/src/orchestrator/failure-mode.ts +43 -28
  48. package/src/orchestrator/framed-output.ts +8 -8
  49. package/src/orchestrator/index.ts +0 -1
  50. package/src/orchestrator/keyed-projects.ts +6 -3
  51. package/src/orchestrator/logger.ts +21 -9
  52. package/src/orchestrator/metrics.ts +19 -8
  53. package/src/orchestrator/miss-save.ts +9 -13
  54. package/src/orchestrator/options.ts +7 -0
  55. package/src/orchestrator/plan.ts +7 -23
  56. package/src/orchestrator/plugin.ts +37 -16
  57. package/src/orchestrator/prepare.ts +13 -4
  58. package/src/orchestrator/run-artifacts.ts +4 -3
  59. package/src/orchestrator/run-lock.ts +25 -24
  60. package/src/orchestrator/run.ts +22 -5
  61. package/src/orchestrator/sandbox-request.ts +96 -18
  62. package/src/orchestrator/save-lane.ts +5 -5
  63. package/src/orchestrator/signals.ts +22 -4
  64. package/src/orchestrator/stable-keys.ts +3 -11
  65. package/src/orchestrator/summary.ts +7 -13
  66. package/src/orchestrator/task-hash.ts +13 -3
  67. package/src/orchestrator/telemetry.ts +16 -7
  68. package/src/orchestrator/upstream.ts +11 -0
  69. package/src/util/hangup.ts +42 -0
  70. package/src/util/index.ts +2 -0
  71. package/src/util/paths.ts +24 -4
  72. package/src/workspace/affected.ts +79 -42
  73. package/src/workspace/config-eval.ts +27 -6
  74. package/src/workspace/config-imports.ts +8 -53
  75. package/src/workspace/config-schema.ts +56 -9
  76. package/src/workspace/filter.ts +19 -0
  77. package/src/workspace/lockfile.ts +2 -1
  78. package/src/workspace/migrate-scripts.ts +5 -3
  79. package/src/workspace/migration.ts +1 -1
  80. package/src/workspace/project-loader.ts +19 -4
  81. package/src/workspace/repo-id.ts +42 -4
  82. 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. Anything already inside a granted directory
330
- * is dropped; what is left is a sibling project's real path.
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
- for (const found of await Promise.all(dirs.map((d) => scan(d, '')))) {
378
- for (const [target, link, name] of found) {
379
- if (!targets.has(target)) targets.set(target, { link, name })
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 (!atOrUnder(target, root) || keyedDirs.has(target)) granted.push(target)
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
- if (hasWildcard || g.endsWith('/')) {
468
- const abs = path.join(projectDir, hasWildcard ? grantPrefix(g) : g)
469
- await mkdir(abs, { recursive: true })
470
- } else {
471
- const abs = path.join(projectDir, g)
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
- /** The credential stores to deny, less any that holds the workspace. */
598
- function credentialStores(workspaceRoot: string): string[] {
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
- return presentStores.paths.filter(
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
- const running = new Set<Promise<void>>()
24
+ let running = 0
25
25
  const start = (save: () => Promise<void>, settle: () => void): void => {
26
- const p = save()
26
+ void save()
27
27
  .catch(onError)
28
28
  .then(() => {
29
- running.delete(p)
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.add(p)
34
+ running++
35
35
  }
36
36
  return {
37
37
  defer(save) {
38
38
  return new Promise<void>((settle) => {
39
- if (running.size < cap) start(save, settle)
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 exits 128+signo (130/143). A
6
- // second signal exits at once. Without the forward, a
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
- process.on('SIGHUP', onSighup)
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. Only `node` + `hash`
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 ${formatDuration(maxMs)} \u00b7 avg ${formatDuration(sumMs / count)} \u00b7 min ${formatDuration(minMs)}`)}`
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'}, ${formatDuration(stats.held.sumMs)} in all`,
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', `${formatDuration(totalMs)}${spread}`))
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', `${formatDuration(totalMs)}${spread}`))
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(formatDuration(totalMs))
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 { TaskNode, TaskOutcome } from '../graph/index.js'
20
- import { span, relPosix, xxh3hex } from '../util/index.js'
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
- const effectiveForwardArgs = args.node.requested ? (args.forwardArgs ?? []) : []
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
- let timer: ReturnType<typeof setInterval> | undefined
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
- if (timer !== undefined) clearInterval(timer)
551
- timer = undefined
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
- if (timer === undefined) {
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(patterns: readonly string[]): (rel: string) => boolean {
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(...slashBraceExpansions(head + alt + tail))
382
+ out.push(...expandBraces(head + alt + tail, slashOnly))
363
383
  }
364
384
  return [...new Set(out)]
365
385
  }