@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.
- package/LICENSE +1 -1
- package/README.md +135 -124
- package/index.ts +10 -0
- package/launcher.mjs +10 -11
- package/package.json +11 -7
- package/src/bin.ts +14 -4
- package/src/cache/archive.ts +659 -0
- package/src/cache/cache.ts +769 -1101
- package/src/cache/chained-cache.ts +194 -0
- package/src/cache/config-evals.ts +89 -0
- package/src/cache/file-hashes.ts +262 -0
- package/src/cache/git-inputs.ts +637 -0
- package/src/cache/index.ts +24 -5
- package/src/cache/inputs.ts +373 -456
- package/src/cache/layer.ts +604 -0
- package/src/cache/layered-cache.ts +96 -43
- package/src/cache/output-index.ts +224 -0
- package/src/cache/policy.ts +84 -0
- package/src/cache/run-history.ts +147 -0
- package/src/cache/tar-stream.ts +358 -0
- package/src/cache/zstd.ts +160 -0
- package/src/cli/cache.ts +75 -21
- package/src/cli/completions.ts +104 -0
- package/src/cli/core-alias.ts +25 -0
- package/src/cli/help.ts +195 -128
- package/src/cli/index.ts +116 -46
- package/src/cli/info.ts +122 -63
- package/src/cli/init.ts +75 -0
- package/src/cli/last.ts +172 -0
- package/src/cli/lock.ts +2 -1
- package/src/cli/plan-format.ts +72 -6
- package/src/cli/plugin-commands.ts +103 -0
- package/src/cli/run.ts +245 -271
- package/src/cli/select.ts +332 -0
- package/src/cli/show.ts +144 -69
- package/src/cli/upgrade.ts +9 -7
- package/src/cli/watch.ts +858 -110
- package/src/cli/why.ts +223 -0
- package/src/cli/workspace-config.ts +126 -0
- package/src/config.ts +187 -150
- package/src/exec/env.ts +18 -1
- package/src/exec/executor.ts +224 -0
- package/src/exec/index.ts +24 -2
- package/src/exec/local-executor.ts +41 -0
- package/src/exec/runner.ts +166 -40
- package/src/exec/sandbox-binds.ts +182 -0
- package/src/exec/sandbox-paths.ts +50 -0
- package/src/exec/sandbox-runtime.ts +642 -260
- package/src/exec/sandbox-violations.ts +280 -0
- package/src/graph/index.ts +4 -10
- package/src/graph/scheduler.ts +232 -203
- package/src/graph/task-graph.ts +222 -8
- package/src/index.ts +146 -153
- package/src/orchestrator/admission.ts +137 -0
- package/src/orchestrator/deferred-outputs.ts +147 -0
- package/src/orchestrator/doctor.ts +255 -0
- package/src/orchestrator/download-policy.ts +171 -0
- package/src/orchestrator/events.ts +72 -8
- package/src/orchestrator/execute-task.ts +374 -549
- package/src/orchestrator/failure-mode.ts +202 -0
- package/src/orchestrator/framed-output.ts +126 -69
- package/src/orchestrator/history.ts +126 -59
- package/src/orchestrator/hit-restore.ts +221 -0
- package/src/orchestrator/index.ts +77 -93
- package/src/orchestrator/local-shortcircuit.ts +22 -0
- package/src/orchestrator/lockfile-claim.ts +281 -0
- package/src/orchestrator/logger.ts +208 -35
- package/src/orchestrator/metrics.ts +102 -1388
- package/src/orchestrator/miss-save.ts +169 -0
- package/src/orchestrator/options.ts +55 -34
- package/src/orchestrator/persistent.ts +79 -0
- package/src/orchestrator/placement.ts +182 -0
- package/src/orchestrator/plan.ts +144 -1
- package/src/orchestrator/plugin-host.ts +357 -120
- package/src/orchestrator/plugin.ts +298 -58
- package/src/orchestrator/prepare.ts +202 -106
- package/src/orchestrator/projects.ts +259 -0
- package/src/orchestrator/remote-prefetch.ts +12 -8
- package/src/orchestrator/run-artifacts.ts +63 -16
- package/src/orchestrator/run-context.ts +167 -29
- package/src/orchestrator/run-records.ts +149 -0
- package/src/orchestrator/run-report.ts +74 -47
- package/src/orchestrator/run.ts +534 -417
- package/src/orchestrator/sandbox-request.ts +243 -0
- package/src/orchestrator/save-lane.ts +47 -0
- package/src/orchestrator/signals.ts +111 -0
- package/src/orchestrator/stable-keys.ts +129 -31
- package/src/orchestrator/status-line.ts +49 -9
- package/src/orchestrator/summary.ts +90 -26
- package/src/orchestrator/tally.ts +78 -35
- package/src/orchestrator/task-hash.ts +111 -15
- package/src/orchestrator/task-log-buffer.ts +288 -0
- package/src/orchestrator/telemetry-host.ts +34 -6
- package/src/orchestrator/telemetry.ts +182 -36
- package/src/orchestrator/upstream.ts +72 -31
- package/src/util/cgroup.ts +160 -0
- package/src/util/edit-distance.ts +70 -0
- package/src/util/errors.ts +15 -1
- package/src/util/index.ts +15 -2
- package/src/util/num.ts +37 -0
- package/src/util/paths.ts +64 -0
- package/src/util/settle.ts +67 -0
- package/src/util/size.ts +5 -1
- package/src/util/tail.ts +66 -0
- package/src/util/timing.ts +63 -0
- package/src/util/verbs.ts +39 -0
- package/src/workspace/affected.ts +250 -23
- package/src/workspace/config-cache.ts +313 -0
- package/src/workspace/config-eval.ts +217 -0
- package/src/workspace/config-imports.ts +162 -0
- package/src/workspace/config-schema.ts +837 -0
- package/src/workspace/filter.ts +65 -10
- package/src/workspace/fingerprint.ts +53 -4
- package/src/workspace/index.ts +42 -3
- package/src/workspace/lockfile.ts +25 -9
- package/src/workspace/migrate-scripts.ts +134 -0
- package/src/workspace/migration.ts +314 -0
- package/src/workspace/nested-dirs.ts +21 -7
- package/src/workspace/package-graph.ts +114 -41
- package/src/workspace/project-loader.ts +224 -450
- package/src/workspace/workspace.ts +298 -72
- package/src/cache/cas-backend.ts +0 -97
- package/src/cache/digest.ts +0 -47
- package/src/cache/tar.ts +0 -343
- package/src/cli/backend.ts +0 -23
- package/src/cli/mcp-rpc.ts +0 -292
- package/src/cli/mcp.ts +0 -92
- package/src/cli/migrate-nx.ts +0 -450
- package/src/cli/migrate-turbo.ts +0 -364
- package/src/cli/migrate.ts +0 -219
- package/src/orchestrator/devframe-surface.ts +0 -74
- package/src/orchestrator/predict.ts +0 -106
- package/src/orchestrator/protocol.ts +0 -156
- package/src/orchestrator/resources.ts +0 -59
- package/src/orchestrator/run-state.ts +0 -145
- package/src/orchestrator/verify.ts +0 -164
- package/src/orchestrator/wire-render.ts +0 -92
- 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.*'
|
|
28
|
-
*
|
|
29
|
-
* that matched literally here
|
|
30
|
-
*
|
|
31
|
-
* a stale-hit trap, so
|
|
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
|
|
73
|
-
const matchers = specs.map((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
|
|
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(
|
|
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
|
-
/**
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
-
|
|
100
|
-
return (name) => name === exact
|
|
133
|
+
return (candidate) => candidate === name
|
|
101
134
|
}
|
|
102
135
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
case '
|
|
119
|
-
|
|
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
|
+
}
|
package/src/util/errors.ts
CHANGED
|
@@ -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`
|
|
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 {
|
|
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'
|
package/src/util/num.ts
ADDED
|
@@ -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
|
-
|
|
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
|