@vzn/vx 0.0.12 → 0.0.15

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.
@@ -2,6 +2,7 @@
2
2
  // run with caching. Each step delegates to a single-purpose sibling file
3
3
  // so the layers can be swapped without touching the others.
4
4
 
5
+ import os from 'node:os'
5
6
  import {
6
7
  type CacheLayer,
7
8
  type CachePolicy,
@@ -21,6 +22,7 @@ import {
21
22
  } from '../graph/index.js'
22
23
  import { ulid, UserError } from '../util/index.js'
23
24
  import { executeTask } from './execute-task.js'
25
+ import { resolveResourceCosts } from './resources.js'
24
26
  import { computeTaskHash } from './task-hash.js'
25
27
  import { busLogger, createEventBus, terminalSubscriber } from './events.js'
26
28
  import { installPlugins } from './plugin.js'
@@ -248,6 +250,20 @@ export async function run(options: RunOptions): Promise<RunSummary> {
248
250
  const remoteCacheEnabled = cache instanceof LayeredCache
249
251
  const policy: CachePolicy = options.cache ?? FULL_CACHE_POLICY
250
252
 
253
+ // `--verify` observes the miss-then-save path, so a policy with NO write
254
+ // axis (`--no-cache`, `--cache=local:,remote:`) verifies nothing — every
255
+ // task would come back green with zero verdicts. The user asked for a
256
+ // proof; silently skipping it is the one failure mode verification must
257
+ // never have (same platform-honesty rule as the sandbox-unavailable
258
+ // error). `--force --verify` is the supported re-verify-warm recipe.
259
+ if (options.verify !== undefined && !policy.localWrite && !policy.remoteWrite) {
260
+ prepared.cache.close()
261
+ throw new UserError(
262
+ '--verify needs cache writes to prove anything (it verifies the save path); ' +
263
+ 'drop --no-cache, or use --force --verify to re-execute and verify a warm graph',
264
+ )
265
+ }
266
+
251
267
  // Per-run context for the Tier-3 `invocations` header row. Captured
252
268
  // ONCE (git is ONE spawn for commit+branch, behind try/catch; never
253
269
  // fails a run). `dirty` reuses the `git status --porcelain` the
@@ -345,6 +361,17 @@ export async function run(options: RunOptions): Promise<RunSummary> {
345
361
  if (node.requested || node.surfaced === true) requestedCount++
346
362
  }
347
363
  }
364
+ // Resource-aware admission: resolve every task's `exec.resources`
365
+ // into absolute costs ONCE, up front (percent forms against the
366
+ // budgets), so the scheduler's inner loop is a plain Map.get. The
367
+ // CPU budget is the run's concurrency; the memory budget is
368
+ // os.totalmem() unless `--memory` overrides it (pass `--memory` in
369
+ // cgroup-limited containers — totalmem() reports the HOST's RAM).
370
+ // Nothing declared → empty map → fields omitted from the scheduler
371
+ // AND the footer → byte-identical legacy path.
372
+ const memBudget = options.memory ?? os.totalmem()
373
+ const resourceCosts = resolveResourceCosts(nodes, concurrency, memBudget)
374
+
348
375
  // Run context for the footer. The top-of-run header is gone — the
349
376
  // banner now lives in the summary, where the eye lands at the end.
350
377
  const runContext = {
@@ -353,6 +380,7 @@ export async function run(options: RunOptions): Promise<RunSummary> {
353
380
  remoteCacheEnabled,
354
381
  concurrency,
355
382
  workspaceProjectCount,
383
+ ...(resourceCosts.size > 0 ? { cpuBudget: concurrency, memBudget } : {}),
356
384
  }
357
385
 
358
386
  // Lifecycle hooks drive the default logger's dynamic status line
@@ -501,6 +529,7 @@ export async function run(options: RunOptions): Promise<RunSummary> {
501
529
  const outcomes = await runGraph({
502
530
  nodes,
503
531
  concurrency,
532
+ ...(resourceCosts.size > 0 ? { resourceCosts, cpuBudget: concurrency, memBudget } : {}),
504
533
  ...(options.continueMode !== undefined ? { continueMode: options.continueMode } : {}),
505
534
  onStart: (node) => {
506
535
  log.taskStart?.(node)
@@ -586,6 +615,21 @@ export async function run(options: RunOptions): Promise<RunSummary> {
586
615
  for (const line of formatRunSummary(list, totalMs, colors, runContext)) log.status(line)
587
616
  if (options.verify !== undefined) {
588
617
  for (const line of formatVerifySection(list)) log.status(line)
618
+ // A fingerprint-only run attaches no verdicts, so the verdict-driven
619
+ // section above prints nothing — report what actually happened.
620
+ if (options.verify.fingerprint && !options.verify.determinism && !options.verify.inputs) {
621
+ const n = list.filter((o) => o.outputFp !== undefined).length
622
+ log.status('')
623
+ log.status(
624
+ ` Verify: fingerprinted ${n} task output trees (cross-machine diff via a connected serve)`,
625
+ )
626
+ // Only EXECUTED tasks fingerprint — a warm all-hit run reports 0.
627
+ // A per-platform matrix wired without `--force` produces nothing
628
+ // forever, so name the cause instead of a bare 0.
629
+ if (n === 0) {
630
+ log.status(' (0 executed — cache hits do not fingerprint; pair with --force)')
631
+ }
632
+ }
589
633
  }
590
634
 
591
635
  // Optional artifacts. Errors are surfaced to the user but don't
@@ -658,6 +702,7 @@ export async function run(options: RunOptions): Promise<RunSummary> {
658
702
  if (o.peakRssBytes !== undefined) t.peakRssBytes = o.peakRssBytes
659
703
  if (o.attempts !== undefined) t.attempts = o.attempts
660
704
  if (o.verify !== undefined) t.verify = o.verify
705
+ if (o.outputFp !== undefined) t.outputFp = o.outputFp
661
706
  if (o.wallclockStartNs !== undefined) t.wallclockStartNs = o.wallclockStartNs.toString()
662
707
  if (o.wallclockEndNs !== undefined) t.wallclockEndNs = o.wallclockEndNs.toString()
663
708
  summaryTasks.push(t)
@@ -125,6 +125,14 @@ export interface RunContext {
125
125
  remoteCacheEnabled: boolean
126
126
  /** Total projects discovery found — the bar's denominator. */
127
127
  workspaceProjectCount?: number
128
+ /**
129
+ * Resource-admission budgets (`exec.resources`), set ONLY when at
130
+ * least one task declared a reservation — a plain run's footer is
131
+ * byte-identical. Shown on the `info` row so a packed run says what
132
+ * it packed against. memBudget in bytes.
133
+ */
134
+ cpuBudget?: number
135
+ memBudget?: number
128
136
  }
129
137
 
130
138
  export function formatSummarySection(
@@ -240,6 +248,9 @@ export function formatSummarySection(
240
248
  if (context.concurrency !== undefined)
241
249
  info.push(`${context.concurrency} worker${context.concurrency === 1 ? '' : 's'}`)
242
250
  info.push(context.remoteCacheEnabled ? 'local + remote cache' : 'local cache')
251
+ if (context.cpuBudget !== undefined) info.push(`cpu budget ${context.cpuBudget}`)
252
+ if (context.memBudget !== undefined && Number.isFinite(context.memBudget))
253
+ info.push(`mem budget ${formatBudgetBytes(context.memBudget)}`)
243
254
  lines.push('', row('info', join(info)), row('time', `${formatDuration(totalMs)}${spread}`))
244
255
  } else {
245
256
  lines.push('', row('time', `${formatDuration(totalMs)}${spread}`))
@@ -247,6 +258,19 @@ export function formatSummarySection(
247
258
  return lines
248
259
  }
249
260
 
261
+ // Local byte formatter — the orchestrator can't import cli/format.ts
262
+ // (module boundary), and the footer only needs one compact form.
263
+ function formatBudgetBytes(bytes: number): string {
264
+ const units = ['B', 'KB', 'MB', 'GB', 'TB']
265
+ let v = bytes
266
+ let u = 0
267
+ while (v >= 1024 && u < units.length - 1) {
268
+ v /= 1024
269
+ u++
270
+ }
271
+ return `${u === 0 ? v : v.toFixed(1)} ${units[u]}`
272
+ }
273
+
250
274
  export function formatRunSummary(
251
275
  outcomes: readonly TaskOutcome[],
252
276
  totalMs: number,
@@ -170,11 +170,26 @@ function hashTaskConfig(cfg: TaskConfig, hashCache?: HashCache): string {
170
170
  if (hashCache) {
171
171
  const cached = hashCache.taskConfig.get(cfg)
172
172
  if (cached !== undefined) return cached
173
- const hash = xxh3hex(JSON.stringify(cfg))
173
+ const hash = xxh3hex(JSON.stringify(hashableConfig(cfg)))
174
174
  hashCache.taskConfig.set(cfg, hash)
175
175
  return hash
176
176
  }
177
- return xxh3hex(JSON.stringify(cfg))
177
+ return xxh3hex(JSON.stringify(hashableConfig(cfg)))
178
+ }
179
+
180
+ /**
181
+ * Project the config for hashing: `exec.resources` is a pure scheduling
182
+ * hint with zero effect on outputs, so it's stripped — tuning a
183
+ * reservation never busts a cache. A config that declares none takes the
184
+ * fast path and stringifies byte-identically to before the field existed
185
+ * (why this needs no CACHE_VERSION bump). `timeout`/`retries` stay folded
186
+ * — their keys are distinct by design (see the decision log); stripping
187
+ * them retroactively would bump CACHE_VERSION.
188
+ */
189
+ function hashableConfig(cfg: TaskConfig): unknown {
190
+ if (cfg.exec?.resources === undefined) return cfg
191
+ const { resources: _resources, ...execRest } = cfg.exec
192
+ return { ...cfg, exec: execRest }
178
193
  }
179
194
 
180
195
  /**
@@ -16,7 +16,7 @@
16
16
  // what or how tasks run. Contrast `backend`/`cache`, which return objects
17
17
  // core calls INTO; those are the behavior capabilities, kept separate.
18
18
 
19
- import type { TaskStatus, VerifyVerdict } from '../graph/index.js'
19
+ import type { OutputFingerprint, TaskStatus, VerifyVerdict } from '../graph/index.js'
20
20
  import type { RunEvent, RunEventSubscriber } from './events.js'
21
21
 
22
22
  /** Bumped when the record shape changes. Readers MUST check `v`. */
@@ -103,6 +103,13 @@ export interface TaskTelemetry {
103
103
  * unsound (its outputs aren't a pure function of its declared inputs); this
104
104
  * is the telemetry-side hermeticity signal a dashboard surfaces. */
105
105
  verify?: VerifyVerdict
106
+ /** Output-tree fingerprint — set ONLY under a fingerprinting `--verify*`
107
+ * mode (`--verify` / `=all` / `=fingerprint`), executed tasks only. A
108
+ * connected serve pairs fingerprints for the SAME `hash` across platforms
109
+ * and names diverging outputs (the cross-machine diff). Absent on plain
110
+ * runs — additive-optional, no schema bump (the `attempts`/`verify`
111
+ * precedent). */
112
+ outputFp?: OutputFingerprint
106
113
  /** bigint hrtime ns relative to run t=0, encoded as a decimal string. */
107
114
  wallclockStartNs?: string
108
115
  wallclockEndNs?: string
@@ -304,6 +311,7 @@ export function createTelemetrySource(args: {
304
311
  if (outcome.peakRssBytes !== undefined) rec.peakRssBytes = outcome.peakRssBytes
305
312
  if (outcome.attempts !== undefined) rec.attempts = outcome.attempts
306
313
  if (outcome.verify !== undefined) rec.verify = outcome.verify
314
+ if (outcome.outputFp !== undefined) rec.outputFp = outcome.outputFp
307
315
  if (outcome.wallclockStartNs !== undefined)
308
316
  rec.wallclockStartNs = outcome.wallclockStartNs.toString()
309
317
  if (outcome.wallclockEndNs !== undefined)
@@ -1,5 +1,7 @@
1
1
  import {
2
2
  DependencySpecError,
3
+ compileTaskPattern,
4
+ isTaskPattern,
3
5
  parseDependencySpec,
4
6
  type DependencySpec,
5
7
  type TaskOutcome,
@@ -22,6 +24,11 @@ import { UserError } from '../util/index.js'
22
24
  * 'name' same-project task `name`
23
25
  * '^name' `name` task in every dep workspace
24
26
  * 'pkg#name' specific package's `name` task
27
+ * 'name.*' task-NAME patterns — the task half of any form above may
28
+ * contain `*` (same glob as dependsOn patterns). A filter
29
+ * that matched literally here while dependsOn expanded the
30
+ * same string would silently select ZERO upstream hashes —
31
+ * a stale-hit trap, so the two surfaces share the matcher.
25
32
  * '!<form>' exclude — any of the above with a leading `!`
26
33
  *
27
34
  * Patterns are applied in order; last write wins, so
@@ -62,14 +69,19 @@ export function filterUpstreamHashes(
62
69
  }
63
70
  })
64
71
 
72
+ // Per-spec task-name matcher, compiled once (exact compare or pattern).
73
+ const matchers = specs.map((spec) => taskMatcher(spec))
74
+
65
75
  // Dedup by hash (the key fold's unit), but remember the first task id
66
76
  // seen for each hash so the diff row can name the upstream.
67
77
  const selected = new Map<string, string>()
68
- for (const spec of specs) {
78
+ for (let i = 0; i < specs.length; i++) {
79
+ const spec = specs[i]!
80
+ const taskMatches = matchers[i]!
69
81
  for (const u of upstream) {
70
82
  if (!u.hash) continue
71
83
  const isSelf = u.node.projectName === selfProjectName
72
- if (!matches(spec, u, isSelf)) continue
84
+ if (!matches(spec, u, isSelf, taskMatches)) continue
73
85
  if (spec.negated) selected.delete(u.hash)
74
86
  else if (!selected.has(u.hash)) selected.set(u.hash, u.node.id)
75
87
  }
@@ -77,17 +89,33 @@ export function filterUpstreamHashes(
77
89
  return [...selected].map(([hash, id]) => [id, hash])
78
90
  }
79
91
 
80
- function matches(spec: DependencySpec, u: TaskOutcome, isSelf: boolean): boolean {
92
+ /** Exact-name compare, or the shared `*`-glob when the form is a pattern. */
93
+ function taskMatcher(spec: DependencySpec): (name: string) => boolean {
94
+ if (spec.kind === 'wildcardSelf' || spec.kind === 'wildcardDeps') return () => true
95
+ if (isTaskPattern(spec.task)) {
96
+ const re = compileTaskPattern(spec.task)
97
+ return (name) => re.test(name)
98
+ }
99
+ const exact = spec.task
100
+ return (name) => name === exact
101
+ }
102
+
103
+ function matches(
104
+ spec: DependencySpec,
105
+ u: TaskOutcome,
106
+ isSelf: boolean,
107
+ taskMatches: (name: string) => boolean,
108
+ ): boolean {
81
109
  switch (spec.kind) {
82
110
  case 'wildcardSelf':
83
111
  return isSelf
84
112
  case 'wildcardDeps':
85
113
  return !isSelf
86
114
  case 'self':
87
- return isSelf && u.node.taskName === spec.task
115
+ return isSelf && taskMatches(u.node.taskName)
88
116
  case 'deps':
89
- return !isSelf && u.node.taskName === spec.task
117
+ return !isSelf && taskMatches(u.node.taskName)
90
118
  case 'cross':
91
- return u.node.projectName === spec.project && u.node.taskName === spec.task
119
+ return u.node.projectName === spec.project && taskMatches(u.node.taskName)
92
120
  }
93
121
  }
@@ -6,8 +6,8 @@
6
6
  // See docs/design/cache-correctness-2026-07.md.
7
7
 
8
8
  import path from 'node:path'
9
- import type { CacheLayer } from '../cache/index.js'
10
- import type { TaskOutcome, VerifyVerdict } from '../graph/index.js'
9
+ import type { OutputFingerprint, TaskOutcome, VerifyVerdict } from '../graph/index.js'
10
+ import { xxh3hex } from '../util/index.js'
11
11
 
12
12
  /** An output file to fingerprint: absolute path + a stable, run-independent
13
13
  * key (project-relative, or `workspace-outputs/<rel-to-root>` for workspace
@@ -35,19 +35,42 @@ export function outputRefs(
35
35
  ]
36
36
  }
37
37
 
38
- /** Content-fingerprint an output tree: key → git-blob OID. Reuses the same
39
- * `Cache.hashFile` primitive the input hashing folds, so it's byte-true and
40
- * mtime-independent — two byte-identical trees produce equal fingerprints
41
- * even though their tar.zst artifacts (which embed mtimes) would differ. */
42
- export async function hashOutputTree(
43
- cache: CacheLayer,
44
- refs: readonly OutputRef[],
45
- ): Promise<Map<string, string>> {
38
+ /** Content-fingerprint an output tree: key → xxh3 of the file's BYTES, read
39
+ * unconditionally from disk. Deliberately NOT `Cache.hashFile`: its
40
+ * mtime+size memo would return attempt 1's digest for a re-run output with
41
+ * equal size and mtime (e.g. a build that normalizes mtimes — standard
42
+ * reproducible-build practice), silently proving a divergent task
43
+ * deterministic. fp1/fp2 are only ever compared to each other, so any
44
+ * content hash works — and the proof must not trust a cache. */
45
+ export async function hashOutputTree(refs: readonly OutputRef[]): Promise<Map<string, string>> {
46
46
  const out = new Map<string, string>()
47
- for (const r of refs) out.set(r.key, await cache.hashFile(r.abs))
47
+ for (const r of refs) {
48
+ out.set(r.key, xxh3hex(new Uint8Array(await Bun.file(r.abs).arrayBuffer())))
49
+ }
48
50
  return out
49
51
  }
50
52
 
53
+ /** Per-file map cap on a shipped fingerprint (~40 KB at the cap). The tree
54
+ * digest always folds ALL entries, so detection never depends on the map. */
55
+ export const FP_MAX_FILES = 500
56
+
57
+ /** Roll an output-tree fingerprint map into the shippable `OutputFingerprint`
58
+ * payload (Phase 4, cross-machine diff): a tree digest over ALL sorted
59
+ * entries folded `key\0hash\n` (\0 boundaries so parts can't alias), plus
60
+ * the first-`cap` pairs — deterministic truncation, so two machines'
61
+ * truncated maps cover the same key subset and stay diffable. */
62
+ export function foldFingerprint(fp: Map<string, string>, cap = FP_MAX_FILES): OutputFingerprint {
63
+ const keys = [...fp.keys()].sort()
64
+ let folded = ''
65
+ for (const k of keys) folded += `${k}\0${fp.get(k)!}\n`
66
+ return {
67
+ tree: xxh3hex(folded),
68
+ fileCount: keys.length,
69
+ files: keys.slice(0, cap).map((k) => [k, fp.get(k)!] as const),
70
+ ...(keys.length > cap ? { truncated: true } : {}),
71
+ }
72
+ }
73
+
51
74
  /** The output keys whose content OID differs between two fingerprints
52
75
  * (added, removed, or changed), sorted. */
53
76
  export function diffOutputTrees(a: Map<string, string>, b: Map<string, string>): string[] {
package/src/util/index.ts CHANGED
@@ -4,4 +4,5 @@
4
4
  export { UserError } from './errors.js'
5
5
  export { xxh3, xxh3hex } from './hash.js'
6
6
  export { relPosix } from './paths.js'
7
+ export { parseSize } from './size.js'
7
8
  export { ulid } from './ulid.js'
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Parse a byte-size string: a bare integer (`"1048576"`) or an integer
3
+ * with a K/M/G/T suffix, powers of 1024, optional trailing `B`
4
+ * (`"512MB"`, `"8G"`). Returns `null` for anything else — including
5
+ * fractional sizes (`"1.5GB"`); callers wanting fractions must express
6
+ * them in a smaller unit.
7
+ */
8
+ export function parseSize(input: string): number | null {
9
+ const m = input.match(/^(\d+)([KMGT])?B?$/i)
10
+ if (!m) return null
11
+ const n = Number(m[1])
12
+ const u = (m[2] ?? '').toUpperCase()
13
+ const mult =
14
+ u === '' ? 1 : u === 'K' ? 1024 : u === 'M' ? 1024 * 1024 : u === 'G' ? 1024 ** 3 : 1024 ** 4
15
+ return n * mult
16
+ }
@@ -1,6 +1,6 @@
1
1
  import path from 'node:path'
2
2
  import type { ProjectConfig, WorkspaceConfig } from '../config.js'
3
- import { UserError, xxh3hex } from '../util/index.js'
3
+ import { parseSize, UserError, xxh3hex } from '../util/index.js'
4
4
 
5
5
  const WORKSPACE_CONFIG_FILENAMES = [
6
6
  'vx.workspace.ts',
@@ -187,6 +187,10 @@ export function validateProjectConfig(config: ProjectConfig, configPath: string)
187
187
  throw new UserError(`${where}.exec.retries must be a non-negative integer`)
188
188
  }
189
189
  }
190
+ const resources = (exec as { resources?: unknown }).resources
191
+ if (resources !== undefined) {
192
+ validateResources(resources, `${where}.exec.resources`)
193
+ }
190
194
  const persistent = (exec as { persistent?: unknown }).persistent
191
195
  if (persistent !== undefined) {
192
196
  if (typeof persistent !== 'object' || persistent === null) {
@@ -454,6 +458,44 @@ function validateSandbox(sandbox: unknown, where: string, hasExec: boolean): voi
454
458
  }
455
459
  }
456
460
 
461
+ const RESOURCES_FIELDS = new Set(['cpus', 'memory'])
462
+ const PERCENT_RE = /^\d+(\.\d+)?%$/
463
+
464
+ function validateResources(resources: unknown, where: string): void {
465
+ if (typeof resources !== 'object' || resources === null || Array.isArray(resources)) {
466
+ throw new UserError(`${where} must be an object (e.g. \`{ cpus: 2, memory: '2GB' }\`)`)
467
+ }
468
+ for (const key of Object.keys(resources as object)) {
469
+ if (!RESOURCES_FIELDS.has(key)) {
470
+ throw new UserError(
471
+ `${where} has unknown field "${key}". Allowed: ${[...RESOURCES_FIELDS].sort().join(', ')}`,
472
+ )
473
+ }
474
+ }
475
+ const { cpus, memory } = resources as { cpus?: unknown; memory?: unknown }
476
+ if (cpus !== undefined) {
477
+ const ok =
478
+ typeof cpus === 'number'
479
+ ? Number.isFinite(cpus) && cpus >= 0
480
+ : typeof cpus === 'string' && PERCENT_RE.test(cpus)
481
+ if (!ok) {
482
+ throw new UserError(`${where}.cpus must be a non-negative number or a "<n>%" string`)
483
+ }
484
+ }
485
+ if (memory !== undefined) {
486
+ const ok =
487
+ typeof memory === 'number'
488
+ ? Number.isInteger(memory) && memory >= 0
489
+ : typeof memory === 'string' && (PERCENT_RE.test(memory) || parseSize(memory) !== null)
490
+ if (!ok) {
491
+ throw new UserError(
492
+ `${where}.memory must be a non-negative integer (bytes), ` +
493
+ `a size string like "512MB", or a "<n>%" string`,
494
+ )
495
+ }
496
+ }
497
+ }
498
+
457
499
  function assertStringArray(v: unknown, where: string): void {
458
500
  if (!Array.isArray(v) || v.some((s) => typeof s !== 'string' || s.length === 0)) {
459
501
  throw new UserError(`${where} must be an array of non-empty strings`)