@vzn/vx 0.0.16 → 0.0.21

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 (138) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +135 -124
  3. package/index.ts +10 -0
  4. package/launcher.mjs +10 -11
  5. package/package.json +11 -7
  6. package/src/bin.ts +14 -4
  7. package/src/cache/archive.ts +659 -0
  8. package/src/cache/cache.ts +769 -1101
  9. package/src/cache/chained-cache.ts +194 -0
  10. package/src/cache/config-evals.ts +89 -0
  11. package/src/cache/file-hashes.ts +262 -0
  12. package/src/cache/git-inputs.ts +637 -0
  13. package/src/cache/index.ts +24 -5
  14. package/src/cache/inputs.ts +373 -456
  15. package/src/cache/layer.ts +604 -0
  16. package/src/cache/layered-cache.ts +96 -43
  17. package/src/cache/output-index.ts +224 -0
  18. package/src/cache/policy.ts +84 -0
  19. package/src/cache/run-history.ts +147 -0
  20. package/src/cache/tar-stream.ts +358 -0
  21. package/src/cache/zstd.ts +160 -0
  22. package/src/cli/cache.ts +75 -21
  23. package/src/cli/completions.ts +104 -0
  24. package/src/cli/core-alias.ts +25 -0
  25. package/src/cli/help.ts +195 -128
  26. package/src/cli/index.ts +116 -46
  27. package/src/cli/info.ts +122 -63
  28. package/src/cli/init.ts +75 -0
  29. package/src/cli/last.ts +172 -0
  30. package/src/cli/lock.ts +2 -1
  31. package/src/cli/plan-format.ts +72 -6
  32. package/src/cli/plugin-commands.ts +103 -0
  33. package/src/cli/run.ts +245 -271
  34. package/src/cli/select.ts +332 -0
  35. package/src/cli/show.ts +144 -69
  36. package/src/cli/upgrade.ts +9 -7
  37. package/src/cli/watch.ts +858 -110
  38. package/src/cli/why.ts +223 -0
  39. package/src/cli/workspace-config.ts +126 -0
  40. package/src/config.ts +187 -150
  41. package/src/exec/env.ts +18 -1
  42. package/src/exec/executor.ts +224 -0
  43. package/src/exec/index.ts +24 -2
  44. package/src/exec/local-executor.ts +41 -0
  45. package/src/exec/runner.ts +166 -40
  46. package/src/exec/sandbox-binds.ts +182 -0
  47. package/src/exec/sandbox-paths.ts +50 -0
  48. package/src/exec/sandbox-runtime.ts +642 -260
  49. package/src/exec/sandbox-violations.ts +280 -0
  50. package/src/graph/index.ts +4 -10
  51. package/src/graph/scheduler.ts +232 -203
  52. package/src/graph/task-graph.ts +222 -8
  53. package/src/index.ts +146 -153
  54. package/src/orchestrator/admission.ts +137 -0
  55. package/src/orchestrator/deferred-outputs.ts +147 -0
  56. package/src/orchestrator/doctor.ts +255 -0
  57. package/src/orchestrator/download-policy.ts +171 -0
  58. package/src/orchestrator/events.ts +72 -8
  59. package/src/orchestrator/execute-task.ts +374 -549
  60. package/src/orchestrator/failure-mode.ts +202 -0
  61. package/src/orchestrator/framed-output.ts +126 -69
  62. package/src/orchestrator/history.ts +126 -59
  63. package/src/orchestrator/hit-restore.ts +221 -0
  64. package/src/orchestrator/index.ts +77 -93
  65. package/src/orchestrator/local-shortcircuit.ts +22 -0
  66. package/src/orchestrator/lockfile-claim.ts +281 -0
  67. package/src/orchestrator/logger.ts +208 -35
  68. package/src/orchestrator/metrics.ts +102 -1388
  69. package/src/orchestrator/miss-save.ts +169 -0
  70. package/src/orchestrator/options.ts +55 -34
  71. package/src/orchestrator/persistent.ts +79 -0
  72. package/src/orchestrator/placement.ts +182 -0
  73. package/src/orchestrator/plan.ts +144 -1
  74. package/src/orchestrator/plugin-host.ts +357 -120
  75. package/src/orchestrator/plugin.ts +298 -58
  76. package/src/orchestrator/prepare.ts +202 -106
  77. package/src/orchestrator/projects.ts +259 -0
  78. package/src/orchestrator/remote-prefetch.ts +12 -8
  79. package/src/orchestrator/run-artifacts.ts +63 -16
  80. package/src/orchestrator/run-context.ts +167 -29
  81. package/src/orchestrator/run-records.ts +149 -0
  82. package/src/orchestrator/run-report.ts +74 -47
  83. package/src/orchestrator/run.ts +534 -417
  84. package/src/orchestrator/sandbox-request.ts +243 -0
  85. package/src/orchestrator/save-lane.ts +47 -0
  86. package/src/orchestrator/signals.ts +111 -0
  87. package/src/orchestrator/stable-keys.ts +129 -31
  88. package/src/orchestrator/status-line.ts +49 -9
  89. package/src/orchestrator/summary.ts +90 -26
  90. package/src/orchestrator/tally.ts +78 -35
  91. package/src/orchestrator/task-hash.ts +111 -15
  92. package/src/orchestrator/task-log-buffer.ts +288 -0
  93. package/src/orchestrator/telemetry-host.ts +34 -6
  94. package/src/orchestrator/telemetry.ts +182 -36
  95. package/src/orchestrator/upstream.ts +72 -31
  96. package/src/util/cgroup.ts +160 -0
  97. package/src/util/edit-distance.ts +70 -0
  98. package/src/util/errors.ts +15 -1
  99. package/src/util/index.ts +15 -2
  100. package/src/util/num.ts +37 -0
  101. package/src/util/paths.ts +64 -0
  102. package/src/util/settle.ts +67 -0
  103. package/src/util/size.ts +5 -1
  104. package/src/util/tail.ts +66 -0
  105. package/src/util/timing.ts +63 -0
  106. package/src/util/verbs.ts +39 -0
  107. package/src/workspace/affected.ts +250 -23
  108. package/src/workspace/config-cache.ts +313 -0
  109. package/src/workspace/config-eval.ts +217 -0
  110. package/src/workspace/config-imports.ts +162 -0
  111. package/src/workspace/config-schema.ts +837 -0
  112. package/src/workspace/filter.ts +65 -10
  113. package/src/workspace/fingerprint.ts +53 -4
  114. package/src/workspace/index.ts +42 -3
  115. package/src/workspace/lockfile.ts +25 -9
  116. package/src/workspace/migrate-scripts.ts +134 -0
  117. package/src/workspace/migration.ts +314 -0
  118. package/src/workspace/nested-dirs.ts +21 -7
  119. package/src/workspace/package-graph.ts +114 -41
  120. package/src/workspace/project-loader.ts +224 -450
  121. package/src/workspace/workspace.ts +298 -72
  122. package/src/cache/cas-backend.ts +0 -97
  123. package/src/cache/digest.ts +0 -47
  124. package/src/cache/tar.ts +0 -343
  125. package/src/cli/backend.ts +0 -23
  126. package/src/cli/mcp-rpc.ts +0 -292
  127. package/src/cli/mcp.ts +0 -92
  128. package/src/cli/migrate-nx.ts +0 -450
  129. package/src/cli/migrate-turbo.ts +0 -364
  130. package/src/cli/migrate.ts +0 -219
  131. package/src/orchestrator/devframe-surface.ts +0 -74
  132. package/src/orchestrator/predict.ts +0 -106
  133. package/src/orchestrator/protocol.ts +0 -156
  134. package/src/orchestrator/resources.ts +0 -59
  135. package/src/orchestrator/run-state.ts +0 -145
  136. package/src/orchestrator/verify.ts +0 -164
  137. package/src/orchestrator/wire-render.ts +0 -92
  138. package/src/orchestrator/wire.ts +0 -212
