@vzn/vx 0.0.21 → 0.0.23
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/README.md +21 -18
- package/package.json +6 -6
- package/src/bin.ts +52 -3
- package/src/cache/archive.ts +170 -21
- package/src/cache/cache.ts +667 -288
- package/src/cache/chained-cache.ts +14 -2
- package/src/cache/config-evals.ts +29 -6
- package/src/cache/file-hashes.ts +63 -35
- package/src/cache/git-inputs.ts +398 -69
- package/src/cache/index.ts +8 -1
- package/src/cache/inputs.ts +403 -102
- package/src/cache/key-fold.ts +232 -0
- package/src/cache/layer.ts +81 -11
- package/src/cache/layered-cache.ts +199 -32
- package/src/cache/output-index.ts +109 -10
- package/src/cache/run-history.ts +8 -3
- package/src/cache/zstd.ts +53 -30
- package/src/cli/cache.ts +51 -21
- package/src/cli/format.ts +4 -15
- package/src/cli/help.ts +13 -3
- package/src/cli/index.ts +35 -9
- package/src/cli/info.ts +49 -7
- package/src/cli/init.ts +17 -5
- package/src/cli/last.ts +96 -21
- package/src/cli/lock.ts +34 -17
- package/src/cli/plan-format.ts +4 -4
- package/src/cli/plugin-commands.ts +20 -5
- package/src/cli/run.ts +55 -12
- package/src/cli/select.ts +23 -13
- package/src/cli/show.ts +9 -4
- package/src/cli/upgrade.ts +134 -14
- package/src/cli/watch.ts +622 -102
- package/src/cli/why.ts +8 -16
- package/src/cli/workspace-config.ts +21 -9
- package/src/config.ts +46 -9
- package/src/exec/executor.ts +51 -5
- package/src/exec/index.ts +10 -0
- package/src/exec/kill-tree.ts +281 -0
- package/src/exec/local-executor.ts +1 -1
- package/src/exec/runner.ts +327 -82
- package/src/exec/sandbox-binds.ts +34 -5
- package/src/exec/sandbox-paths.ts +22 -2
- package/src/exec/sandbox-runtime.ts +627 -102
- package/src/exec/sandbox-violations.ts +36 -23
- package/src/graph/dependency-spec.ts +14 -14
- package/src/graph/index.ts +12 -2
- package/src/graph/priorities.ts +130 -0
- package/src/graph/scheduler.ts +182 -134
- package/src/graph/task-graph.ts +552 -188
- package/src/index.ts +16 -2
- package/src/orchestrator/admission.ts +41 -17
- package/src/orchestrator/colors.ts +8 -3
- package/src/orchestrator/deferred-outputs.ts +22 -7
- package/src/orchestrator/doctor.ts +122 -14
- package/src/orchestrator/events.ts +71 -2
- package/src/orchestrator/excluded-keys.ts +155 -0
- package/src/orchestrator/execute-task.ts +331 -37
- package/src/orchestrator/failure-recap.ts +121 -0
- package/src/orchestrator/fingerprint-watch.ts +89 -0
- package/src/orchestrator/framed-output.ts +81 -14
- package/src/orchestrator/history.ts +6 -7
- package/src/orchestrator/hit-restore.ts +82 -18
- package/src/orchestrator/index.ts +7 -1
- package/src/orchestrator/keyed-projects.ts +89 -0
- package/src/orchestrator/local-shortcircuit.ts +83 -23
- package/src/orchestrator/lockfile-claim.ts +152 -32
- package/src/orchestrator/logger.ts +116 -20
- package/src/orchestrator/metrics.ts +156 -60
- package/src/orchestrator/miss-save.ts +106 -43
- package/src/orchestrator/options.ts +30 -4
- package/src/orchestrator/persistent.ts +41 -21
- package/src/orchestrator/placement.ts +55 -25
- package/src/orchestrator/plan.ts +24 -11
- package/src/orchestrator/plugin-host.ts +17 -23
- package/src/orchestrator/plugin.ts +31 -8
- package/src/orchestrator/prepare.ts +99 -13
- package/src/orchestrator/projects.ts +19 -8
- package/src/orchestrator/remote-prefetch.ts +21 -1
- package/src/orchestrator/run-artifacts.ts +9 -0
- package/src/orchestrator/run-context.ts +19 -4
- package/src/orchestrator/run-lock.ts +349 -0
- package/src/orchestrator/run-records.ts +6 -23
- package/src/orchestrator/run-report.ts +4 -2
- package/src/orchestrator/run.ts +264 -57
- package/src/orchestrator/sandbox-request.ts +388 -62
- package/src/orchestrator/save-lane.ts +4 -7
- package/src/orchestrator/shell-verdict.ts +117 -0
- package/src/orchestrator/signals.ts +86 -30
- package/src/orchestrator/stable-keys.ts +202 -10
- package/src/orchestrator/status-line.ts +33 -1
- package/src/orchestrator/summary.ts +49 -2
- package/src/orchestrator/task-hash.ts +87 -7
- package/src/orchestrator/task-log-buffer.ts +7 -7
- package/src/orchestrator/telemetry-host.ts +16 -7
- package/src/orchestrator/telemetry.ts +69 -28
- package/src/orchestrator/upstream.ts +91 -45
- package/src/util/bun-version.ts +43 -0
- package/src/util/errors.ts +86 -0
- package/src/util/hash.ts +10 -1
- package/src/util/index.ts +32 -3
- package/src/util/paths.ts +147 -5
- package/src/util/procfs.ts +20 -0
- package/src/util/settle.ts +4 -2
- package/src/util/size.ts +33 -0
- package/src/util/task-id.ts +20 -0
- package/src/util/timing.ts +11 -1
- package/src/util/which.ts +48 -0
- package/src/workspace/affected.ts +272 -64
- package/src/workspace/config-cache.ts +262 -32
- package/src/workspace/config-eval.ts +49 -12
- package/src/workspace/config-imports.ts +126 -5
- package/src/workspace/config-schema.ts +237 -41
- package/src/workspace/filter.ts +33 -18
- package/src/workspace/fingerprint.ts +29 -11
- package/src/workspace/index.ts +7 -0
- package/src/workspace/json-data.ts +90 -0
- package/src/workspace/load-reads.ts +32 -0
- package/src/workspace/lockfile.ts +33 -11
- package/src/workspace/migrate-scripts.ts +60 -7
- package/src/workspace/migration.ts +40 -1
- package/src/workspace/package-graph.ts +154 -16
- package/src/workspace/project-loader.ts +227 -77
- package/src/workspace/workspace.ts +173 -23
|
@@ -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` →
|
|
40
|
-
* - `filter === []` → none
|
|
89
|
+
* - `filter === undefined` → every candidate, as given.
|
|
90
|
+
* - `filter === []` → none (fully decoupled).
|
|
41
91
|
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
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
|
|
51
|
-
|
|
97
|
+
export function selectFoldedDeps(
|
|
98
|
+
deps: readonly FoldCandidate[],
|
|
52
99
|
filter: readonly string[] | undefined,
|
|
53
100
|
selfProjectName: string,
|
|
54
101
|
selfTaskId: string,
|
|
55
|
-
):
|
|
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
|
-
|
|
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
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
if (
|
|
86
|
-
if (
|
|
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
|
|
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,
|
|
106
|
-
*
|
|
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
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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): (
|
|
188
|
+
function specMatcher(spec: DependencySpec): (n: TaskNode, isSelf: boolean) => boolean {
|
|
143
189
|
switch (spec.kind) {
|
|
144
190
|
case 'wildcardSelf':
|
|
145
|
-
return (
|
|
191
|
+
return (_n, isSelf) => isSelf
|
|
146
192
|
case 'wildcardDeps':
|
|
147
|
-
return (
|
|
193
|
+
return (_n, isSelf) => !isSelf
|
|
148
194
|
case 'self': {
|
|
149
195
|
const task = nameMatcher(spec.task)
|
|
150
|
-
return (
|
|
196
|
+
return (n, isSelf) => isSelf && task(n.taskName)
|
|
151
197
|
}
|
|
152
198
|
case 'deps': {
|
|
153
199
|
const task = nameMatcher(spec.task)
|
|
154
|
-
return (
|
|
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 (
|
|
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
|
+
}
|
package/src/util/errors.ts
CHANGED
|
@@ -3,6 +3,9 @@
|
|
|
3
3
|
//
|
|
4
4
|
// `bin.ts` and the scheduler consult `isUserError` to decide what to print.
|
|
5
5
|
|
|
6
|
+
import { realpathSync } from 'node:fs'
|
|
7
|
+
import os from 'node:os'
|
|
8
|
+
|
|
6
9
|
export class UserError extends Error {
|
|
7
10
|
constructor(message: string) {
|
|
8
11
|
super(message)
|
|
@@ -23,3 +26,86 @@ export class UserError extends Error {
|
|
|
23
26
|
export function isUserError(err: unknown): err is UserError {
|
|
24
27
|
return err instanceof UserError || (err instanceof Error && err.name === 'UserError')
|
|
25
28
|
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* An `EACCES`, `EPERM` or `EROFS` from the file system: a path vx must
|
|
32
|
+
* write is not this user's to write (a root-owned `.vx`, a read-only
|
|
33
|
+
* checkout). The environment's failure, reported like a UserError — one
|
|
34
|
+
* line naming the path — never as an internal error with a stack.
|
|
35
|
+
*/
|
|
36
|
+
export function isPermissionError(err: unknown): err is NodeJS.ErrnoException {
|
|
37
|
+
if (!(err instanceof Error)) return false
|
|
38
|
+
const code = (err as NodeJS.ErrnoException).code
|
|
39
|
+
return code === 'EACCES' || code === 'EPERM' || code === 'EROFS'
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** What follows the path in a permission error's one line. */
|
|
43
|
+
export const PERMISSION_HINT = 'a path vx must write is not writable by this user'
|
|
44
|
+
|
|
45
|
+
/** `ENOSPC` or `EDQUOT`: the disk a path is on is full. */
|
|
46
|
+
export function isDiskFull(err: unknown): err is NodeJS.ErrnoException {
|
|
47
|
+
if (!(err instanceof Error)) return false
|
|
48
|
+
const code = (err as NodeJS.ErrnoException).code
|
|
49
|
+
return code === 'ENOSPC' || code === 'EDQUOT'
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** A file system refusing a write for a reason no code path caused: permission or space. */
|
|
53
|
+
export function isFsRefusal(err: unknown): err is NodeJS.ErrnoException {
|
|
54
|
+
return isPermissionError(err) || isDiskFull(err)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export const DISK_FULL_HINT = 'the disk that path is on is full'
|
|
58
|
+
|
|
59
|
+
/** The one-line hint after a refusal's path. */
|
|
60
|
+
export function fsRefusalHint(err: NodeJS.ErrnoException): string {
|
|
61
|
+
return isDiskFull(err) ? DISK_FULL_HINT : PERMISSION_HINT
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* A spawn that could not run at all — Bun throws `ENOENT` ("Executable not
|
|
66
|
+
* found in $PATH") synchronously from `Bun.spawn` and `Bun.spawnSync` alike.
|
|
67
|
+
* Every git call site tells it from a git that ran and failed, because the
|
|
68
|
+
* remedy differs: install git, not "git init".
|
|
69
|
+
*/
|
|
70
|
+
export function isExecutableMissing(err: unknown): boolean {
|
|
71
|
+
return (err as NodeJS.ErrnoException)?.code === 'ENOENT'
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The one refusal for a git that is not on PATH, wherever vx needed it — a
|
|
76
|
+
* `--affected` base, the input enumeration, a watch judgement. A minimal
|
|
77
|
+
* image without git met a stack from `defaultAffectedBase` before
|
|
78
|
+
* (2026-09-16); the enumeration had this line and the others did not.
|
|
79
|
+
*/
|
|
80
|
+
export function gitSpawnRefusal(cwd: string): UserError {
|
|
81
|
+
return new UserError(
|
|
82
|
+
`vx requires git: failed to spawn 'git' (working dir: ${cwd}). Install git and re-run.`,
|
|
83
|
+
)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export const TMPDIR_HINT = 'point TMPDIR at a writable directory'
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* A temp-directory refusal: the path is under `os.tmpdir()` and the error
|
|
90
|
+
* says it is missing, a file, or not writable. The knob is TMPDIR, and a
|
|
91
|
+
* line that names the path should name the knob — a minimal image's
|
|
92
|
+
* sandboxed task said "EACCES … mkdtemp '/tmp/…/srt-obs-…'" and nothing
|
|
93
|
+
* else (2026-09-16). Only for a site whose path IS the temp directory by
|
|
94
|
+
* construction: a workspace under /tmp would pass the path test too.
|
|
95
|
+
*/
|
|
96
|
+
export function isTmpdirRefusal(err: unknown): err is NodeJS.ErrnoException {
|
|
97
|
+
if (!(err instanceof Error)) return false
|
|
98
|
+
const code = (err as NodeJS.ErrnoException).code
|
|
99
|
+
if (code !== 'ENOENT' && code !== 'ENOTDIR' && !isPermissionError(err)) return false
|
|
100
|
+
const p = (err as NodeJS.ErrnoException).path
|
|
101
|
+
const named = typeof p === 'string' ? p : err.message
|
|
102
|
+
const tmp = os.tmpdir()
|
|
103
|
+
// The error may name the resolved directory (macOS: /tmp is /private/tmp);
|
|
104
|
+
// a temp directory that does not exist has no real path, so the name as
|
|
105
|
+
// given is the only one it can carry.
|
|
106
|
+
let real = tmp
|
|
107
|
+
try {
|
|
108
|
+
real = realpathSync(tmp)
|
|
109
|
+
} catch {}
|
|
110
|
+
return named.includes(tmp) || named.includes(real)
|
|
111
|
+
}
|
package/src/util/hash.ts
CHANGED
|
@@ -13,10 +13,19 @@
|
|
|
13
13
|
// folds `part` into the running digest. Equivalent to the old
|
|
14
14
|
// `CryptoHasher.update(...).update(...).digest()` pattern, without
|
|
15
15
|
// allocating an intermediate buffer.
|
|
16
|
+
//
|
|
17
|
+
// Bun reads only the LOW 32 bits of that seed (measured, item 682), so a
|
|
18
|
+
// bare `xxHash3(part, prev)` carries 32 bits of state from step to step:
|
|
19
|
+
// two input sets whose running digests share their low halves merge at
|
|
20
|
+
// the next step, and a birthday search found such a pair in 0.08 s — a
|
|
21
|
+
// stale hit at ~2^-32 per step, not 2^-64. The seed is XORed back in
|
|
22
|
+
// (feed-forward): states that share their low half keep their exact
|
|
23
|
+
// high-half difference through every later step, so they never merge,
|
|
24
|
+
// and a seed of 0 changes nothing, so single-shot digests are unchanged.
|
|
16
25
|
|
|
17
26
|
/** xxHash3 bigint output. Use as the seed for the next chain step. */
|
|
18
27
|
export function xxh3(input: string | Uint8Array, seed: bigint = 0n): bigint {
|
|
19
|
-
return Bun.hash.xxHash3(input, seed)
|
|
28
|
+
return Bun.hash.xxHash3(input, seed) ^ seed
|
|
20
29
|
}
|
|
21
30
|
|
|
22
31
|
/** xxHash3 hex-encoded, fixed 16 chars (zero-padded). */
|
package/src/util/index.ts
CHANGED
|
@@ -1,13 +1,38 @@
|
|
|
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 {
|
|
4
|
+
export {
|
|
5
|
+
DISK_FULL_HINT,
|
|
6
|
+
fsRefusalHint,
|
|
7
|
+
isDiskFull,
|
|
8
|
+
isFsRefusal,
|
|
9
|
+
isTmpdirRefusal,
|
|
10
|
+
TMPDIR_HINT,
|
|
11
|
+
isPermissionError,
|
|
12
|
+
isUserError,
|
|
13
|
+
PERMISSION_HINT,
|
|
14
|
+
UserError,
|
|
15
|
+
gitSpawnRefusal,
|
|
16
|
+
isExecutableMissing,
|
|
17
|
+
} from './errors.js'
|
|
5
18
|
export { xxh3, xxh3hex } from './hash.js'
|
|
6
19
|
export { mark, printTimings, span } from './timing.js'
|
|
7
20
|
export { clampInt, MAX_TIMEOUT_MS, parseDecimalInt } from './num.js'
|
|
8
|
-
export {
|
|
21
|
+
export {
|
|
22
|
+
asTrees,
|
|
23
|
+
BUN_GLOB_WILDCARDS,
|
|
24
|
+
grantPrefix,
|
|
25
|
+
isLiteralPattern,
|
|
26
|
+
GLOB_WILDCARDS,
|
|
27
|
+
normalizeBunGlob,
|
|
28
|
+
normalizeGlob,
|
|
29
|
+
relPosix,
|
|
30
|
+
staticPrefix,
|
|
31
|
+
taskGlob,
|
|
32
|
+
wholeSubtreePrefixes,
|
|
33
|
+
} from './paths.js'
|
|
9
34
|
export { killGraceMs, settleWithin, teardownTimeoutMs } from './settle.js'
|
|
10
|
-
export { parseSize } from './size.js'
|
|
35
|
+
export { formatBytes, parseDuration, parseSize } from './size.js'
|
|
11
36
|
export {
|
|
12
37
|
cgroupCpuQuota,
|
|
13
38
|
cgroupMemoryLimitBytes,
|
|
@@ -17,5 +42,9 @@ export {
|
|
|
17
42
|
} from './cgroup.js'
|
|
18
43
|
export { appendTail, createTail, resetTail, tailText, type Tail } from './tail.js'
|
|
19
44
|
export { ulid } from './ulid.js'
|
|
45
|
+
export { splitTaskId } from './task-id.js'
|
|
20
46
|
export { editDistance, nearMatches, nearest } from './edit-distance.js'
|
|
21
47
|
export { CORE_VERBS, DISPATCHED_VERBS, MOVED_VERBS } from './verbs.js'
|
|
48
|
+
export { isUnsupportedBun, MIN_BUN, unsupportedBunMessage } from './bun-version.js'
|
|
49
|
+
export { executablePath } from './which.js'
|
|
50
|
+
export { procfsIsOwn } from './procfs.js'
|
package/src/util/paths.ts
CHANGED
|
@@ -24,13 +24,43 @@ export function relPosix(from: string, to: string): string {
|
|
|
24
24
|
* baseline joins it onto a dir to get a write prefix, and the deferral
|
|
25
25
|
* eligibility gate compares two of them for overlap. A second copy is
|
|
26
26
|
* how the two would disagree about what a prefix is.
|
|
27
|
+
*
|
|
28
|
+
* The spelling is normalized FIRST, here rather than at each call site,
|
|
29
|
+
* because sharing the function was not enough to make the two callers
|
|
30
|
+
* agree: the sandbox joins the prefix onto a directory and `path.join`
|
|
31
|
+
* folds `./`, `//` and `/./` on the way, while the deferral gate compares
|
|
32
|
+
* the raw strings and does not. So `./out/**` and `out/**` named the same
|
|
33
|
+
* tree to the resolver, the sandbox and the watcher, and two DIFFERENT
|
|
34
|
+
* prefixes to the gate — which then deferred a producer a same-project
|
|
35
|
+
* reader's key could see, moving that key with a transfer flag (item 441,
|
|
36
|
+
* measured through a real run: `./out/**` keyed the consumer two ways).
|
|
37
|
+
* Every glob reaching here is user-written, so every one takes the rule.
|
|
38
|
+
*/
|
|
39
|
+
export function staticPrefix(rawGlob: string): string {
|
|
40
|
+
return prefixOf(normalizeGlob(rawGlob), GLOB_WILDCARDS)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* {@link staticPrefix} under `Bun.Glob`'s own alphabet, for a sandbox GRANT:
|
|
45
|
+
* the grant is expanded by a `Bun.Glob` scan that reads `[ab]` as a class, so
|
|
46
|
+
* its prefix must stop there too, or `write: ['g/[ab].txt']` would create a
|
|
47
|
+
* DIRECTORY named `g/[ab].txt`.
|
|
27
48
|
*/
|
|
28
|
-
export function
|
|
49
|
+
export function grantPrefix(rawGlob: string): string {
|
|
50
|
+
return prefixOf(normalizeBunGlob(rawGlob), BUN_GLOB_WILDCARDS)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function prefixOf(glob: string, wildcards: RegExp): string {
|
|
29
54
|
// A brace set is a wildcard too: `{dist,build}/**` reaches either dir,
|
|
30
55
|
// and reading it as the literal directory `{dist,build}` gave the
|
|
31
56
|
// sandbox baseline a prefix that exists nowhere (2026-09-10).
|
|
32
|
-
const wildcardIdx = glob.search(
|
|
33
|
-
|
|
57
|
+
const wildcardIdx = glob.search(wildcards)
|
|
58
|
+
// A LITERAL keeps its trailing slash through `normalizeGlob` on purpose
|
|
59
|
+
// (`asTrees` is what turns `out/` into the tree `out` + `out/**`), but a
|
|
60
|
+
// prefix with a slash on the end compares as a different string: `out/`
|
|
61
|
+
// against `out/sub` found no overlap where `out` does. The prefix is a
|
|
62
|
+
// directory either way. `/` is the root, not a trailing slash.
|
|
63
|
+
if (wildcardIdx === -1) return glob === '/' ? '/' : glob.replace(/\/+$/, '')
|
|
34
64
|
const head = glob.slice(0, wildcardIdx)
|
|
35
65
|
const lastSep = head.lastIndexOf('/')
|
|
36
66
|
if (lastSep === -1) return '.'
|
|
@@ -51,7 +81,7 @@ export function wholeSubtreePrefixes(globs: readonly string[]): string[] | null
|
|
|
51
81
|
if (globs.length === 0) return null
|
|
52
82
|
const out: string[] = []
|
|
53
83
|
for (const g of globs.map(normalizeGlob)) {
|
|
54
|
-
const m = /^([^*?
|
|
84
|
+
const m = /^([^*?{}!]+?)\/\*\*$/.exec(g)
|
|
55
85
|
if (m === null) return null
|
|
56
86
|
const dir = m[1]!.replace(/\/+$/, '')
|
|
57
87
|
if (dir === '' || dir === '.' || dir.startsWith('/') || dir.split('/').includes('..'))
|
|
@@ -71,10 +101,122 @@ export function wholeSubtreePrefixes(globs: readonly string[]): string[] | null
|
|
|
71
101
|
* after an optional `!`; a bare `.` is the empty entry the schema refuses.
|
|
72
102
|
*/
|
|
73
103
|
export function normalizeGlob(glob: string): string {
|
|
104
|
+
return normalizeWith(glob, GLOB_WILDCARDS)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* {@link normalizeGlob} for a glob in `Bun.Glob`'s own alphabet (a workspace
|
|
109
|
+
* member glob, a sandbox grant): the same spellings, but `\[` stays escaped,
|
|
110
|
+
* since there it is what keeps a bracket from opening a class.
|
|
111
|
+
*/
|
|
112
|
+
export function normalizeBunGlob(glob: string): string {
|
|
113
|
+
return normalizeWith(glob, BUN_GLOB_WILDCARDS)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function normalizeWith(glob: string, wildcards: RegExp): string {
|
|
74
117
|
const neg = glob.startsWith('!')
|
|
75
118
|
let g = neg ? glob.slice(1) : glob
|
|
76
119
|
g = g.replace(/\/{2,}/g, '/').replace(/(^|\/)(\.\/)+/g, '$1')
|
|
77
120
|
if (g === '.') g = ''
|
|
78
|
-
|
|
121
|
+
// Where a bracket is literal, Turbo's escaped spelling `\[id\]` names the
|
|
122
|
+
// same path as `[id]`; one spelling is what lets every literal fast path
|
|
123
|
+
// (string compare, `settleLiterals`, `asTrees`) see it as a literal.
|
|
124
|
+
if (wildcards === GLOB_WILDCARDS && g.includes('\\')) g = g.replace(/\\([[\]])/g, '$1')
|
|
125
|
+
if (wildcards.test(g) && g.endsWith('/')) g = `${g.replace(/\/+$/, '')}/**`
|
|
79
126
|
return neg ? `!${g}` : g
|
|
80
127
|
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* True when a pattern carries no wildcard — it names exactly one path.
|
|
131
|
+
*
|
|
132
|
+
* The character SET is the whole content: in a task glob `*`, `?` and a
|
|
133
|
+
* brace alternation are wildcards, so a pattern holding any of them must
|
|
134
|
+
* be MATCHED, never compared as a string. It lives here, exported, because
|
|
135
|
+
* four places asked the same question and one of them asked it with a
|
|
136
|
+
* smaller set: `graph/task-graph.ts` omitted `{}`, so `dist/{a,b}.txt`
|
|
137
|
+
* counted as a literal and the overlapping-output refusal compared it to
|
|
138
|
+
* `dist/a.txt` as two unequal strings — the two tasks were accepted and
|
|
139
|
+
* then deleted each other's outputs, green, every run (item 495). That is
|
|
140
|
+
* the same divergence `asTrees` was moved here to end in item 442, and the
|
|
141
|
+
* same one that removed `@vzn/vx-migrate`'s copy of `outputsOverlap` in
|
|
142
|
+
* item 445.
|
|
143
|
+
*/
|
|
144
|
+
export function isLiteralPattern(glob: string): boolean {
|
|
145
|
+
return !GLOB_WILDCARDS.test(glob)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* The alphabet of a TASK glob (`cache.inputs.files`, `cache.outputs.files`,
|
|
150
|
+
* `workspaceFiles`), behind {@link isLiteralPattern}, for the callers that
|
|
151
|
+
* need the POSITION of the first wildcard rather than a verdict. Every site
|
|
152
|
+
* that asks "must this declaration be matched?" reads this one regex: item
|
|
153
|
+
* 495 found the fourth copy with a smaller set, and item 577 found the
|
|
154
|
+
* class written out nine times — a spelling that drifts is how two guards
|
|
155
|
+
* come to answer differently for one pattern.
|
|
156
|
+
*
|
|
157
|
+
* `[` and `]` are NOT in it: a bracket is a literal character in a task
|
|
158
|
+
* glob, and there are no character classes (item 667). Route directories
|
|
159
|
+
* are named `[id]` across Next.js, SvelteKit and Astro, and read as a class
|
|
160
|
+
* `app/[id]/**` matched `app/i/…` and `app/d/…` and never the route: the
|
|
161
|
+
* file never entered the key (a stale hit) and the output clean deleted an
|
|
162
|
+
* unrelated `app/i/page.js`. {@link taskGlob} is how such a glob reaches
|
|
163
|
+
* `Bun.Glob`, whose own alphabet still reads the class.
|
|
164
|
+
*/
|
|
165
|
+
export const GLOB_WILDCARDS = /[*?{}]/
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* `Bun.Glob`'s own alphabet, the class included, for the globs vx does not
|
|
169
|
+
* own: package-manager workspace members and `--filter` path globs follow
|
|
170
|
+
* npm/pnpm semantics, and a sandbox grant's prefix must stop where the scan
|
|
171
|
+
* that expands the grant (`Bun.Glob`, `MOUNT_WILDCARDS`) sees a wildcard.
|
|
172
|
+
*/
|
|
173
|
+
export const BUN_GLOB_WILDCARDS = /[*?[\]{}]/
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Compile a task glob for `Bun.Glob`, with every bare bracket escaped so it
|
|
177
|
+
* matches itself (item 667). Every task glob is compiled here and nowhere
|
|
178
|
+
* else: one site that forgot is a route directory that keys nothing.
|
|
179
|
+
*/
|
|
180
|
+
export function taskGlob(pattern: string): Bun.Glob {
|
|
181
|
+
// A `]` with no `[` before it is already literal to `Bun.Glob`.
|
|
182
|
+
if (!pattern.includes('[')) return new Bun.Glob(pattern)
|
|
183
|
+
return new Bun.Glob(pattern.replace(/(?<!\\)[[\]]/g, '\\$&'))
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function stripTrailingSlash(p: string): string {
|
|
187
|
+
return p.replace(/\/+$/, '')
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* A literal entry names a file OR a directory tree: `src/` and `src` both
|
|
192
|
+
* mean everything under `src`, as they do in Turbo and every `.gitignore`.
|
|
193
|
+
* A glob matcher sees only the literal path, so `['src/']` folded ZERO
|
|
194
|
+
* files — a key that never moves with its source, and the most common
|
|
195
|
+
* turbo.json shape (`"outputs": ["dist"]`) captured nothing. Every
|
|
196
|
+
* literal therefore compiles to itself plus its subtree; a literal that
|
|
197
|
+
* names a file still matches exactly that file, since `x/**` matches
|
|
198
|
+
* nothing under a file.
|
|
199
|
+
*
|
|
200
|
+
* It lives in `util` rather than beside the resolver because it is not
|
|
201
|
+
* only the resolver's rule. It decides what a clean DELETES, so the
|
|
202
|
+
* graph's overlapping-output refusal has to read the same one: while it
|
|
203
|
+
* did not, `dist` and `dist/app.js` compared as two unequal literals and
|
|
204
|
+
* the run that wiped `dist/app.js` reported success (item 442). `graph`
|
|
205
|
+
* may not import `cache`, and a second copy is how the two would
|
|
206
|
+
* disagree — which is exactly what item 441 measured about
|
|
207
|
+
* `staticPrefix`.
|
|
208
|
+
*/
|
|
209
|
+
export function asTrees(patterns: readonly string[]): string[] {
|
|
210
|
+
const out: string[] = []
|
|
211
|
+
for (const raw of patterns) {
|
|
212
|
+
const p = normalizeGlob(raw)
|
|
213
|
+
if (!isLiteralPattern(p)) {
|
|
214
|
+
out.push(p)
|
|
215
|
+
continue
|
|
216
|
+
}
|
|
217
|
+
const lit = stripTrailingSlash(p)
|
|
218
|
+
if (lit.length === 0) continue
|
|
219
|
+
out.push(lit, `${lit}/**`)
|
|
220
|
+
}
|
|
221
|
+
return out
|
|
222
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// Is `/proc` this process's view of the pid namespace? A procfs mounted
|
|
2
|
+
// for another namespace is a table of strangers: under the sandbox's
|
|
3
|
+
// nested pid namespace vx is pid 2 while `/proc/self` names 7, and
|
|
4
|
+
// `/proc/<our child's pid>` is some other process or none (2026-09-24).
|
|
5
|
+
// Asked once per process: the mount does not change under a run.
|
|
6
|
+
|
|
7
|
+
import { readlinkSync } from 'node:fs'
|
|
8
|
+
|
|
9
|
+
let own: boolean | undefined
|
|
10
|
+
|
|
11
|
+
export function procfsIsOwn(): boolean {
|
|
12
|
+
if (own === undefined) {
|
|
13
|
+
try {
|
|
14
|
+
own = process.platform === 'linux' && readlinkSync('/proc/self') === String(process.pid)
|
|
15
|
+
} catch {
|
|
16
|
+
own = false
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
return own
|
|
20
|
+
}
|
package/src/util/settle.ts
CHANGED
|
@@ -47,9 +47,11 @@ export function killGraceMs(defaultMs: number): number {
|
|
|
47
47
|
}
|
|
48
48
|
|
|
49
49
|
/**
|
|
50
|
-
* Await `p`, giving up after `ms`. Returns true when `p`
|
|
50
|
+
* Await `p`, giving up after `ms`. Returns true when `p` fulfilled first,
|
|
51
51
|
* false when the deadline won — the caller decides whether a lost result
|
|
52
|
-
* is worth reporting.
|
|
52
|
+
* is worth reporting. A rejection before the deadline is not a timeout: it
|
|
53
|
+
* propagates, so the caller reports the failure rather than a hang
|
|
54
|
+
* (`tests/util-settle.test.ts`).
|
|
53
55
|
*/
|
|
54
56
|
export async function settleWithin(p: Promise<unknown>, ms: number): Promise<boolean> {
|
|
55
57
|
let timer: ReturnType<typeof setTimeout> | undefined
|