@vzn/vx 0.0.594 → 0.0.596

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 (80) hide show
  1. package/README.md +1 -1
  2. package/package.json +5 -5
  3. package/src/bin.ts +26 -11
  4. package/src/cache/archive.ts +2 -2
  5. package/src/cache/cache.ts +235 -174
  6. package/src/cache/chained-cache.ts +25 -7
  7. package/src/cache/config-evals.ts +19 -4
  8. package/src/cache/git-inputs.ts +19 -8
  9. package/src/cache/index.ts +1 -0
  10. package/src/cache/inputs.ts +122 -49
  11. package/src/cache/layer.ts +7 -5
  12. package/src/cache/layered-cache.ts +25 -2
  13. package/src/cache/policy.ts +5 -0
  14. package/src/cache/tar-stream.ts +1 -1
  15. package/src/cache/zstd.ts +10 -4
  16. package/src/cli/cache.ts +11 -15
  17. package/src/cli/completions.ts +42 -6
  18. package/src/cli/foreign-flags.ts +26 -5
  19. package/src/cli/help.ts +5 -3
  20. package/src/cli/init.ts +1 -1
  21. package/src/cli/last.ts +4 -1
  22. package/src/cli/plan-format.ts +9 -0
  23. package/src/cli/plugin-templates.ts +2 -2
  24. package/src/cli/run.ts +60 -42
  25. package/src/cli/select.ts +30 -3
  26. package/src/cli/show.ts +42 -3
  27. package/src/cli/watch-filter.ts +77 -13
  28. package/src/cli/watch-fs.ts +15 -7
  29. package/src/cli/watch-judge.ts +16 -9
  30. package/src/cli/watch.ts +22 -15
  31. package/src/config.ts +26 -26
  32. package/src/exec/env.ts +5 -0
  33. package/src/exec/index.ts +1 -0
  34. package/src/exec/proc-sample.ts +6 -2
  35. package/src/exec/runner.ts +98 -40
  36. package/src/exec/sandbox-paths.ts +12 -6
  37. package/src/exec/sandbox-runtime.ts +136 -44
  38. package/src/exec/sandbox-violations.ts +58 -11
  39. package/src/graph/index.ts +1 -0
  40. package/src/graph/scheduler.ts +29 -13
  41. package/src/graph/task-graph.ts +134 -25
  42. package/src/index.ts +6 -0
  43. package/src/orchestrator/admission.ts +12 -3
  44. package/src/orchestrator/events.ts +10 -1
  45. package/src/orchestrator/execute-task.ts +65 -20
  46. package/src/orchestrator/failure-mode.ts +8 -2
  47. package/src/orchestrator/framed-output.ts +3 -3
  48. package/src/orchestrator/hit-restore.ts +6 -3
  49. package/src/orchestrator/local-shortcircuit.ts +4 -1
  50. package/src/orchestrator/lockfile-claim.ts +43 -46
  51. package/src/orchestrator/logger.ts +11 -6
  52. package/src/orchestrator/metrics.ts +19 -8
  53. package/src/orchestrator/miss-save.ts +2 -2
  54. package/src/orchestrator/options.ts +7 -0
  55. package/src/orchestrator/plan.ts +28 -4
  56. package/src/orchestrator/plugin-host.ts +25 -3
  57. package/src/orchestrator/plugin.ts +39 -17
  58. package/src/orchestrator/prepare.ts +13 -4
  59. package/src/orchestrator/run-artifacts.ts +2 -1
  60. package/src/orchestrator/run.ts +72 -12
  61. package/src/orchestrator/sandbox-request.ts +65 -9
  62. package/src/orchestrator/save-lane.ts +5 -5
  63. package/src/orchestrator/signals.ts +5 -2
  64. package/src/orchestrator/summary.ts +16 -5
  65. package/src/orchestrator/telemetry.ts +16 -7
  66. package/src/util/index.ts +7 -1
  67. package/src/util/paths.ts +23 -3
  68. package/src/util/secret-mask.ts +36 -0
  69. package/src/util/settle.ts +15 -0
  70. package/src/workspace/affected.ts +30 -8
  71. package/src/workspace/config-cache.ts +25 -4
  72. package/src/workspace/config-imports.ts +8 -6
  73. package/src/workspace/config-schema.ts +74 -9
  74. package/src/workspace/filter.ts +19 -0
  75. package/src/workspace/migrate-scripts.ts +5 -3
  76. package/src/workspace/migration.ts +1 -1
  77. package/src/workspace/package-graph.ts +15 -3
  78. package/src/workspace/project-loader.ts +76 -2
  79. package/src/workspace/repo-id.ts +42 -4
  80. package/src/workspace/workspace.ts +98 -20