@@ -24,11 +24,12 @@ import { UserError } from '../util/index.js'
24
24
  * 'name' same-project task `name`
25
25
  * '^name' `name` task in every dep workspace
26
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.
27
+ * 'name.*' patterns — EITHER half of any form above may contain `*`
28
+ * (same glob as dependsOn patterns), including the project
29
+ * half of `pkg#name`. A filter that matched literally here
30
+ * would silently select ZERO upstream hashes and decouple the
31
+ * task from its dependencies — a stale-hit trap, so every
32
+ * name is matched through the shared glob.
32
33
  * '!<form>' exclude — any of the above with a leading `!`
33
34
  *
34
35
  * Patterns are applied in order; last write wins, so
@@ -69,19 +70,19 @@ export function filterUpstreamHashes(
69
70
  }
70
71
  })
71
72
 
72
- // Per-spec task-name matcher, compiled once (exact compare or pattern).
73
- const matchers = specs.map((spec) => taskMatcher(spec))
73
+ // Per-spec predicate, compiled once (exact compares + patterns).
74
+ const matchers = specs.map((spec) => specMatcher(spec))
74
75
 
75
76
  // Dedup by hash (the key fold's unit), but remember the first task id
76
77
  // seen for each hash so the diff row can name the upstream.
77
78
  const selected = new Map<string, string>()
