@vzn/vx 0.0.18 → 0.0.22

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 (149) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +107 -70
  3. package/package.json +10 -20
  4. package/src/bin.ts +64 -5
  5. package/src/cache/archive.ts +279 -28
  6. package/src/cache/cache.ts +952 -1584
  7. package/src/cache/chained-cache.ts +14 -10
  8. package/src/cache/config-evals.ts +112 -0
  9. package/src/cache/file-hashes.ts +290 -0
  10. package/src/cache/git-inputs.ts +966 -0
  11. package/src/cache/index.ts +23 -7
  12. package/src/cache/inputs.ts +553 -739
  13. package/src/cache/key-fold.ts +232 -0
  14. package/src/cache/layer.ts +674 -0
  15. package/src/cache/layered-cache.ts +211 -49
  16. package/src/cache/output-index.ts +323 -0
  17. package/src/cache/policy.ts +84 -0
  18. package/src/cache/run-history.ts +152 -0
  19. package/src/cache/zstd.ts +183 -0
  20. package/src/cli/cache.ts +88 -30
  21. package/src/cli/completions.ts +104 -0
  22. package/src/cli/core-alias.ts +25 -0
  23. package/src/cli/format.ts +4 -15
  24. package/src/cli/help.ts +76 -38
  25. package/src/cli/index.ts +82 -17
  26. package/src/cli/info.ts +162 -91
  27. package/src/cli/init.ts +87 -0
  28. package/src/cli/last.ts +130 -23
  29. package/src/cli/lock.ts +36 -18
  30. package/src/cli/plan-format.ts +18 -7
  31. package/src/cli/plugin-commands.ts +34 -12
  32. package/src/cli/run.ts +138 -313
  33. package/src/cli/select.ts +342 -0
  34. package/src/cli/show.ts +151 -71
  35. package/src/cli/upgrade.ts +136 -15
  36. package/src/cli/watch.ts +1175 -116
  37. package/src/cli/why.ts +51 -29
  38. package/src/cli/workspace-config.ts +138 -0
  39. package/src/config.ts +183 -126
  40. package/src/exec/env.ts +11 -0
  41. package/src/exec/executor.ts +62 -31
  42. package/src/exec/index.ts +16 -2
  43. package/src/exec/kill-tree.ts +281 -0
  44. package/src/exec/local-executor.ts +41 -0
  45. package/src/exec/runner.ts +361 -77
  46. package/src/exec/sandbox-binds.ts +211 -0
  47. package/src/exec/sandbox-paths.ts +70 -0
  48. package/src/exec/sandbox-runtime.ts +1152 -415
  49. package/src/exec/sandbox-violations.ts +293 -0
  50. package/src/graph/dependency-spec.ts +14 -14
  51. package/src/graph/index.ts +6 -4
  52. package/src/graph/priorities.ts +130 -0
  53. package/src/graph/scheduler.ts +332 -329
  54. package/src/graph/task-graph.ts +552 -188
  55. package/src/index.ts +111 -102
  56. package/src/orchestrator/admission.ts +161 -0
  57. package/src/orchestrator/colors.ts +8 -3
  58. package/src/orchestrator/deferred-outputs.ts +25 -7
  59. package/src/orchestrator/doctor.ts +363 -0
  60. package/src/orchestrator/events.ts +111 -8
  61. package/src/orchestrator/excluded-keys.ts +155 -0
  62. package/src/orchestrator/execute-task.ts +450 -565
  63. package/src/orchestrator/failure-mode.ts +169 -28
  64. package/src/orchestrator/failure-recap.ts +121 -0
  65. package/src/orchestrator/fingerprint-watch.ts +89 -0
  66. package/src/orchestrator/framed-output.ts +121 -49
  67. package/src/orchestrator/history.ts +123 -104
  68. package/src/orchestrator/hit-restore.ts +285 -0
  69. package/src/orchestrator/index.ts +42 -5
  70. package/src/orchestrator/keyed-projects.ts +89 -0
  71. package/src/orchestrator/local-shortcircuit.ts +83 -23
  72. package/src/orchestrator/lockfile-claim.ts +401 -0
  73. package/src/orchestrator/logger.ts +128 -41
  74. package/src/orchestrator/metrics.ts +201 -72
  75. package/src/orchestrator/miss-save.ts +232 -0
  76. package/src/orchestrator/options.ts +63 -34
  77. package/src/orchestrator/persistent.ts +99 -0
  78. package/src/orchestrator/placement.ts +212 -0
  79. package/src/orchestrator/plan.ts +24 -11
  80. package/src/orchestrator/plugin-host.ts +200 -49
  81. package/src/orchestrator/plugin.ts +190 -14
  82. package/src/orchestrator/prepare.ts +168 -137
  83. package/src/orchestrator/projects.ts +270 -0
  84. package/src/orchestrator/remote-prefetch.ts +22 -2
  85. package/src/orchestrator/run-artifacts.ts +34 -3
  86. package/src/orchestrator/run-context.ts +20 -5
  87. package/src/orchestrator/run-lock.ts +349 -0
  88. package/src/orchestrator/run-records.ts +132 -0
  89. package/src/orchestrator/run-report.ts +6 -4
  90. package/src/orchestrator/run.ts +467 -611
  91. package/src/orchestrator/sandbox-request.ts +569 -0
  92. package/src/orchestrator/save-lane.ts +44 -0
  93. package/src/orchestrator/shell-verdict.ts +117 -0
  94. package/src/orchestrator/signals.ts +167 -0
  95. package/src/orchestrator/stable-keys.ts +259 -10
  96. package/src/orchestrator/status-line.ts +35 -2
  97. package/src/orchestrator/summary.ts +115 -25
  98. package/src/orchestrator/task-hash.ts +103 -21
  99. package/src/orchestrator/task-log-buffer.ts +15 -18
  100. package/src/orchestrator/telemetry-host.ts +27 -8
  101. package/src/orchestrator/telemetry.ts +71 -44
  102. package/src/orchestrator/upstream.ts +91 -45
  103. package/src/util/bun-version.ts +43 -0
  104. package/src/util/cgroup.ts +160 -0
  105. package/src/util/edit-distance.ts +49 -0
  106. package/src/util/errors.ts +86 -0
  107. package/src/util/hash.ts +10 -1
  108. package/src/util/index.ts +42 -5
  109. package/src/util/paths.ts +168 -5
  110. package/src/util/procfs.ts +20 -0
  111. package/src/util/settle.ts +23 -2
  112. package/src/util/size.ts +33 -0
  113. package/src/util/task-id.ts +20 -0
  114. package/src/util/timing.ts +11 -1
  115. package/src/util/verbs.ts +39 -0
  116. package/src/util/which.ts +48 -0
  117. package/src/workspace/affected.ts +368 -60
  118. package/src/workspace/config-cache.ts +271 -32
  119. package/src/workspace/config-eval.ts +60 -16
  120. package/src/workspace/config-imports.ts +131 -9
  121. package/src/workspace/config-schema.ts +1033 -0
  122. package/src/workspace/filter.ts +69 -17
  123. package/src/workspace/fingerprint.ts +59 -7
  124. package/src/workspace/index.ts +37 -3
  125. package/src/workspace/json-data.ts +90 -0
  126. package/src/workspace/load-reads.ts +32 -0
  127. package/src/workspace/lockfile.ts +41 -14
  128. package/src/workspace/migrate-scripts.ts +187 -0
  129. package/src/workspace/migration.ts +353 -0
  130. package/src/workspace/package-graph.ts +239 -31
  131. package/src/workspace/project-loader.ts +295 -815
  132. package/src/workspace/workspace.ts +266 -31
  133. package/plugins/local-cache/index.ts +0 -2
  134. package/plugins/local-executor/index.ts +0 -2
  135. package/plugins/schedule-history/index.ts +0 -2
  136. package/src/cache/cas-backend.ts +0 -115
  137. package/src/cache/digest.ts +0 -32
  138. package/src/cli/migrate-emit.ts +0 -21
  139. package/src/cli/migrate-nx.ts +0 -473
  140. package/src/cli/migrate-scripts.ts +0 -127
  141. package/src/cli/migrate-turbo.ts +0 -401
  142. package/src/cli/migrate.ts +0 -305
  143. package/src/cli/prune.ts +0 -291
  144. package/src/orchestrator/missing-plugin.ts +0 -9
  145. package/src/orchestrator/resources.ts +0 -45
  146. package/src/orchestrator/verify.ts +0 -174
  147. package/src/plugins/local-cache/index.ts +0 -11
  148. package/src/plugins/local-executor/index.ts +0 -45
  149. package/src/plugins/schedule-history/index.ts +0 -109