@@ -6,7 +6,13 @@ import type { ProjectEntry } from '../workspace/index.js'
6
6
  import { loadWorkspace, unreachedHint, unreachedPackages } from '../workspace/index.js'
7
7
  import { realpathSync } from 'node:fs'
8
8
  import path from 'node:path'
9
- import { Cache, type CacheLayer, type CachePolicy, stopRuntimeProbes } from '../cache/index.js'
9
+ import {
10
+ Cache,
11
+ type CacheLayer,
12
+ type CachePolicy,
13
+ cachesNothing,
14
+ stopRuntimeProbes,
15
+ } from '../cache/index.js'
10
16
  import { VERSION } from '../version.js'
11
17
  import {
12
18
  resetSandbox,
@@ -44,6 +50,7 @@ import {
44
50
  secretMask,
45
51
  } from '../util/index.js'
46
52
  import { keyedProjects } from './keyed-projects.js'
53
+ import { isDefaultBuild } from './projects.js'
47
54
  import { prepareSandbox } from './sandbox-request.js'
48
55
  import type { OutputDirSnapshot, SaveFacts } from './miss-save.js'
49
56
  import { admitTasks, taintTracker } from './admission.js'
@@ -87,8 +94,12 @@ import {
87
94
  detectCi,
88
95
  } from './run-context.js'
89
96
  import { startRemotePrefetch } from './remote-prefetch.js'
90
- import { startLocalShortCircuit, type ShortCircuit } from './local-shortcircuit.js'
91
- import { probesAfterWrites } from './stable-keys.js'
97
+ import {
98
+ restoreTierExclusions,
99
+ startLocalShortCircuit,
100
+ type ShortCircuit,
101
+ } from './local-shortcircuit.js'
102
+ import { deriveStableKeys, probesAfterWrites } from './stable-keys.js'
92
103
 
93
104
  import { assembleRunRecords } from './run-records.js'
94
105
  import { hasEnded, selectKeepAlive, shutdownPersistent } from './persistent.js'
@@ -1013,6 +1024,7 @@ async function runOnBus(
1013
1024
  // is one probe of the failed-row index.
1014
1025
  const flaky: FlakyFinding[] = []
1015
1026
  const historyDb = prepared.localCache.dbHandle()
1027
+ const cacheOff = cachesNothing(policy)
1016
1028
  const judgeFlaky = (o: TaskOutcome): void => {
1017
1029
  const candidates = flakyCandidates([o])
1018
1030
  if (candidates.length === 0) return
@@ -1040,6 +1052,7 @@ async function runOnBus(
1040
1052
  log.taskStart?.(node)
1041
1053
  },
1042
1054
  onFinish: (o) => {
1055
+ if (cacheOff) o.cacheOff = true
1043
1056
  taint.settled(o)
1044
1057
  judgeFlaky(o)
1045
1058
  log.taskComplete(o.node, o)
@@ -1086,8 +1099,6 @@ async function runOnBus(
1086
1099
  ).filter((c) => endedBeforeStop?.has(persistentRegistry.get(c.id)!) ?? true)
1087
1100
 
1088
1101
  mark('run graph')
1089
- // Clear the status line for good before the summary prints.
1090
- log.runEnd?.()
1091
1102
 
1092
1103
  // A server that died on its own before the stop failed, whatever its
1093
1104
  // outcome said when it became ready: the footer counted it a success
@@ -1097,8 +1108,11 @@ async function runOnBus(
1097
1108
  const failServer = (id: string, code: number | string): void => {
1098
1109
  const o = outcomes.get(id)
1099
1110
  if (o === undefined) return
1100
- const exitCode = typeof code === 'number' ? code : signalExitCode(code)
1101
- outcomes.set(id, { ...o, status: 'failed', exitCode })
1111
+ // In place: the renderer holds this outcome (events carry live refs)
1112
+ // and closes the server's output block from it at runEnd, where a
1113
+ // copy left it reading `running` under its exit line (WD-10).
1114
+ o.status = 'failed'
1115
+ o.exitCode = typeof code === 'number' ? code : signalExitCode(code)
1102
1116
  }
1103
1117
  for (const c of crashedPersistent) failServer(c.id, c.code)
1104
1118
  keepAlive.nodes.forEach((n, i) => {
@@ -1107,6 +1121,8 @@ async function runOnBus(
1107
1121
  if (endedBeforeStop !== undefined && !endedBeforeStop.has(child)) return
1108
1122
  failServer(n.id, child.exitCode ?? child.signalCode ?? 'unknown')
1109
1123
  })
1124
+ // Clear the status line for good before the summary prints.
1125
+ log.runEnd?.()
1110
1126
  const list = [...outcomes.values()]
1111
1127
  const ok = list.every((o) => isPassStatus(o.status))
1112
1128
 
@@ -1339,6 +1355,8 @@ async function runOnBus(
1339
1355
  // task's own facts (flaky, blocked) ride its row. Only a kept server's
1340
1356
  // own output follows.
1341
1357
  if (options.summaryTable === true) for (const line of formatOutcomeTable(list)) log.status(line)
1358
+ const above = options.beforeFooter?.(list, ok)
1359
+ if (above) log.status(above.replace(/\n$/, ''))
1342
1360
  for (const line of formatRunSummary(list, totalMs, colors, runContext)) log.status(line)
1343
1361
 
1344
1362
  // Edge case the summary already reported: the user requested a
@@ -1396,7 +1414,9 @@ async function runOnBus(
1396
1414
  })
1397
1415
  })
1398
1416
  const node = keepAlive.nodes[first.i]!
1399
- const others = keepAlive.nodes.length - 1
1417
+ // Only the ones still up are stopped: a kept server already dead was
1418
+ // counted as one (WD-11).
1419
+ const others = keepAlive.children.filter((c, j) => j !== first.i && !hasEnded(c)).length
1400
1420
  // Not when the run was stopped: the server ended because the user
1401
1421
  // stopped it, and "exited with code 130" read as a crash after every
1402
1422
  // Ctrl-C once a stop let run() finish its own path (item 852).
@@ -1421,7 +1441,9 @@ async function runOnBus(
1421
1441
  await summarize(ok && first.code === 0, final)
1422
1442
  if (historyAfterWait) {
1423
1443
  recordHistory(() => {
1424
- const local = Cache.inspect(prepared.cacheDir)
1444
+ // A writer's open: another vx version may have reset the index
1445
+ // to its schema during the session, and a mismatch resets silently.
1446
+ const local = new Cache(prepared.cacheDir, undefined, prepared.workspaceRoot)
1425
1447
  try {
1426
1448
  local.recordRunBundle(recordsOf(final, ok && first.code === 0))
1427
1449
  } finally {
@@ -1526,12 +1548,13 @@ export async function planRun(options: RunOptions): Promise<RunPlan> {
1526
1548
  // Its own mark: a dry run's plan (every task's hash, the cache lookups,
1527
1549
  // the history p50s) was booked under `close`, the next mark, and read
1528
1550
  // as 105 ms of closing a cache at 1,000 projects (item 601).
1551
+ const policy = effectiveCachePolicy(prepared.cachePolicy, prepared.hasRemoteLayer)
1529
1552
  const planned = await plan({
1530
1553
  nodes: prepared.nodes,
1531
1554
  workspaceRoot: prepared.workspaceRoot,
1532
1555
  workspaceFingerprint: prepared.workspaceFingerprint,
1533
1556
  cache: prepared.cache,
1534
- cachePolicy: effectiveCachePolicy(prepared.cachePolicy, prepared.hasRemoteLayer),
1557
+ cachePolicy: policy,
1535
1558
  forwardArgs: options.forwardArgs,
1536
1559
  nestedDirsByProject: prepared.nestedDirsByProject,
1537
1560
  gitFilesCache: prepared.gitFilesCache,
@@ -1548,6 +1571,7 @@ export async function planRun(options: RunOptions): Promise<RunPlan> {
1548
1571
  // the same plugin-factory call `prepareRun` already makes for the
1549
1572
  // cache capability, so plan mode gains no new class of side effect.
1550
1573
  ...(await planExecutorOf(prepared, log, options.download ?? 'all')),
1574
+ ...(await planRestorable(prepared, policy, options.forwardArgs)),
1551
1575
  })
1552
1576
  mark('plan')
1553
1577
  return planned
@@ -1564,6 +1588,39 @@ export async function planRun(options: RunOptions): Promise<RunPlan> {
1564
1588
  }
1565
1589
  }
1566
1590
 
1591
+ /**
1592
+ * What the run's short-circuit would restore ahead of its deps, so the plan
1593
+ * can tell a server nobody needs from one that starts: `--dry` called a
1594
+ * server every dependant of which was a local hit `would exec`, and the run
1595
+ * never spawned it. The run's own gates (`shouldShortCircuit`, stable keys,
1596
+ * workspace-output reach), asked only when a server could idle.
1597
+ */
1598
+ async function planRestorable(
1599
+ prepared: PreparedRun,
1600
+ policy: CachePolicy,
1601
+ forwardArgs: readonly string[] | undefined,
1602
+ ): Promise<{ restorable?: Set<string> }> {
1603
+ const { nodes } = prepared
1604
+ const serves = [...nodes.values()].some(
1605
+ (n) => n.config.exec?.persistent !== undefined && !n.requested && n.surfaced !== true,
1606
+ )
1607
+ if (!serves || !shouldShortCircuit(nodes, policy, prepared.cache)) return {}
1608
+ const keptOut = restoreTierExclusions(nodes, prepared.workspaceRoot)
1609
+ const stable = await deriveStableKeys({
1610
+ nodes,
1611
+ cache: prepared.cache,
1612
+ workspaceRoot: prepared.workspaceRoot,
1613
+ workspaceFingerprint: prepared.workspaceFingerprint,
1614
+ forwardArgs,
1615
+ nestedDirsByProject: prepared.nestedDirsByProject,
1616
+ gitFilesCache: prepared.gitFilesCache,
1617
+ hashCache: prepared.hashCache,
1618
+ })
1619
+ return {
1620
+ restorable: new Set(stable.map((k) => k.node.id).filter((id) => !keptOut.has(id))),
1621
+ }
1622
+ }
1623
+
1567
1624
  /** The names a project outside the run's selection declares, and how to reach them. */
1568
1625
  function elsewhereHint(names: readonly string[]): string {
1569
1626
  if (names.length === 0) return ''
@@ -1588,7 +1645,7 @@ async function initHint(prepared: {
1588
1645
  // Single-project mode with packages the root's missing `workspaces`
1589
1646
  // never reaches: `vx init` would find no scripts either (item 248).
1590
1647
  const unreached = await unreachedPackages(await loadWorkspace(prepared.workspaceRoot))
1591
- if (unreached.length > 0) return ` ${unreachedHint(unreached)}`
1648
+ if (unreached.length > 0) return ` ${unreachedHint(unreached, prepared.workspaceRoot)}`
1592
1649
  return ' No package declares a vx.config — run `vx init` to write one per package from its package.json scripts.'
1593
1650
  }
1594
1651
 
@@ -1605,8 +1662,11 @@ function didYouMean(
1605
1662
  elsewhere: readonly string[] = [],
1606
1663
  ): string {
1607
1664
  const tasksOf = (p: ProjectEntry | undefined): string[] => Object.keys(p?.config.tasks ?? {})
1665
+ // What a bare name can select: a default `build` is no match (X-102).
1608
1666
  const allTasks = new Set<string>()
1609
- for (const p of projects.values()) for (const t of tasksOf(p)) allTasks.add(t)
1667
+ for (const p of projects.values()) {
1668
+ for (const t of tasksOf(p)) if (!isDefaultBuild(p.config.tasks![t])) allTasks.add(t)
1669
+ }
1610
1670
  // A Set: two typos of the same task hint it once, not once per typo.
1611
1671
  const hints = new Set<string>()
1612
1672
  // A typo past two edits named nothing to pick from (M-56): with no near
@@ -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
@@ -464,11 +464,13 @@ async function prepareOutputsForBind(
464
464
  await mkdir(abs, { recursive: true }).catch(() => undefined)
465
465
  continue
466
466
  }
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)
467
+ const dir = hasWildcard || g.endsWith('/')
468
+ const abs = path.join(projectDir, hasWildcard ? grantPrefix(g) : g)
469
+ try {
470
+ if (dir) {
471
+ await mkdir(abs, { recursive: true })
472
+ continue
473
+ }
472
474
  // Whatever is already there is what the task meant — a grant on the
473
475
  // project dir itself is a directory, and touching it as a file is
474
476
  // an EISDIR, not a missing bind. `lstat`, and an exclusive create: a
@@ -478,11 +480,33 @@ async function prepareOutputsForBind(
478
480
  await mkdir(path.dirname(abs), { recursive: true })
479
481
  await writeFile(abs, '', { flag: 'wx' })
480
482
  placeholders.push({ path: abs, mtimeMs: (await lstat(abs)).mtimeMs })
483
+ } catch (err) {
484
+ await sweepPlaceholders(placeholders)
485
+ const code = (err as NodeJS.ErrnoException).code
486
+ if (code !== 'EEXIST' && code !== 'ENOTDIR') throw err
487
+ // A file stands where the grant needs a directory: `out.txt/` over a
488
+ // file, or `a.txt/x/*.js`. It reached the user as an internal error.
489
+ const blocker = await fileOnPath(projectDir, dir ? abs : path.dirname(abs))
490
+ throw new UserError(
491
+ `exec.sandbox.allow.write: "${g}" needs a directory at ${blocker}, and a file is ` +
492
+ `there — remove the file, or grant a path beside it`,
493
+ )
481
494
  }
482
495
  }
483
496
  return placeholders
484
497
  }
485
498
 
499
+ /** The first path from `from` down to `to` that exists and is not a directory. */
500
+ async function fileOnPath(from: string, to: string): Promise<string> {
501
+ let at = from
502
+ for (const part of path.relative(from, to).split(path.sep)) {
503
+ at = path.join(at, part)
504
+ const st = await lstat(at).catch(() => undefined)
505
+ if (st !== undefined && !st.isDirectory()) return at
506
+ }
507
+ return to
508
+ }
509
+
486
510
  /**
487
511
  * The walls a project's grants stop at: its nested projects, the
488
512
  * repository and vx's directory (item 1010). A read grant around one is
@@ -589,13 +613,35 @@ const CREDENTIAL_STORES = [
589
613
  '.npmrc',
590
614
  '.yarnrc.yml',
591
615
  '.pypirc',
616
+ // Registry and service tokens of the same kind: Bun's global bunfig
617
+ // (`[install.scopes]` tokens) at either of its homes, Cargo's, RubyGems'.
618
+ '.bunfig.toml',
619
+ '.config/.bunfig.toml',
620
+ '.cargo/credentials',
621
+ '.cargo/credentials.toml',
622
+ '.gem/credentials',
623
+ '.config/hub',
624
+ '.config/containers/auth.json',
625
+ '.terraform.d/credentials.tfrc.json',
626
+ '.vault-token',
627
+ '.pgpass',
592
628
  ]
593
629
 
594
630
  /** The stores present on this host, learned once per home. */
595
631
  let presentStores: { home: string; paths: string[] } | undefined
596
632
 
597
- /** The credential stores to deny, less any that holds the workspace. */
598
- function credentialStores(workspaceRoot: string): string[] {
633
+ /**
634
+ * The credential stores to deny, less any that holds the workspace, and
635
+ * punched around the task's write grants: a grant names a store as a read
636
+ * does. Denied whole, the store's mask landed on the grant's bind (a file
637
+ * grant binds its directory on Linux), so `write: ['~/.npmrc']` wrote to
638
+ * /dev/null with exit 0, and `~/.aws/config` left `~/.aws/credentials`
639
+ * readable. A grant above a store does not name it, so it stays denied.
640
+ */
641
+ async function credentialStores(
642
+ workspaceRoot: string,
643
+ writes: readonly string[],
644
+ ): Promise<string[]> {
599
645
  const home = homedir()
600
646
  if (presentStores?.home !== home) {
601
647
  presentStores = {
@@ -603,9 +649,19 @@ function credentialStores(workspaceRoot: string): string[] {
603
649
  paths: CREDENTIAL_STORES.map((rel) => path.join(home, rel)).filter((p) => existsSync(p)),
604
650
  }
605
651
  }
606
- return presentStores.paths.filter(
652
+ const stores = presentStores.paths.filter(
607
653
  (p) => workspaceRoot !== p && !workspaceRoot.startsWith(p + path.sep),
608
654
  )
655
+ return (await Promise.all(stores.map((p) => denyAround(toRealPath(p), writes)))).flat()
656
+ }
657
+
658
+ /** `dir` denied, or its entries no write grant lies under or at when one is inside it. */
659
+ async function denyAround(dir: string, writes: readonly string[]): Promise<string[]> {
660
+ const inside = writes.filter((w) => atOrUnder(w, dir))
661
+ if (inside.length === 0) return [dir]
662
+ if (inside.includes(dir)) return []
663
+ const entries = await readdir(dir).catch(() => [])
664
+ return (await Promise.all(entries.map((e) => denyAround(path.join(dir, e), inside)))).flat()
609
665
  }
610
666
 
611
667
  /** `~/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
  },
@@ -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 { killGraceMs, settleWithin } from '../util/index.js'
17
+ import { claimExitForSignal, 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
 
@@ -69,7 +69,9 @@ export async function terminateChildren(
69
69
  signal: ForwardedSignal = 'SIGTERM',
70
70
  graceMs: number = killGraceMs(SIGNAL_SHUTDOWN_GRACE_MS),
71
71
  ): Promise<void> {
72
- const children = live()
72
+ // A ready server is in both of run()'s lists: signalled twice, it read
73
+ // the second as a repeated Ctrl-C (force quit).
74
+ const children = [...new Set(live())]
73
75
  // On the group guard's list until the sweep below is done: a shell that
74
76
  // dies on the signal lets its group go while what it forked runs out
75
77
  // the grace (kill-tree.ts, `holdGroups`).
@@ -131,6 +133,7 @@ export function forwardSignals(args: {
131
133
  // that ended the run.
132
134
  if (stopping !== undefined) exit(stopping)
133
135
  stopping = signal
136
+ claimExitForSignal()
134
137
  // Clear the live worker/status region BEFORE exiting so a TTY isn't
135
138
  // left with a frozen region in the scrollback. runEnd is idempotent
136
139
  // and a no-op for non-TTY loggers.
@@ -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
 
@@ -119,6 +119,8 @@ export interface SummaryStats {
119
119
  spread: { maxMs: number; minMs: number; sumMs: number; count: number } | null
120
120
  /** Tasks an `admit` policy held with a worker free, and the waits summed. Absent with no hold. */
121
121
  held?: { count: number; sumMs: number }
122
+ /** A run of groups only: their names, so `0 tasks` says why. */
123
+ emptyGroups?: readonly string[]
122
124
  }
123
125
 
124
126
  /**
@@ -213,7 +215,14 @@ export function formatSummarySection(
213
215
  // No legend line until the first bucket lands (live: all-gray bar).
214
216
  if (taskParts.length > 0) lines.push(legend(taskParts))
215
217
  } else {
216
- lines.push(row('tasks', dim('0 tasks')))
218
+ const groups = stats.emptyGroups ?? []
219
+ // `vx run build` where no project gives build a command runs the default
220
+ // group alone: say so, or it reads like a selection that matched nothing.
221
+ const why =
222
+ groups.length > 0
223
+ ? ` ${dim('\u00b7')} ${dim(`${groups.join(', ')} ${groups.length === 1 ? 'has' : 'have'} no command in these projects`)}`
224
+ : ''
225
+ lines.push(row('tasks', dim('0 tasks') + why))
217
226
  // A stop before any task finished still names what it took down.
218
227
  if (taskParts.length > 0) lines.push(legend(taskParts))
219
228
  }
@@ -356,9 +365,7 @@ export function formatRunSummary(
356
365
  // distinction — it never consulted the cache, so it is not a miss.
357
366
  const noCache = outcomes.filter(
358
367
  (o) =>
359
- !isGroupTask(o.node) &&
360
- (o.status === 'success' || o.status === 'failed') &&
361
- o.node.config.cache === undefined,
368
+ !isGroupTask(o.node) && (o.status === 'success' || o.status === 'failed') && ranNoCache(o),
362
369
  ).length
363
370
  const t = tallyOutcomes(outcomes)
364
371
  // Spread over executed tasks only — `success`/`failed` statuses ran
@@ -368,6 +375,9 @@ export function formatRunSummary(
368
375
  .map((o) => o.durationMs)
369
376
  const heldOutcomes = outcomes.filter((o) => o.admissionHeldMs !== undefined)
370
377
  const notRun = outcomes.filter((o) => neverStarted(o) && !isGroupTask(o.node)).length
378
+ const emptyGroups = outcomes.every((o) => isGroupTask(o.node))
379
+ ? [...new Set(outcomes.map((o) => o.node.taskName))]
380
+ : []
371
381
  return formatSummarySection(
372
382
  {
373
383
  failed: t.failed,
@@ -398,6 +408,7 @@ export function formatRunSummary(
398
408
  },
399
409
  }
400
410
  : {}),
411
+ ...(emptyGroups.length > 0 ? { emptyGroups } : {}),
401
412
  },
402
413
  totalMs,
403
414
  colors,
@@ -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
 
package/src/util/index.ts CHANGED
@@ -49,7 +49,13 @@ export {
49
49
  wholeSubtreePrefixes,
50
50
  slashBraceExpansions,
51
51
  } from './paths.js'
52
- export { killGraceMs, settleWithin, teardownTimeoutMs } from './settle.js'
52
+ export {
53
+ claimExitForSignal,
54
+ exitClaimedBySignal,
55
+ killGraceMs,
56
+ settleWithin,
57
+ teardownTimeoutMs,
58
+ } from './settle.js'
53
59
  export { formatBytes, parseDuration, parseSize } from './size.js'
54
60
  export {
55
61
  cgroupCpuQuota,
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)
@@ -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
  }
@@ -49,6 +49,13 @@ export interface SecretMask {
49
49
  * the next chunk or `end`.
50
50
  */
51
51
  stream(): { push(chunk: string): string; end(): string }
52
+ /**
53
+ * `mask` for the two sides of a cut, where the text between them is
54
+ * gone: a value the cut split leaves a piece at the end of `head` or the
55
+ * start of `tail` that no whole-value match sees, so each piece is
56
+ * masked too.
57
+ */
58
+ maskCut(head: string, tail: string): [string, string]
52
59
  }
53
60
 
54
61
  /**
@@ -103,8 +110,37 @@ export function secretMask(
103
110
  }
104
111
  return longest
105
112
  }
113
+ /** The longest head of `text` that is a proper suffix of a value. */
114
+ const partialHead = (text: string): number => {
115
+ let longest = 0
116
+ for (const v of sorted) {
117
+ for (let k = Math.min(v.length - 1, text.length); k > longest; k--) {
118
+ if (text.startsWith(v.slice(v.length - k))) {
119
+ longest = k
120
+ break
121
+ }
122
+ }
123
+ }
124
+ return longest
125
+ }
106
126
  return {
107
127
  mask,
128
+ maskCut(head, tail) {
129
+ let h = head.length - partialTail(head)
130
+ pattern.lastIndex = 0
131
+ for (let m = pattern.exec(head); m !== null; m = pattern.exec(head)) {
132
+ if (m.index < h && m.index + m[0].length > h) h = m.index
133
+ }
134
+ let t = partialHead(tail)
135
+ pattern.lastIndex = 0
136
+ for (let m = pattern.exec(tail); m !== null; m = pattern.exec(tail)) {
137
+ if (m.index < t && m.index + m[0].length > t) t = m.index + m[0].length
138
+ }
139
+ return [
140
+ mask(head.slice(0, h)) + (h < head.length ? MASKED : ''),
141
+ (t > 0 ? MASKED : '') + mask(tail.slice(t)),
142
+ ]
143
+ },
108
144
  stream() {
109
145
  let carry = ''
110
146
  return {
@@ -68,3 +68,18 @@ export async function settleWithin(p: Promise<unknown>, ms: number): Promise<boo
68
68
  void p.catch(() => {})
69
69
  }
70
70
  }
71
+
72
+ // Who ends the process. bin.ts exits once its verb has settled and the
73
+ // streams have ended; a signal's handler exits by itself, with the
74
+ // signal's code, once the run it stopped is done. The verb settles first,
75
+ // with the run's verdict (1), so bin.ts asks before it exits.
76
+ let signalOwnsExit = false
77
+
78
+ /** A signal's handler will end the process: bin.ts leaves the exit to it. */
79
+ export function claimExitForSignal(): void {
80
+ signalOwnsExit = true
81
+ }
82
+
83
+ export function exitClaimedBySignal(): boolean {
84
+ return signalOwnsExit
85
+ }