78
79
  for (let i = 0; i < specs.length; i++) {
79
80
  const spec = specs[i]!
80
- const taskMatches = matchers[i]!
81
+ const matches = matchers[i]!
81
82
  for (const u of upstream) {
82
83
  if (!u.hash) continue
83
84
  const isSelf = u.node.projectName === selfProjectName
84
- if (!matches(spec, u, isSelf, taskMatches)) continue
85
+ if (!matches(u, isSelf)) continue
85
86
  if (spec.negated) selected.delete(u.hash)
86
87
  else if (!selected.has(u.hash)) selected.set(u.hash, u.node.id)
87
88
  }
@@ -89,33 +90,73 @@ export function filterUpstreamHashes(
89
90
  return [...selected].map(([hash, id]) => [id, hash])
90
91
  }
91
92
 
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)
93
+ /**
94
+ * Expand any GROUP among these outcomes into the real tasks it stands for,
95
+ * transitively, preserving order and de-duplicating by task id.
96
+ *
97
+ * A group has no `exec`, so it has no outputs and no cache entry — its hash
98
+ * is a synthetic roll-up. A dependent that asked the local index what its
99
+ * upstream produced therefore got an EMPTY output list for a group, which is
100
+ * invisible locally (the members' outputs are already on disk, put there by
101
+ * their own tasks) and fatal for an input-shipping executor, where that list
102
+ * IS the input root: `dependsOn: ['install']` shipped a worker an action with
103
+ * none of what `install` chains.
104
+ *
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
107
+ * `cache.inputs.tasks` brings no members with it.
108
+ */
109
+ export function expandGroupUpstream(upstream: readonly TaskOutcome[]): TaskOutcome[] {
110
+ const out: TaskOutcome[] = []
111
+ 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)
121
+ }
122
+ }
123
+ walk(upstream)
124
+ return out
125
+ }
126
+
127
+ /** Exact-name compare, or the shared `*`-glob when the name is a pattern. */
128
+ function nameMatcher(name: string): (candidate: string) => boolean {
129
+ if (isTaskPattern(name)) {
130
+ const re = compileTaskPattern(name)
131
+ return (candidate) => re.test(candidate)
98
132
  }
99
- const exact = spec.task
100
- return (name) => name === exact
133
+ return (candidate) => candidate === name
101
134
  }
102
135
 