@@ -16,12 +16,12 @@
16
16
  // what or how tasks run. Contrast `cache`/`executor`, which return objects
17
17
  // core calls INTO; those are the behavior capabilities, kept separate.
18
18
 
19
- import type { OutputFingerprint, TaskStatus, VerifyVerdict } from '../graph/index.js'
19
+ import type { TaskOutcome, TaskStatus } from '../graph/index.js'
20
20
  import { settleWithin, teardownTimeoutMs } from '../util/index.js'
21
21
  import type { RunEvent, RunEventSubscriber } from './events.js'
22
22
 
23
23
  /** Bumped when the record shape changes. Readers MUST check `v`. */
24
- export const TELEMETRY_SCHEMA_VERSION = 2
24
+ export const TELEMETRY_SCHEMA_VERSION = 3
25
25
 
26
26
  /** Where a task's result came from, derived ONCE in core from the status. */
27
27
  export type CacheSource = 'miss' | 'local' | 'remote' | 'none'
@@ -75,8 +75,6 @@ const PASSES: Record<TaskStatus, boolean> = {
75
75
  */
76
76
  export const TASK_STATUSES: readonly TaskStatus[] = Object.keys(PASSES) as TaskStatus[]
77
77
 
78
- const KNOWN_STATUSES: ReadonlySet<string> = new Set(TASK_STATUSES)
79
-
80
78
  /**
81
79
  * Did the task pass? A cache hit counts — it produced the same result without
82
80
  * spending the time, which is the whole point. `skipped` and `aborted` do NOT:
@@ -97,7 +95,8 @@ export function isPassStatus(status: string): boolean {
97
95
  * cannot disagree about what a hit is. Unknown strings read as not-a-hit.
98
96
  */
99
97
  export function isCacheHit(status: string): boolean {
100
- if (!KNOWN_STATUSES.has(status)) return false
98
+ // No known-status guard: an unknown string falls through the switch to
99
+ // undefined, which is neither hit source (the guard survived item 654).
101
100
  const source = deriveCacheSource(status as TaskStatus)
102
101
  return source === 'local' || source === 'remote'
103
102
  }
@@ -160,7 +159,7 @@ export interface TaskTelemetry {
160
159
  cpuMs?: number
161
160
  peakRssBytes?: number
162
161
  /** Executor-reported placement (a worker id) — absent for local runs.
163
- * Additive-optional, no schema bump (the `attempts`/`verify` precedent). */
162
+ * Additive-optional, no schema bump (the `attempts` precedent). */
164
163
  where?: string
165
164
  /** `'deferred'` when the outputs stayed remote (`--download=none`).
166
165
  * Additive-optional, same no-bump precedent as `where`. */
@@ -169,18 +168,18 @@ export interface TaskTelemetry {
169
168
  * / `--retry` produced more than one attempt. A retried-then-passed task is
170
169
  * flaky by definition; this is the telemetry-side flaky signal. */
171
170
  attempts?: number
172
- /** Cache-correctness verdict — set ONLY on a `--verify` run (absent
173
- * otherwise). A `nondeterministic` verdict means the task's cache entry is
174
- * unsound (its outputs aren't a pure function of its declared inputs); this
175
- * is the telemetry-side hermeticity signal a dashboard surfaces. */
176
- verify?: VerifyVerdict
177
- /** Output-tree fingerprint — set ONLY under a fingerprinting `--verify*`
178
- * mode (`--verify` / `=all` / `=fingerprint`), executed tasks only. A
179
- * connected serve pairs fingerprints for the SAME `hash` across platforms
180
- * and names diverging outputs (the cross-machine diff). Absent on plain
181
- * runs — additive-optional, no schema bump (the `attempts`/`verify`
182
- * precedent). */
183
- outputFp?: OutputFingerprint
171
+ /**
172
+ * On a `skipped` task: the id of the failed (or aborted) task at the
173
+ * root of what blocked it, through any chain of skips. Absent on a
174
+ * fail-fast skip and on every other status. Additive (schema stays 2).
175
+ */
176
+ blockedBy?: string
177
+ /** On a `failed` task: vx's own `timeout` killed it (exit 143 is the deadline, not a signal). Additive. */
178
+ timedOut?: true
179
+ /** On a sandboxed task: how many declared-boundary violations the sandbox recorded (a failure on its own). Additive. */
180
+ sandboxViolations?: number
181
+ /** On a failed persistent task: why it never became ready. `exitCode` is the child's own when it exited. Additive. */
182
+ notReady?: 'timeout' | 'exited' | 'spawn'
184
183
  /** bigint hrtime ns relative to run t=0, encoded as a decimal string. */
185
184
  wallclockStartNs?: string
186
185
  wallclockEndNs?: string
@@ -239,6 +238,12 @@ export interface RunSummaryRecord {
239
238
  totalDurationMs: number
240
239
  taskCount: number
241
240
  failedCount: number
241
+ /**
242
+ * Tasks a shutdown signal or an embedder's abort killed (v3). They are
243
+ * not in `tasks`, which holds real runs only, so without this a stopped
244
+ * run read as a failure with nothing failed (item 851).
245
+ */
246
+ abortedCount: number
242
247
  hitCount: number
243
248
  hitLocalCount: number
244
249
  hitRemoteCount: number
@@ -254,13 +259,19 @@ export interface RunSummaryRecord {
254
259
  * distributed run and a local run produce byte-identical summaries and land in
255
260
  * the same ingest. The per-task tallies (taskCount / failedCount /
256
261
  * hitLocal|Remote|Count) derive from `tasks`; `totalDurationMs` (wall time) and
257
- * `exitOk` (the run's overall verdict — which counts skipped/verify beyond the
262
+ * `exitOk` (the run's overall verdict — which counts skipped tasks beyond the
258
263
  * recorded task list) are run-level facts and are passed in.
259
264
  */
260
265
  export function assembleRunSummary(
261
266
  run: RunContextRecord,
262
267
  tasks: readonly TaskTelemetry[],
263
- timing: { startedAt: number; endedAt: number; totalDurationMs: number; exitOk: boolean },
268
+ timing: {
269
+ startedAt: number
270
+ endedAt: number
271
+ totalDurationMs: number
272
+ exitOk: boolean
273
+ abortedCount: number
274
+ },
264
275
  ): RunSummaryRecord {
265
276
  let failedCount = 0
266
277
  let hitLocalCount = 0
@@ -278,6 +289,7 @@ export function assembleRunSummary(
278
289
  totalDurationMs: timing.totalDurationMs,
279
290
  taskCount: tasks.length,
280
291
  failedCount,
292
+ abortedCount: timing.abortedCount,
281
293
  hitCount: hitLocalCount + hitRemoteCount,
282
294
  hitLocalCount,
283
295
  hitRemoteCount,
@@ -364,8 +376,9 @@ export function createTelemetrySource(args: {
364
376
  // Never silently. The standing rule is that a never-fail path must still
365
377
  // WARN — telemetry that vanishes without a word is indistinguishable from
366
378
  // telemetry nobody configured.
379
+ // Every hook call site skips a disabled sink first, so this runs at most
380
+ // once per sink without a guard of its own (item 654).
367
381
  const disable = (sink: TelemetrySink, hook: string, err: unknown): void => {
368
- if (disabled.has(sink)) return
369
382
  disabled.add(sink)
370
383
  warn?.(
371
384
  `[vx] telemetry sink '${sink.name}' threw in ${hook}; disabled for this run: ${err instanceof Error ? err.message : String(err)}`,
@@ -435,32 +448,13 @@ export function createTelemetrySource(args: {
435
448
  case 'task:complete': {
436
449
  const { node, outcome } = event
437
450
  if (node.config.exec === undefined) return // group task
438
- const rec: TelemetryRecord = {
451
+ deliver({
439
452
  v: TELEMETRY_SCHEMA_VERSION,
440
453
  kind: 'task.end',
441
454
  runId,
442
455
  ts,
443
- taskId: node.id,
444
- project: node.projectName,
445
- task: node.taskName,
446
- status: outcome.status,
447
- cacheSource: deriveCacheSource(outcome.status),
448
- exitCode: outcome.exitCode,
449
- durationMs: outcome.durationMs,
450
- }
451
- if (outcome.hash !== undefined) rec.hash = outcome.hash
452
- if (outcome.cpuMs !== undefined) rec.cpuMs = outcome.cpuMs
453
- if (outcome.peakRssBytes !== undefined) rec.peakRssBytes = outcome.peakRssBytes
454
- if (outcome.where !== undefined) rec.where = outcome.where
455
- if (outcome.outputs !== undefined) rec.outputs = outcome.outputs
456
- if (outcome.attempts !== undefined) rec.attempts = outcome.attempts
457
- if (outcome.verify !== undefined) rec.verify = outcome.verify
458
- if (outcome.outputFp !== undefined) rec.outputFp = outcome.outputFp
459
- if (outcome.wallclockStartNs !== undefined)
460
- rec.wallclockStartNs = outcome.wallclockStartNs.toString()
461
- if (outcome.wallclockEndNs !== undefined)
462
- rec.wallclockEndNs = outcome.wallclockEndNs.toString()
463
- deliver(rec)
456
+ ...taskTelemetryOf(outcome),
457
+ })
464
458
  return
465
459
  }
466
460
  case 'run:status':
@@ -490,7 +484,8 @@ export function createTelemetrySource(args: {
490
484
  const ms = teardownTimeoutMs()
491
485
  // Bounded, for the same reason plugin teardown is bounded in
492
486
  // plugin-host.ts: run() awaits this BEFORE closeCache() and before it
493
- // returns, and bin.ts is `process.exit(await run(...))`. A sink whose
487
+ // returns, and bin.ts exits with run()'s code once it returns (through
488
+ // stdout's end callback since 2026-09-15; the code is still that). A sink whose
494
489
  // flush never settles therefore drains the event loop with no exit code
495
490
  // pending — Bun exits 0 and a FAILED run reports green, with the cache's
496
491
  // accessed_at bumps and every later plugin's teardown lost with it.
@@ -516,3 +511,35 @@ export function createTelemetrySource(args: {
516
511
  },
517
512
  }
518
513
  }
514
+
515
+ /**
516
+ * The one projection of an outcome into `TaskTelemetry`, for the streaming
517
+ * `task.end` record and the summary's `tasks[]` alike. Two copies drifted:
518
+ * `task.end` dropped `blockedBy`, `timedOut`, `sandboxViolations` and
519
+ * `notReady`, so a streaming sink saw a timed-out or blocked task as a plain
520
+ * failure or skip (item 660).
521
+ */
522
+ export function taskTelemetryOf(o: TaskOutcome): TaskTelemetry {
523
+ const t: TaskTelemetry = {
524
+ taskId: o.node.id,
525
+ project: o.node.projectName,
526
+ task: o.node.taskName,
527
+ status: o.status,
528
+ cacheSource: deriveCacheSource(o.status),
529
+ exitCode: o.exitCode,
530
+ durationMs: o.durationMs,
531
+ }
532
+ if (o.hash !== undefined) t.hash = o.hash
533
+ if (o.cpuMs !== undefined) t.cpuMs = o.cpuMs
534
+ if (o.peakRssBytes !== undefined) t.peakRssBytes = o.peakRssBytes
535
+ if (o.where !== undefined) t.where = o.where
536
+ if (o.outputs !== undefined) t.outputs = o.outputs
537
+ if (o.attempts !== undefined) t.attempts = o.attempts
538
+ if (o.blockedBy !== undefined) t.blockedBy = o.blockedBy
539
+ if (o.timedOut === true) t.timedOut = true
540
+ if (o.sandboxViolations !== undefined) t.sandboxViolations = o.sandboxViolations
541
+ if (o.notReady !== undefined) t.notReady = o.notReady
542
+ if (o.wallclockStartNs !== undefined) t.wallclockStartNs = o.wallclockStartNs.toString()
543
+ if (o.wallclockEndNs !== undefined) t.wallclockEndNs = o.wallclockEndNs.toString()
544
+ return t
545
+ }
@@ -4,10 +4,22 @@ import {
4
4
  isTaskPattern,
5
5
  parseDependencySpec,
6
6
  type DependencySpec,
7
+ type TaskNode,
7
8
  type TaskOutcome,
8
9
  } from '../graph/index.js'
9
10
  import { UserError } from '../util/index.js'
10
11
 
12
+ /**
13
+ * A task's upstream as its KEY reads it: the live outcomes of its scheduled
14
+ * dependencies, plus the keys of any `--exclude-dependencies` took out of
15
+ * the schedule (`excluded-keys.ts`). Every key site goes through this —
16
+ * the run, the plan, the up-front classify — so a selection cannot change
17
+ * a key at one of them and not the others.
18
+ */
19
+ export function keyUpstream(node: TaskNode, upstream: TaskOutcome[]): TaskOutcome[] {
20
+ return node.excludedUpstream === undefined ? upstream : [...upstream, ...node.excludedUpstream]
21
+ }
22
+
11
23
  /**
12
24
  * Pick which upstream task hashes participate in the current task's
13
25
  * cache key, filtered by `cache.inputs.tasks`. The folded value is the
@@ -18,6 +30,44 @@ import { UserError } from '../util/index.js'
18
30
  * output still re-runs its dependents (early cutoff was removed —
19
31
  * rare in practice, not worth the cascade complexity).
20
32
  *
33
+ * The selection itself is `selectFoldedDeps`, which the sandbox's keyed
34
+ * set (keyed-projects.ts) walks over the graph before any hash exists:
35
+ * one matcher, so what the key folds and what the sandbox believes it
36
+ * folds cannot drift.
37
+ *
38
+ * Returns `[upstreamTaskId, hash]` pairs. The hash is the only thing
39
+ * folded into the cache key (the fold sorts by hash, so ordering here
40
+ * doesn't affect derivation); the task id rides along so Tier-3's
41
+ * `entry_inputs` rows can NAME which upstream a hash came from. An
42
+ * upstream with no hash (a persistent task) folds nothing.
43
+ */
44
+ export function filterUpstreamHashes(
45
+ upstream: TaskOutcome[],
46
+ filter: readonly string[] | undefined,
47
+ selfProjectName: string,
48
+ selfTaskId: string,
49
+ ): Array<[upstreamTaskId: string, hash: string]> {
50
+ const candidates: FoldCandidate[] = []
51
+ for (const u of upstream) if (u.hash) candidates.push({ node: u.node, unit: u.hash })
52
+ return selectFoldedDeps(candidates, filter, selfProjectName, selfTaskId).map((c) => [
53
+ c.node.id,
54
+ c.unit,
55
+ ])
56
+ }
57
+
58
+ /**
59
+ * One dependency as the key fold sees it: the task, and the value the
60
+ * fold dedups by — its hash on the hash path, a structural stand-in for
61
+ * the hash on the graph (`foldUnit` in keyed-projects.ts).
62
+ */
63
+ export interface FoldCandidate {
64
+ node: TaskNode
65
+ unit: string
66
+ }
67
+
68
+ /**
69
+ * The dependencies a task's key folds, per its `cache.inputs.tasks`.
70
+ *
21
71
  * Patterns (Turbo/Nx micro-syntax + filter extensions):
22
72
  * '*' all same-project upstream
23
73
  * '^*' all dep-workspace upstream
@@ -36,28 +86,21 @@ import { UserError } from '../util/index.js'
36
86
  * `['*', '^*', '!^noisy']` reads as "all minus deps' noisy".
37
87
  *
38
88
  * Defaults:
39
- * - `filter === undefined` → all upstream contribute.
40
- * - `filter === []` → none contribute (fully decoupled).
89
+ * - `filter === undefined` → every candidate, as given.
90
+ * - `filter === []` → none (fully decoupled).
41
91
  *
42
- * Returns `[upstreamTaskId, hash]` pairs. The hash is the only thing
43
- * folded into the cache key (the fold sorts by hash, so ordering here
44
- * doesn't affect derivation); the task id rides along so Tier-3's
45
- * `entry_inputs` rows can NAME which upstream a hash came from. The
46
- * filter dedups by hash, as before — two upstream tasks with identical
47
- * hashes contribute one pair (first id wins; their key contribution is
48
- * identical anyway).
92
+ * Otherwise deduped by `unit`, first candidate kept. The unit is the
93
+ * fold's own identity, not the task's: two groups over the same members
94
+ * share one hash, so excluding either excludes both, and a selection by
95
+ * task id would keep the other while the key folded neither.
49
96
  */
50
- export function filterUpstreamHashes(
51
- upstream: TaskOutcome[],
97
+ export function selectFoldedDeps(
98
+ deps: readonly FoldCandidate[],
52
99
  filter: readonly string[] | undefined,
53
100
  selfProjectName: string,
54
101
  selfTaskId: string,
55
- ): Array<[upstreamTaskId: string, hash: string]> {
56
- if (filter === undefined) {
57
- const out: Array<[string, string]> = []
58
- for (const u of upstream) if (u.hash) out.push([u.node.id, u.hash])
59
- return out
60
- }
102
+ ): FoldCandidate[] {
103
+ if (filter === undefined) return [...deps]
61
104
 
62
105
  const specs: DependencySpec[] = filter.map((raw) => {
63
106
  try {
@@ -73,21 +116,18 @@ export function filterUpstreamHashes(
73
116
  // Per-spec predicate, compiled once (exact compares + patterns).
74
117
  const matchers = specs.map((spec) => specMatcher(spec))
75
118
 
76
- // Dedup by hash (the key fold's unit), but remember the first task id
77
- // seen for each hash so the diff row can name the upstream.
78
- const selected = new Map<string, string>()
119
+ const selected = new Map<string, FoldCandidate>()
79
120
  for (let i = 0; i < specs.length; i++) {
80
121
  const spec = specs[i]!
81
122
  const matches = matchers[i]!
82
- for (const u of upstream) {
83
- if (!u.hash) continue
84
- const isSelf = u.node.projectName === selfProjectName
85
- if (!matches(u, isSelf)) continue
86
- if (spec.negated) selected.delete(u.hash)
87
- else if (!selected.has(u.hash)) selected.set(u.hash, u.node.id)
123
+ for (const d of deps) {
124
+ const isSelf = d.node.projectName === selfProjectName
125
+ if (!matches(d.node, isSelf)) continue
126
+ if (spec.negated) selected.delete(d.unit)
127
+ else if (!selected.has(d.unit)) selected.set(d.unit, d)
88
128
  }
89
129
  }
90
- return [...selected].map(([hash, id]) => [id, hash])
130
+ return [...selected.values()]
91
131
  }
92
132
 
93
133
  /**
@@ -102,25 +142,31 @@ export function filterUpstreamHashes(
102
142
  * IS the input root: `dependsOn: ['install']` shipped a worker an action with
103
143
  * none of what `install` chains.
104
144
  *
105
- * Groups may nest, so the walk recurses. Only what the FILTER already
106
- * selected is passed in, so a group excluded from a task's
145
+ * Groups may nest, as deep as the graph (the builder takes a 50,000-deep
146
+ * chain, item 737), so the walk keeps its own stack; and a group reached
147
+ * along two paths (a group `build` whose `^build` meets a package diamond)
148
+ * is expanded once, or the walk doubles per layer. Only what the FILTER
149
+ * already selected is passed in, so a group excluded from a task's
107
150
  * `cache.inputs.tasks` brings no members with it.
108
151
  */
109
152
  export function expandGroupUpstream(upstream: readonly TaskOutcome[]): TaskOutcome[] {
110
153
  const out: TaskOutcome[] = []
111
154
  const seen = new Set<string>()
112
- const walk = (list: readonly TaskOutcome[]): void => {
113
- for (const u of list) {
114
- if (u.groupUpstream !== undefined) {
115
- walk(u.groupUpstream)
116
- continue
117
- }
118
- if (seen.has(u.node.id)) continue
119
- seen.add(u.node.id)
120
- out.push(u)
155
+ const expanded = new Set<TaskOutcome>()
156
+ const stack = upstream.toReversed()
157
+ while (stack.length > 0) {
158
+ const u = stack.pop()!
159
+ if (expanded.has(u)) continue
160
+ const members = u.groupUpstream
161
+ if (members !== undefined) {
162
+ expanded.add(u)
163
+ for (let i = members.length - 1; i >= 0; i--) stack.push(members[i]!)
164
+ continue
121
165
  }
166
+ if (seen.has(u.node.id)) continue
167
+ seen.add(u.node.id)
168
+ out.push(u)
122
169
  }
123
- walk(upstream)
124
170
  return out
125
171
  }
126
172
 
@@ -139,24 +185,24 @@ function nameMatcher(name: string): (candidate: string) => boolean {
139
185
  * so a package pattern is unambiguous here — unlike dependsOn, which must
140
186
  * materialize concrete edges and therefore rejects it.
141
187
  */
142
- function specMatcher(spec: DependencySpec): (u: TaskOutcome, isSelf: boolean) => boolean {
188
+ function specMatcher(spec: DependencySpec): (n: TaskNode, isSelf: boolean) => boolean {
143
189
  switch (spec.kind) {
144
190
  case 'wildcardSelf':
145
- return (_u, isSelf) => isSelf
191
+ return (_n, isSelf) => isSelf
146
192
  case 'wildcardDeps':
147
- return (_u, isSelf) => !isSelf
193
+ return (_n, isSelf) => !isSelf
148
194
  case 'self': {
149
195
  const task = nameMatcher(spec.task)
150
- return (u, isSelf) => isSelf && task(u.node.taskName)
196
+ return (n, isSelf) => isSelf && task(n.taskName)
151
197
  }
152
198
  case 'deps': {
153
199
  const task = nameMatcher(spec.task)
154
- return (u, isSelf) => !isSelf && task(u.node.taskName)
200
+ return (n, isSelf) => !isSelf && task(n.taskName)
155
201
  }
156
202
  case 'cross': {
157
203
  const project = nameMatcher(spec.project)
158
204
  const task = nameMatcher(spec.task)
159
- return (u) => project(u.node.projectName) && task(u.node.taskName)
205
+ return (n) => project(n.projectName) && task(n.taskName)
160
206
  }
161
207
  }
162
208
  }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * The runtime floor. `package.json`'s `engines` states it, but `engines` is
3
+ * advice an install may print and a run never reads: `bun src/bin.ts` on an
4
+ * older Bun starts fine and then answers WRONGLY, which is the failure class
5
+ * this repo treats as the worst one.
6
+ *
7
+ * Measured on Bun 1.3.11 (2026-09-19, STATUS item 366): a 2 MiB `--format
8
+ * json` answer reached a pipe as 219 KB — `bin.ts` ends stdout in a callback
9
+ * precisely to prevent that, and the fix does not hold below the floor; the
10
+ * runner read no `peakRssBytes` at all, so `vx last` and `vx why` reported
11
+ * nothing a task used; and a config syntax error arrived as a `BuildMessage`
12
+ * the loader's classifier does not know, surfacing as an internal error
13
+ * instead of the UserError naming file, line and column. Ten of that
14
+ * container's twenty-three failures were this, and every one of them is a
15
+ * green exit over a wrong answer.
16
+ *
17
+ * `@vzn/vx-reapi` REFUSES an old Bun at its wire, because its failure mode is
18
+ * a hang and a hang leaves the user nothing to read. Core warns instead: most
19
+ * of it works below the floor (23 of ~2,000 tests failed on 1.3.11), so
20
+ * refusing would be heavier than the measurement supports. What the
21
+ * measurement supports is that a green exit can hide a truncated answer, and
22
+ * that is what the warning says.
23
+ */
24
+ export const MIN_BUN = [1, 4, 0] as const
25
+
26
+ /** True when `version` is older than {@link MIN_BUN}. */
27
+ export function isUnsupportedBun(version: string): boolean {
28
+ const [maj = 0, min = 0, patch = 0] = version.split('.').map((p) => Number.parseInt(p, 10) || 0)
29
+ const [reqMaj, reqMin, reqPatch] = MIN_BUN
30
+ if (maj !== reqMaj) return maj < reqMaj
31
+ if (min !== reqMin) return min < reqMin
32
+ return patch < reqPatch
33
+ }
34
+
35
+ /** The warning a CLI entry prints once, before the verb runs. */
36
+ export function unsupportedBunMessage(version: string): string {
37
+ return (
38
+ `vx needs Bun >= ${MIN_BUN.join('.')} (running ${version}). ` +
39
+ `An older Bun does not fail — it answers wrongly: a large \`--format json\` ` +
40
+ `answer is truncated mid-write, no task reports what it used, and a config ` +
41
+ `syntax error surfaces as an internal error. Upgrade with \`bun upgrade\`.`
42
+ )
43
+ }
@@ -0,0 +1,160 @@
1
+ // What this process may actually use, as its cgroup bounds it. Inside a
2
+ // container `navigator.hardwareConcurrency` and `os.totalmem()` report
3
+ // the HOST's cores and RAM; the cgroup that runs the job (a docker
4
+ // executor's `--cpus` / `--memory`, a Kubernetes limit) is what the
5
+ // kernel enforces. A limit set on an ANCESTOR binds too, so each reader
6
+ // walks from this process's cgroup up to the root and keeps the
7
+ // tightest value. Linux only; elsewhere the machine's numbers stand.
8
+ // Both hierarchies: v2 (unified, `memory.max` / `cpu.max`) and v1 (one
9
+ // mount per controller, `memory.limit_in_bytes` / `cpu.cfs_*_us`).
10
+
11
+ import { existsSync, readFileSync } from 'node:fs'
12
+ import os from 'node:os'
13
+ import path from 'node:path'
14
+
15
+ export interface CgroupProbe {
16
+ /** The cgroup filesystem root. Default `/sys/fs/cgroup`. */
17
+ readonly root?: string
18
+ /** This process's cgroup membership file. Default `/proc/self/cgroup`. */
19
+ readonly procSelfCgroup?: string
20
+ }
21
+
22
+ /**
23
+ * The tightest memory limit on this process's cgroup or any ancestor, in
24
+ * bytes; undefined when no cgroup bounds it. v2: `memory.max`, `max`
25
+ * binds nothing. v1: `memory.limit_in_bytes`, where an unlimited level
26
+ * writes the page-counter maximum (~2^63) — anything past 2^60 binds
27
+ * nothing.
28
+ */
29
+ export function cgroupMemoryLimitBytes(probe: CgroupProbe = {}): number | undefined {
30
+ return tightest(probe, 'memory', {
31
+ v2: (dir) =>
32
+ parseLimit(read(dir, 'memory.max'), (raw) => (raw === 'max' ? undefined : Number(raw))),
33
+ v1: (dir) =>
34
+ parseLimit(read(dir, 'memory.limit_in_bytes'), (raw) => {
35
+ const n = Number(raw)
36
+ return n >= 2 ** 60 ? undefined : n
37
+ }),
38
+ })
39
+ }
40
+
41
+ /**
42
+ * The tightest CPU quota on this process's cgroup or any ancestor, in
43
+ * cores (a fraction is possible: `--cpus=1.5`); undefined when no cgroup
44
+ * bounds it. v2: `cpu.max` is `<quota> <period>` or `max <period>`. v1:
45
+ * `cpu.cfs_quota_us` (-1 for none) over `cpu.cfs_period_us`.
46
+ */
47
+ export function cgroupCpuQuota(probe: CgroupProbe = {}): number | undefined {
48
+ return tightest(probe, 'cpu', {
49
+ v2: (dir) => {
50
+ const raw = read(dir, 'cpu.max')
51
+ if (raw === undefined) return undefined
52
+ const [quota, period] = raw.split(/\s+/)
53
+ return quota === 'max' ? undefined : ratio(Number(quota), Number(period ?? '100000'))
54
+ },
55
+ v1: (dir) => {
56
+ const quota = read(dir, 'cpu.cfs_quota_us')
57
+ const period = read(dir, 'cpu.cfs_period_us')
58
+ if (quota === undefined || period === undefined) return undefined
59
+ const q = Number(quota)
60
+ return q <= 0 ? undefined : ratio(q, Number(period))
61
+ },
62
+ })
63
+ }
64
+
65
+ /** The machine's total memory, capped by its cgroup limit. */
66
+ export function machineMemoryBytes(probe: CgroupProbe = {}): number {
67
+ const total = os.totalmem()
68
+ if (process.platform !== 'linux') return total
69
+ const limit = cgroupMemoryLimitBytes(probe)
70
+ return limit === undefined ? total : Math.min(total, limit)
71
+ }
72
+
73
+ /**
74
+ * The cores this process may run on, capped by its cgroup CPU quota,
75
+ * rounded up (a 1.5-core quota is two workers, not one) and never below
76
+ * one. The default worker count.
77
+ */
78
+ export function machineParallelism(probe: CgroupProbe = {}): number {
79
+ const cores = Math.max(1, navigator.hardwareConcurrency)
80
+ if (process.platform !== 'linux') return cores
81
+ const quota = cgroupCpuQuota(probe)
82
+ return quota === undefined ? cores : Math.max(1, Math.min(cores, Math.ceil(quota)))
83
+ }
84
+
85
+ function ratio(quota: number, period: number): number | undefined {
86
+ return Number.isFinite(quota) && Number.isFinite(period) && quota > 0 && period > 0
87
+ ? quota / period
88
+ : undefined
89
+ }
90
+
91
+ function parseLimit(
92
+ raw: string | undefined,
93
+ parse: (raw: string) => number | undefined,
94
+ ): number | undefined {
95
+ if (raw === undefined) return undefined
96
+ const n = parse(raw)
97
+ return n !== undefined && Number.isFinite(n) && n > 0 ? n : undefined
98
+ }
99
+
100
+ function read(dir: string, file: string): string | undefined {
101
+ const p = path.join(dir, file)
102
+ if (!existsSync(p)) return undefined
103
+ try {
104
+ return readFileSync(p, 'utf8').trim()
105
+ } catch {
106
+ // A level that cannot be read bounds nothing.
107
+ return undefined
108
+ }
109
+ }
110
+
111
+ /**
112
+ * The minimum over every level from this process's cgroup (for
113
+ * `controller`) up to the hierarchy's root. The v1 mount is
114
+ * `<root>/<controller>`; v2 is the one unified tree at `<root>`. A
115
+ * membership path that escapes the root (a foreign file) is not walked.
116
+ */
117
+ function tightest(
118
+ probe: CgroupProbe,
119
+ controller: 'memory' | 'cpu',
120
+ readers: { v2: (dir: string) => number | undefined; v1: (dir: string) => number | undefined },
121
+ ): number | undefined {
122
+ const root = probe.root ?? '/sys/fs/cgroup'
123
+ let membership: string
124
+ try {
125
+ membership = readFileSync(probe.procSelfCgroup ?? '/proc/self/cgroup', 'utf8')
126
+ } catch {
127
+ return undefined
128
+ }
129
+ const candidates: number[] = []
130
+ for (const line of membership.split('\n')) {
131
+ const parts = line.split(':')
132
+ if (parts.length < 3) continue
133
+ const controllers = parts[1]!
134
+ const cgroupPath = parts.slice(2).join(':')
135
+ if (controllers === '') {
136
+ walkUp(path.join(root, cgroupPath), root, readers.v2, candidates)
137
+ } else if (controllers.split(',').includes(controller)) {
138
+ const base = path.join(root, controller)
139
+ walkUp(path.join(base, cgroupPath), base, readers.v1, candidates)
140
+ }
141
+ }
142
+ return candidates.length === 0 ? undefined : Math.min(...candidates)
143
+ }
144
+
145
+ function walkUp(
146
+ from: string,
147
+ stopAt: string,
148
+ readLevel: (dir: string) => number | undefined,
149
+ into: number[],
150
+ ): void {
151
+ let dir = path.resolve(from)
152
+ const stop = path.resolve(stopAt)
153
+ if (dir !== stop && !dir.startsWith(stop + path.sep)) return
154
+ for (;;) {
155
+ const n = readLevel(dir)
156
+ if (n !== undefined) into.push(n)
157
+ if (dir === stop) return
158
+ dir = path.dirname(dir)
159
+ }
160
+ }
@@ -19,3 +19,52 @@ export function editDistance(a: string, b: string): number {
19
19
  }
20
20
  return Math.min(prev[b.length]!, 3)
21
21
  }
22
+
23
+ /**
24
+ * The ONE near-miss rule every "did you mean" hint applies: the closest
25
+ * candidate within two edits of `name`, or undefined — an exact match is
26
+ * not a hint, and anything further would guess rather than help. Task
27
+ * names, `pkg#task` halves, project names, flags and verbs all go through
28
+ * here so a typo is hinted the same way wherever it is typed.
29
+ */
30
+ export function nearest(
31
+ name: string,
32
+ candidates: Iterable<string>,
33
+ maxEdits = 2,
34
+ ): string | undefined {
35
+ let best: string | undefined
36
+ let bestD = maxEdits + 1
37
+ for (const c of candidates) {
38
+ const d = editDistance(name, c)
39
+ if (d < bestD) {
40
+ bestD = d
41
+ best = c
42
+ }
43
+ }
44
+ return best === name ? undefined : best
45
+ }
46
+
47
+ /**
48
+ * Up to `limit` candidates worth offering for `name`: the nearest by edit
49
+ * distance first, then any that contain it (or that it contains), case-
50
+ * insensitively — the inspection verbs (`why`, `prune`) list a few rather
51
+ * than pick one, since a partial name is as common a query there as a
52
+ * typo. Never includes `name` itself.
53
+ */
54
+ export function nearMatches(name: string, candidates: Iterable<string>, limit = 3): string[] {
55
+ const all = [...new Set(candidates)].filter((c) => c !== name)
56
+ const q = name.toLowerCase()
57
+ const byDistance = all
58
+ .map((c) => [c, editDistance(name, c)] as const)
59
+ .filter(([, d]) => d < 3)
60
+ .sort((a, b) => a[1] - b[1])
61
+ .map(([c]) => c)
62
+ const out = [...byDistance]
63
+ for (const c of all) {
64
+ if (out.length >= limit) break
65
+ if (out.includes(c)) continue
66
+ const n = c.toLowerCase()
67
+ if (n.includes(q) || q.includes(n)) out.push(c)
68
+ }
69
+ return out.slice(0, limit)
70
+ }