@vzn/vx 0.0.18 → 0.0.22
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +1 -1
- package/README.md +107 -70
- package/package.json +10 -20
- package/src/bin.ts +64 -5
- package/src/cache/archive.ts +279 -28
- package/src/cache/cache.ts +952 -1584
- package/src/cache/chained-cache.ts +14 -10
- package/src/cache/config-evals.ts +112 -0
- package/src/cache/file-hashes.ts +290 -0
- package/src/cache/git-inputs.ts +966 -0
- package/src/cache/index.ts +23 -7
- package/src/cache/inputs.ts +553 -739
- package/src/cache/key-fold.ts +232 -0
- package/src/cache/layer.ts +674 -0
- package/src/cache/layered-cache.ts +211 -49
- package/src/cache/output-index.ts +323 -0
- package/src/cache/policy.ts +84 -0
- package/src/cache/run-history.ts +152 -0
- package/src/cache/zstd.ts +183 -0
- package/src/cli/cache.ts +88 -30
- package/src/cli/completions.ts +104 -0
- package/src/cli/core-alias.ts +25 -0
- package/src/cli/format.ts +4 -15
- package/src/cli/help.ts +76 -38
- package/src/cli/index.ts +82 -17
- package/src/cli/info.ts +162 -91
- package/src/cli/init.ts +87 -0
- package/src/cli/last.ts +130 -23
- package/src/cli/lock.ts +36 -18
- package/src/cli/plan-format.ts +18 -7
- package/src/cli/plugin-commands.ts +34 -12
- package/src/cli/run.ts +138 -313
- package/src/cli/select.ts +342 -0
- package/src/cli/show.ts +151 -71
- package/src/cli/upgrade.ts +136 -15
- package/src/cli/watch.ts +1175 -116
- package/src/cli/why.ts +51 -29
- package/src/cli/workspace-config.ts +138 -0
- package/src/config.ts +183 -126
- package/src/exec/env.ts +11 -0
- package/src/exec/executor.ts +62 -31
- package/src/exec/index.ts +16 -2
- package/src/exec/kill-tree.ts +281 -0
- package/src/exec/local-executor.ts +41 -0
- package/src/exec/runner.ts +361 -77
- package/src/exec/sandbox-binds.ts +211 -0
- package/src/exec/sandbox-paths.ts +70 -0
- package/src/exec/sandbox-runtime.ts +1152 -415
- package/src/exec/sandbox-violations.ts +293 -0
- package/src/graph/dependency-spec.ts +14 -14
- package/src/graph/index.ts +6 -4
- package/src/graph/priorities.ts +130 -0
- package/src/graph/scheduler.ts +332 -329
- package/src/graph/task-graph.ts +552 -188
- package/src/index.ts +111 -102
- package/src/orchestrator/admission.ts +161 -0
- package/src/orchestrator/colors.ts +8 -3
- package/src/orchestrator/deferred-outputs.ts +25 -7
- package/src/orchestrator/doctor.ts +363 -0
- package/src/orchestrator/events.ts +111 -8
- package/src/orchestrator/excluded-keys.ts +155 -0
- package/src/orchestrator/execute-task.ts +450 -565
- package/src/orchestrator/failure-mode.ts +169 -28
- package/src/orchestrator/failure-recap.ts +121 -0
- package/src/orchestrator/fingerprint-watch.ts +89 -0
- package/src/orchestrator/framed-output.ts +121 -49
- package/src/orchestrator/history.ts +123 -104
- package/src/orchestrator/hit-restore.ts +285 -0
- package/src/orchestrator/index.ts +42 -5
- package/src/orchestrator/keyed-projects.ts +89 -0
- package/src/orchestrator/local-shortcircuit.ts +83 -23
- package/src/orchestrator/lockfile-claim.ts +401 -0
- package/src/orchestrator/logger.ts +128 -41
- package/src/orchestrator/metrics.ts +201 -72
- package/src/orchestrator/miss-save.ts +232 -0
- package/src/orchestrator/options.ts +63 -34
- package/src/orchestrator/persistent.ts +99 -0
- package/src/orchestrator/placement.ts +212 -0
- package/src/orchestrator/plan.ts +24 -11
- package/src/orchestrator/plugin-host.ts +200 -49
- package/src/orchestrator/plugin.ts +190 -14
- package/src/orchestrator/prepare.ts +168 -137
- package/src/orchestrator/projects.ts +270 -0
- package/src/orchestrator/remote-prefetch.ts +22 -2
- package/src/orchestrator/run-artifacts.ts +34 -3
- package/src/orchestrator/run-context.ts +20 -5
- package/src/orchestrator/run-lock.ts +349 -0
- package/src/orchestrator/run-records.ts +132 -0
- package/src/orchestrator/run-report.ts +6 -4
- package/src/orchestrator/run.ts +467 -611
- package/src/orchestrator/sandbox-request.ts +569 -0
- package/src/orchestrator/save-lane.ts +44 -0
- package/src/orchestrator/shell-verdict.ts +117 -0
- package/src/orchestrator/signals.ts +167 -0
- package/src/orchestrator/stable-keys.ts +259 -10
- package/src/orchestrator/status-line.ts +35 -2
- package/src/orchestrator/summary.ts +115 -25
- package/src/orchestrator/task-hash.ts +103 -21
- package/src/orchestrator/task-log-buffer.ts +15 -18
- package/src/orchestrator/telemetry-host.ts +27 -8
- package/src/orchestrator/telemetry.ts +71 -44
- package/src/orchestrator/upstream.ts +91 -45
- package/src/util/bun-version.ts +43 -0
- package/src/util/cgroup.ts +160 -0
- package/src/util/edit-distance.ts +49 -0
- package/src/util/errors.ts +86 -0
- package/src/util/hash.ts +10 -1
- package/src/util/index.ts +42 -5
- package/src/util/paths.ts +168 -5
- package/src/util/procfs.ts +20 -0
- package/src/util/settle.ts +23 -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/verbs.ts +39 -0
- package/src/util/which.ts +48 -0
- package/src/workspace/affected.ts +368 -60
- package/src/workspace/config-cache.ts +271 -32
- package/src/workspace/config-eval.ts +60 -16
- package/src/workspace/config-imports.ts +131 -9
- package/src/workspace/config-schema.ts +1033 -0
- package/src/workspace/filter.ts +69 -17
- package/src/workspace/fingerprint.ts +59 -7
- package/src/workspace/index.ts +37 -3
- package/src/workspace/json-data.ts +90 -0
- package/src/workspace/load-reads.ts +32 -0
- package/src/workspace/lockfile.ts +41 -14
- package/src/workspace/migrate-scripts.ts +187 -0
- package/src/workspace/migration.ts +353 -0
- package/src/workspace/package-graph.ts +239 -31
- package/src/workspace/project-loader.ts +295 -815
- package/src/workspace/workspace.ts +266 -31
- package/plugins/local-cache/index.ts +0 -2
- package/plugins/local-executor/index.ts +0 -2
- package/plugins/schedule-history/index.ts +0 -2
- package/src/cache/cas-backend.ts +0 -115
- package/src/cache/digest.ts +0 -32
- package/src/cli/migrate-emit.ts +0 -21
- package/src/cli/migrate-nx.ts +0 -473
- package/src/cli/migrate-scripts.ts +0 -127
- package/src/cli/migrate-turbo.ts +0 -401
- package/src/cli/migrate.ts +0 -305
- package/src/cli/prune.ts +0 -291
- package/src/orchestrator/missing-plugin.ts +0 -9
- package/src/orchestrator/resources.ts +0 -45
- package/src/orchestrator/verify.ts +0 -174
- package/src/plugins/local-cache/index.ts +0 -11
- package/src/plugins/local-executor/index.ts +0 -45
- package/src/plugins/schedule-history/index.ts +0 -109
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,50 @@
|
|
|
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 {
|
|
9
|
-
|
|
10
|
-
|
|
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'
|
|
34
|
+
export { killGraceMs, settleWithin, teardownTimeoutMs } from './settle.js'
|
|
35
|
+
export { formatBytes, parseDuration, parseSize } from './size.js'
|
|
36
|
+
export {
|
|
37
|
+
cgroupCpuQuota,
|
|
38
|
+
cgroupMemoryLimitBytes,
|
|
39
|
+
machineMemoryBytes,
|
|
40
|
+
machineParallelism,
|
|
41
|
+
type CgroupProbe,
|
|
42
|
+
} from './cgroup.js'
|
|
11
43
|
export { appendTail, createTail, resetTail, tailText, type Tail } from './tail.js'
|
|
12
44
|
export { ulid } from './ulid.js'
|
|
13
|
-
export {
|
|
45
|
+
export { splitTaskId } from './task-id.js'
|
|
46
|
+
export { editDistance, nearMatches, nearest } from './edit-distance.js'
|
|
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,10 +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
|
|
29
|
-
|
|
30
|
-
|
|
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 {
|
|
54
|
+
// A brace set is a wildcard too: `{dist,build}/**` reaches either dir,
|
|
55
|
+
// and reading it as the literal directory `{dist,build}` gave the
|
|
56
|
+
// sandbox baseline a prefix that exists nowhere (2026-09-10).
|
|
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(/\/+$/, '')
|
|
31
64
|
const head = glob.slice(0, wildcardIdx)
|
|
32
65
|
const lastSep = head.lastIndexOf('/')
|
|
33
66
|
if (lastSep === -1) return '.'
|
|
@@ -47,8 +80,8 @@ export function staticPrefix(glob: string): string {
|
|
|
47
80
|
export function wholeSubtreePrefixes(globs: readonly string[]): string[] | null {
|
|
48
81
|
if (globs.length === 0) return null
|
|
49
82
|
const out: string[] = []
|
|
50
|
-
for (const g of globs) {
|
|
51
|
-
const m = /^([^*?
|
|
83
|
+
for (const g of globs.map(normalizeGlob)) {
|
|
84
|
+
const m = /^([^*?{}!]+?)\/\*\*$/.exec(g)
|
|
52
85
|
if (m === null) return null
|
|
53
86
|
const dir = m[1]!.replace(/\/+$/, '')
|
|
54
87
|
if (dir === '' || dir === '.' || dir.startsWith('/') || dir.split('/').includes('..'))
|
|
@@ -57,3 +90,133 @@ export function wholeSubtreePrefixes(globs: readonly string[]): string[] | null
|
|
|
57
90
|
}
|
|
58
91
|
return [...new Set(out)]
|
|
59
92
|
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* The spellings a reader, Turbo and `.gitignore` all accept but a matcher
|
|
96
|
+
* fed the raw string turns into NOTHING — and a task keyed on nothing
|
|
97
|
+
* replays old outputs as a green hit (2026-09-10, probed one by one):
|
|
98
|
+
* a leading `./`, an inner `/./` segment, a doubled `//`, and a trailing
|
|
99
|
+
* `/` on a pattern (`src/*\/` means the trees under `src`, so it becomes
|
|
100
|
+
* `src/*\/**`; a trailing slash on a LITERAL is `asTrees`' job). Applied
|
|
101
|
+
* after an optional `!`; a bare `.` is the empty entry the schema refuses.
|
|
102
|
+
*/
|
|
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 {
|
|
117
|
+
const neg = glob.startsWith('!')
|
|
118
|
+
let g = neg ? glob.slice(1) : glob
|
|
119
|
+
g = g.replace(/\/{2,}/g, '/').replace(/(^|\/)(\.\/)+/g, '$1')
|
|
120
|
+
if (g === '.') g = ''
|
|
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(/\/+$/, '')}/**`
|
|
126
|
+
return neg ? `!${g}` : g
|
|
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
|
@@ -28,9 +28,30 @@ export function teardownTimeoutMs(): number {
|
|
|
28
28
|
}
|
|
29
29
|
|
|
30
30
|
/**
|
|
31
|
-
*
|
|
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` fulfilled first,
|
|
32
51
|
* false when the deadline won — the caller decides whether a lost result
|
|
33
|
-
* 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`).
|
|
34
55
|
*/
|
|
35
56
|
export async function settleWithin(p: Promise<unknown>, ms: number): Promise<boolean> {
|
|
36
57
|
let timer: ReturnType<typeof setTimeout> | undefined
|
package/src/util/size.ts
CHANGED
|
@@ -18,3 +18,36 @@ export function parseSize(input: string): number | null {
|
|
|
18
18
|
u === '' ? 1 : u === 'K' ? 1024 : u === 'M' ? 1024 * 1024 : u === 'G' ? 1024 ** 3 : 1024 ** 4
|
|
19
19
|
return n * mult
|
|
20
20
|
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Parse `<n><unit>` where unit is s/m/h/d, case-insensitively (`parseSize`
|
|
24
|
+
* has always been case-insensitive; this matched only lowercase, so `30D`
|
|
25
|
+
* was rejected while `1GB` and `1gb` both worked).
|
|
26
|
+
*/
|
|
27
|
+
export function parseDuration(input: string): number | null {
|
|
28
|
+
const m = input.match(/^(\d+)([smhd])$/i)
|
|
29
|
+
if (!m) return null
|
|
30
|
+
const n = parseDecimalInt(m[1]!)
|
|
31
|
+
if (n === null) return null
|
|
32
|
+
// Lowercase before the switch: with the /i flag an uppercase `M` would
|
|
33
|
+
// otherwise fall through to the days branch.
|
|
34
|
+
const unit = m[2]!.toLowerCase()
|
|
35
|
+
const mult = unit === 's' ? 1000 : unit === 'm' ? 60_000 : unit === 'h' ? 3_600_000 : 86_400_000
|
|
36
|
+
return n * mult
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Human-readable byte size with KB/MB/GB/TB/PB suffixes and one
|
|
41
|
+
* decimal of precision below 10 of any unit. Used by `vx info`, `vx cache prune` and the
|
|
42
|
+
* run's `cacheRetention` line.
|
|
43
|
+
*/
|
|
44
|
+
export function formatBytes(n: number): string {
|
|
45
|
+
if (n < 1024) return `${n} B`
|
|
46
|
+
const units = ['KB', 'MB', 'GB', 'TB']
|
|
47
|
+
let v = n / 1024
|
|
48
|
+
for (const u of units) {
|
|
49
|
+
if (v < 1024) return `${v.toFixed(v < 10 ? 1 : 0)} ${u}`
|
|
50
|
+
v /= 1024
|
|
51
|
+
}
|
|
52
|
+
return `${v.toFixed(0)} PB`
|
|
53
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The inverse of `taskId` (`graph/task-graph.ts`). Splits on the FIRST
|
|
3
|
+
* `#`, so a task name that itself contains one round-trips: `taskId('a', 'b#c')` → `'a#b#c'` → `['a', 'b#c']`.
|
|
4
|
+
*
|
|
5
|
+
* This exists because it kept being written by hand as `id.split('#', 2)`,
|
|
6
|
+
* which is NOT the inverse — it discards everything after the second segment,
|
|
7
|
+
* so `'a#b#c'` reads back as task `'b'`. Six call sites had that form while
|
|
8
|
+
* `parseDependencySpec` (the surface that decides what actually runs) has
|
|
9
|
+
* always split on the first `#`, so the query layer and the graph disagreed
|
|
10
|
+
* about the identity of the same task: a lookup either found nothing or, worse,
|
|
11
|
+
* answered with a different task's history.
|
|
12
|
+
*
|
|
13
|
+
* A `#`-containing task name is legal and pinned elsewhere in the suite, so
|
|
14
|
+
* this is reachable rather than theoretical. The cache's run history read
|
|
15
|
+
* the same rule from a private copy until item 646; one rule, one place.
|
|
16
|
+
*/
|
|
17
|
+
export function splitTaskId(id: string): [project: string, task: string] {
|
|
18
|
+
const i = id.indexOf('#')
|
|
19
|
+
return i < 0 ? [id, ''] : [id.slice(0, i), id.slice(i + 1)]
|
|
20
|
+
}
|
package/src/util/timing.ts
CHANGED
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
// The CPU profiler attributes a tight loop's cost unreliably and hides
|
|
4
4
|
// where an `await` waited; a stage table answers "where did the warm run
|
|
5
5
|
// go?" directly, and costs one boolean check per mark when off. Marks are
|
|
6
|
-
// cumulative from
|
|
6
|
+
// cumulative from this module's load — process start and the imports ahead
|
|
7
|
+
// of it sit outside the table; the table shows each stage's own share.
|
|
7
8
|
|
|
8
9
|
const enabled = process.env.VX_TIMING !== undefined && process.env.VX_TIMING !== ''
|
|
9
10
|
const t0 = Bun.nanoseconds()
|
|
@@ -58,6 +59,15 @@ export function printTimings(): void {
|
|
|
58
59
|
` ${label.padEnd(24)} ${(ns / 1e6).toFixed(1).padStart(8)}ms ${String(count).padStart(6)}`,
|
|
59
60
|
)
|
|
60
61
|
}
|
|
62
|
+
// The trap this table sets, said where it is sprung: a span's total is
|
|
63
|
+
// WALL summed per call, and the run's spans are taken under the
|
|
64
|
+
// scheduler's concurrency, so a span that overlaps other work reads
|
|
65
|
+
// far larger than the work it names. `output dirs` at 124 µs a task is
|
|
66
|
+
// a handful of `lstat`s (items 254 and 407). Compare spans to each
|
|
67
|
+
// other, and measure a suspect one in isolation before chasing it.
|
|
68
|
+
lines.push(
|
|
69
|
+
' (wall per call, summed; concurrent calls overlap — compare spans, not totals)',
|
|
70
|
+
)
|
|
61
71
|
}
|
|
62
72
|
process.stderr.write(lines.join('\n') + '\n')
|
|
63
73
|
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The verbs the CLI dispatcher (`cli/index.ts`) owns; `stats` is a
|
|
3
|
+
* deprecated alias of `info` and stays out of hints but is still matched
|
|
4
|
+
* first. Lives in util rather than cli because the workspace VALIDATOR
|
|
5
|
+
* refuses a plugin verb that names one of these — a verb core matches
|
|
6
|
+
* first could never run — and the workspace module cannot import cli.
|
|
7
|
+
*/
|
|
8
|
+
export const CORE_VERBS = [
|
|
9
|
+
'run',
|
|
10
|
+
'watch',
|
|
11
|
+
'cache',
|
|
12
|
+
'lock',
|
|
13
|
+
'init',
|
|
14
|
+
'upgrade',
|
|
15
|
+
'show',
|
|
16
|
+
'info',
|
|
17
|
+
'why',
|
|
18
|
+
'last',
|
|
19
|
+
'completions',
|
|
20
|
+
'help',
|
|
21
|
+
'version',
|
|
22
|
+
] as const
|
|
23
|
+
|
|
24
|
+
/** Every word the dispatcher matches before consulting plugins. */
|
|
25
|
+
export const DISPATCHED_VERBS: readonly string[] = [...CORE_VERBS, 'stats']
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Verbs core owned once and a package owns now. Not core verbs: a plugin
|
|
29
|
+
* may declare them, and the dispatcher prints the
|
|
30
|
+
* pointer only when no declared plugin claimed the name.
|
|
31
|
+
*/
|
|
32
|
+
export const MOVED_VERBS: Readonly<Record<string, string>> = {
|
|
33
|
+
migrate:
|
|
34
|
+
'vx migrate moved to @vzn/vx-migrate: run `bunx @vzn/vx-migrate` (turbo.json or an Nx ' +
|
|
35
|
+
'project graph → vx.config.ts); `vx init` reads package.json scripts',
|
|
36
|
+
prune:
|
|
37
|
+
'vx prune was removed (2026-09-11): the Docker-subset verb is not shipped; copy the ' +
|
|
38
|
+
'workspace and `--filter` the build instead',
|
|
39
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// A spawn of a bare name walks PATH with a stat per entry — `sh` for every
|
|
2
|
+
// task, `git` for every git call: 200 tasks paid ~2,000 stats on a 100-package
|
|
3
|
+
// workspace for the same answer each time. The walk is done here once and
|
|
4
|
+
// the spawn is handed the absolute path.
|
|
5
|
+
//
|
|
6
|
+
// The PATH walked is vx's OWN, never a task's. A task's PATH starts with its
|
|
7
|
+
// project's and the workspace's `node_modules/.bin`, and `exec.env.define`
|
|
8
|
+
// may set it outright; the shell that parses the command and the git that
|
|
9
|
+
// enumerates the inputs are the machine's tools, not the task's, so a
|
|
10
|
+
// dependency that ships a `sh` bin must not become the interpreter of every
|
|
11
|
+
// command in its project. The task's PATH still decides what the COMMAND
|
|
12
|
+
// resolves, inside that shell.
|
|
13
|
+
//
|
|
14
|
+
// Memoized per PATH value rather than for the process's life: nothing in vx
|
|
15
|
+
// changes PATH mid-run, so a run pays one walk, and a process whose PATH does
|
|
16
|
+
// change (an embedder, a test) is answered for the PATH it has. A miss is
|
|
17
|
+
// not remembered: it ends the spawn that asked, and a long-lived process
|
|
18
|
+
// (`vx watch`) finds the tool once the user installs it.
|
|
19
|
+
|
|
20
|
+
let memoPath: string | undefined
|
|
21
|
+
const found = new Map<string, string>()
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* `name`'s absolute path on vx's own PATH. Throws Bun's own shape for a
|
|
25
|
+
* spawn that could not run (`code: 'ENOENT'`) when it is not there, so a
|
|
26
|
+
* call inside a spawn site's `try` reaches the same `isExecutableMissing`
|
|
27
|
+
* refusal the spawn's throw did.
|
|
28
|
+
*/
|
|
29
|
+
export function executablePath(name: string): string {
|
|
30
|
+
const PATH = process.env['PATH'] ?? ''
|
|
31
|
+
if (PATH !== memoPath) {
|
|
32
|
+
found.clear()
|
|
33
|
+
memoPath = PATH
|
|
34
|
+
}
|
|
35
|
+
const hit = found.get(name)
|
|
36
|
+
if (hit !== undefined) return hit
|
|
37
|
+
const at = Bun.which(name, { PATH })
|
|
38
|
+
if (at === null) {
|
|
39
|
+
throw Object.assign(new Error(`Executable not found in $PATH: "${name}"`), {
|
|
40
|
+
code: 'ENOENT',
|
|
41
|
+
path: name,
|
|
42
|
+
})
|
|
43
|
+
}
|
|
44
|
+
// Absolute even for a relative PATH entry (Bun.which resolves it against
|
|
45
|
+
// this process's cwd), so it holds in whatever cwd the spawn is given.
|
|
46
|
+
found.set(name, at)
|
|
47
|
+
return at
|
|
48
|
+
}
|