103
- function matches(
104
- spec: DependencySpec,
105
- u: TaskOutcome,
106
- isSelf: boolean,
107
- taskMatches: (name: string) => boolean,
108
- ): boolean {
136
+ /**
137
+ * Compile one spec into an upstream predicate. BOTH halves of a `pkg#task`
138
+ * form glob: a filter only ever selects from upstreams that already exist,
139
+ * so a package pattern is unambiguous here — unlike dependsOn, which must
140
+ * materialize concrete edges and therefore rejects it.
141
+ */
142
+ function specMatcher(spec: DependencySpec): (u: TaskOutcome, isSelf: boolean) => boolean {
109
143
  switch (spec.kind) {
110
144
  case 'wildcardSelf':
111
- return isSelf
145
+ return (_u, isSelf) => isSelf
112
146
  case 'wildcardDeps':
113
- return !isSelf
114
- case 'self':
115
- return isSelf && taskMatches(u.node.taskName)
116
- case 'deps':
117
- return !isSelf && taskMatches(u.node.taskName)
118
- case 'cross':
119
- return u.node.projectName === spec.project && taskMatches(u.node.taskName)
147
+ return (_u, isSelf) => !isSelf
148
+ case 'self': {
149
+ const task = nameMatcher(spec.task)
150
+ return (u, isSelf) => isSelf && task(u.node.taskName)
151
+ }
152
+ case 'deps': {
153
+ const task = nameMatcher(spec.task)
154
+ return (u, isSelf) => !isSelf && task(u.node.taskName)
155
+ }
156
+ case 'cross': {
157
+ const project = nameMatcher(spec.project)
158
+ const task = nameMatcher(spec.task)
159
+ return (u) => project(u.node.projectName) && task(u.node.taskName)
160
+ }
120
161
  }
121
162
  }
@@ -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
+ }
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Levenshtein distance, capped: anything past two edits reads as 3. Used
3
+ * by the "did you mean" hints for task names and flags, where a hint
4
+ * beyond two edits would guess rather than help.
5
+ */
6
+ export function editDistance(a: string, b: string): number {
7
+ if (Math.abs(a.length - b.length) > 2) return 3
8
+ let prev = Array.from({ length: b.length + 1 }, (_, i) => i)
9
+ for (let i = 1; i <= a.length; i++) {
10
+ const cur = [i]
11
+ for (let j = 1; j <= b.length; j++) {
12
+ cur[j] = Math.min(
13
+ prev[j]! + 1,
14
+ cur[j - 1]! + 1,
15
+ prev[j - 1]! + (a[i - 1] === b[j - 1] ? 0 : 1),
16
+ )
17
+ }
18
+ prev = cur
19
+ }
20
+ return Math.min(prev[b.length]!, 3)
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
+ }
@@ -1,7 +1,7 @@
1
1
  // Shared error types used across modules to distinguish user-input
2
2
  // failures (clean message only) from internal bugs (full stack trace).
3
3
  //
4
- // `bin.ts` consults `instanceof UserError` to decide what to print.
4
+ // `bin.ts` and the scheduler consult `isUserError` to decide what to print.
5
5
 
6
6
  export class UserError extends Error {
7
7
  constructor(message: string) {
@@ -9,3 +9,17 @@ export class UserError extends Error {
9
9
  this.name = 'UserError'
10
10
  }
11
11
  }
12
+
13
+ /**
14
+ * `instanceof UserError`, plus the same class arriving from ANOTHER COPY of
15
+ * core. A compiled `vx` binary carries core inside it while a plugin in the
16
+ * workspace imports `@vzn/vx` from node_modules, so a plugin's `UserError`
17
+ * is a different class object and `instanceof` is false — a plugin verb's
18
+ * "bad flag --x" printed as `UserError: bad flag --x` with a stack, and a
19
+ * REAPI refusal would have read as an "internal error" (reproduced through
20
+ * the real binary, 2026-09-03). The name is the contract that survives the
21
+ * copy boundary.
22
+ */
23
+ export function isUserError(err: unknown): err is UserError {
24
+ return err instanceof UserError || (err instanceof Error && err.name === 'UserError')
25
+ }
package/src/util/index.ts CHANGED
@@ -1,8 +1,21 @@
1
1
  // Module contract. Cross-module imports must come through here; see
2
2
  // docs/design/module-isolation-2026-06.md and tests/module-boundaries.test.ts.
3
3
 
4
- export { UserError } from './errors.js'
4
+ export { isUserError, UserError } from './errors.js'
5
5
  export { xxh3, xxh3hex } from './hash.js'
6
- export { relPosix } from './paths.js'
6
+ export { mark, printTimings, span } from './timing.js'
7
+ export { clampInt, MAX_TIMEOUT_MS, parseDecimalInt } from './num.js'
8
+ export { normalizeGlob, relPosix, staticPrefix, wholeSubtreePrefixes } from './paths.js'
9
+ export { killGraceMs, settleWithin, teardownTimeoutMs } from './settle.js'
7
10
  export { parseSize } from './size.js'
11
+ export {
12
+ cgroupCpuQuota,
13
+ cgroupMemoryLimitBytes,
14
+ machineMemoryBytes,
15
+ machineParallelism,
16
+ type CgroupProbe,
17
+ } from './cgroup.js'
18
+ export { appendTail, createTail, resetTail, tailText, type Tail } from './tail.js'
8
19
  export { ulid } from './ulid.js'
20
+ export { editDistance, nearMatches, nearest } from './edit-distance.js'
21
+ export { CORE_VERBS, DISPATCHED_VERBS, MOVED_VERBS } from './verbs.js'
@@ -0,0 +1,37 @@
1
+ /**
2
+ * The largest delay `setTimeout` actually honours: 2^31 - 1 ms (~24.8 days).
3
+ *
4
+ * A larger delay does NOT saturate and does NOT throw — it silently becomes
5
+ * **1 ms**, which is the exact INVERSE of what the caller asked for. A task
6
+ * declaring a 317-year timeout is SIGTERMed 4 ms after it spawns and reported
7
+ * `failed`, and the only clue is a `TimeoutOverflowWarning` on stderr that a CI
8
+ * log swallows. Every surface that accepts a millisecond delay bounds it
9
+ * against this, so "effectively no limit" can never mean "kill immediately".
10
+ */
11
+ export const MAX_TIMEOUT_MS = 2 ** 31 - 1
12
+
13
+ /**
14
+ * Clamp to an INTEGER in `[min, max]`; a non-finite value collapses to `min`.
15
+ *
16
+ * The floor is load-bearing wherever the result reaches SQL: a fractional
17
+ * `LIMIT` is a `datatype mismatch` error, not a smaller page.
18
+ */
19
+ export function clampInt(n: number, min: number, max: number): number {
20
+ if (!Number.isFinite(n)) return min
21
+ return Math.min(max, Math.max(min, Math.floor(n)))
22
+ }
23
+
24
+ /**
25
+ * Parse a PLAIN decimal integer from user input; `null` for anything else.
26
+ *
27
+ * `Number()` is the wrong tool at an argument boundary: it silently accepts
28
+ * hex (`0x10` → 16), exponents (`1e3` → 1000), fractions, a leading `+`, and
29
+ * surrounding whitespace — so a typo becomes a different number instead of an
30
+ * error. Values past `Number.MAX_SAFE_INTEGER` are rejected too: they parse to
31
+ * a number the user did not type (`9007199254740993` → `…92`).
32
+ */
33
+ export function parseDecimalInt(input: string): number | null {
34
+ if (!/^\d+$/.test(input)) return null
35
+ const n = Number(input)
36
+ return Number.isSafeInteger(n) ? n : null
37
+ }
package/src/util/paths.ts CHANGED
@@ -14,3 +14,67 @@ export function toPosix(p: string): string {
14
14
  export function relPosix(from: string, to: string): string {
15
15
  return toPosix(path.relative(from, to))
16
16
  }
17
+
18
+ /**
19
+ * The leading, wildcard-free part of a glob, trimmed back to whole path
20
+ * components (`dist/sub-**` → `dist`). `.` when the first component is
21
+ * already a wildcard, i.e. the glob can reach anything in its anchor dir.
22
+ *
23
+ * Two callers with different needs share it deliberately: the sandbox
24
+ * baseline joins it onto a dir to get a write prefix, and the deferral
25
+ * eligibility gate compares two of them for overlap. A second copy is
26
+ * how the two would disagree about what a prefix is.
27
+ */
28
+ export function staticPrefix(glob: string): string {
29
+ // A brace set is a wildcard too: `{dist,build}/**` reaches either dir,
30
+ // and reading it as the literal directory `{dist,build}` gave the
31
+ // sandbox baseline a prefix that exists nowhere (2026-09-10).
32
+ const wildcardIdx = glob.search(/[*?[\]{}]/)
33
+ if (wildcardIdx === -1) return glob
34
+ const head = glob.slice(0, wildcardIdx)
35
+ const lastSep = head.lastIndexOf('/')
36
+ if (lastSep === -1) return '.'
37
+ return head.slice(0, lastSep) || '/'
38
+ }
39
+
40
+ /**
41
+ * The directories a task's declared outputs cover WHOLE — every glob is
42
+ * `<dir>/**` with a plain, non-root, non-escaping `<dir>` — or `null` when
43
+ * any glob is shaped otherwise. The directory-mtime short-circuit on a warm
44
+ * hit (`Cache.outputDirsCurrent`) is sound only for whole subtrees: with
45
+ * every directory under `<dir>` recorded, a file added or removed anywhere
46
+ * the glob could see bumps a recorded directory's mtime (its parent, or a
47
+ * new directory whose creation bumped a recorded ancestor). A root-anchored
48
+ * `**\/*.js` has no such closed set, so it keeps the walk.
49
+ */
50
+ export function wholeSubtreePrefixes(globs: readonly string[]): string[] | null {
51
+ if (globs.length === 0) return null
52
+ const out: string[] = []
53
+ for (const g of globs.map(normalizeGlob)) {
54
+ const m = /^([^*?[\]{}!]+?)\/\*\*$/.exec(g)
55
+ if (m === null) return null
56
+ const dir = m[1]!.replace(/\/+$/, '')
57
+ if (dir === '' || dir === '.' || dir.startsWith('/') || dir.split('/').includes('..'))
58
+ return null
59
+ out.push(dir)
60
+ }
61
+ return [...new Set(out)]
62
+ }
63
+
64
+ /**
65
+ * The spellings a reader, Turbo and `.gitignore` all accept but a matcher
66
+ * fed the raw string turns into NOTHING — and a task keyed on nothing
67
+ * replays old outputs as a green hit (2026-09-10, probed one by one):
68
+ * a leading `./`, an inner `/./` segment, a doubled `//`, and a trailing
69
+ * `/` on a pattern (`src/*\/` means the trees under `src`, so it becomes
70
+ * `src/*\/**`; a trailing slash on a LITERAL is `asTrees`' job). Applied
71
+ * after an optional `!`; a bare `.` is the empty entry the schema refuses.
72
+ */
73
+ export function normalizeGlob(glob: string): string {
74
+ const neg = glob.startsWith('!')
75
+ let g = neg ? glob.slice(1) : glob
76
+ g = g.replace(/\/{2,}/g, '/').replace(/(^|\/)(\.\/)+/g, '$1')
77
+ if (g === '.') g = ''
78
+ if (/[*?[\]{}]/.test(g) && g.endsWith('/')) g = `${g.replace(/\/+$/, '')}/**`
79
+ return neg ? `!${g}` : g
80
+ }
@@ -0,0 +1,67 @@
1
+ // End-of-run settle bound. A plugin's flush/teardown is I/O a third party
2
+ // wrote; it must not hold the run's exit hostage.
3
+
4
+ import { MAX_TIMEOUT_MS } from './num.js'
5
+
6
+ /** Default upper bound on one end-of-run flush/teardown await. */
7
+ const DEFAULT_TIMEOUT_MS = 3000
8
+
9
+ /**
10
+ * The bound to apply, read per call so a test can drive the deadline
11
+ * instead of waiting it out.
12
+ */
13
+ export function teardownTimeoutMs(): number {
14
+ const raw = process.env['VX_TEARDOWN_TIMEOUT_MS']
15
+ // Out-of-range falls back to the DEFAULT rather than clamping to
16
+ // MAX_TIMEOUT_MS, and the distinction matters. This value is a BOUND, not a
17
+ // duration: there is no "no limit" option here, because the whole reason the
18
+ // deadline exists is that a plugin's flush must not hold the run's exit
19
+ // hostage. Clamping would honour "wait ~24.8 days", which defeats it — the
20
+ // run would hang. Unbounded is worse still (the delay becomes 1 ms, so every
21
+ // flush times out and every buffered record is dropped). Falling back keeps
22
+ // the bound real in both directions.
23
+ if (raw !== undefined && /^[0-9]+$/.test(raw)) {
24
+ const n = Number(raw)
25
+ if (n <= MAX_TIMEOUT_MS) return n
26
+ }
27
+ return DEFAULT_TIMEOUT_MS
28
+ }
29
+
30
+ /**
31
+ * The SIGTERM→SIGKILL grace a run grants a child that ignores SIGTERM: the
32
+ * one-shot timeout escalation (`exec/runner.ts`) and the end-of-run
33
+ * persistent shutdown (`orchestrator/persistent.ts`) share it. Two seconds
34
+ * is generous for a real server's cleanup and is what every run waits when
35
+ * a child wedges. `VX_KILL_GRACE_MS` overrides it, read per call and bounded
36
+ * like the teardown deadline (out of range falls back to the default): the
37
+ * tests that prove the escalation used to wait the full two seconds each,
38
+ * a third of the suite's wall time for a claim a 200 ms grace proves too.
39
+ */
40
+ export function killGraceMs(defaultMs: number): number {
41
+ const raw = process.env['VX_KILL_GRACE_MS']
42
+ if (raw !== undefined && /^[0-9]+$/.test(raw)) {
43
+ const n = Number(raw)
44
+ if (n > 0 && n <= MAX_TIMEOUT_MS) return n
45
+ }
46
+ return defaultMs
47
+ }
48
+
49
+ /**
50
+ * Await `p`, giving up after `ms`. Returns true when `p` settled first,
51
+ * false when the deadline won — the caller decides whether a lost result
52
+ * is worth reporting.
53
+ */
54
+ export async function settleWithin(p: Promise<unknown>, ms: number): Promise<boolean> {
55
+ let timer: ReturnType<typeof setTimeout> | undefined
56
+ const deadline = new Promise<false>((resolve) => {
57
+ timer = setTimeout(() => resolve(false), ms)
58
+ })
59
+ try {
60
+ return (await Promise.race([p.then(() => true), deadline])) !== false
61
+ } finally {
62
+ clearTimeout(timer)
63
+ // A rejection landing after the deadline won already resolved the
64
+ // race must not surface as an unhandled-rejection crash.
65
+ void p.catch(() => {})
66
+ }
67
+ }
package/src/util/size.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import { parseDecimalInt } from './num.js'
2
+
1
3
  /**
2
4
  * Parse a byte-size string: a bare integer (`"1048576"`) or an integer
3
5
  * with a K/M/G/T suffix, powers of 1024, optional trailing `B`
@@ -8,7 +10,9 @@
8
10
  export function parseSize(input: string): number | null {
9
11
  const m = input.match(/^(\d+)([KMGT])?B?$/i)
10
12
  if (!m) return null
11
- const n = Number(m[1])
13
+ // Digits past 2^53 parse to a different number than the user typed.
14
+ const n = parseDecimalInt(m[1]!)
15
+ if (n === null) return null
12
16
  const u = (m[2] ?? '').toUpperCase()
13
17
  const mult =
14
18
  u === '' ? 1 : u === 'K' ? 1024 : u === 'M' ? 1024 * 1024 : u === 'G' ? 1024 ** 3 : 1024 ** 4