@vzn/vx 0.0.363 → 0.0.365
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 +26 -35
- package/package.json +5 -5
- package/src/bin.ts +10 -3
- package/src/cache/cache.ts +8 -2
- package/src/cache/tar-stream.ts +40 -3
- package/src/exec/sandbox-runtime.ts +2 -0
- package/src/exec/sandbox-violations.ts +29 -0
- package/src/orchestrator/execute-task.ts +3 -1
- package/src/orchestrator/run-lock.ts +48 -6
- package/src/workspace/config-eval.ts +71 -0
- package/src/workspace/config-schema.ts +30 -0
- package/src/workspace/project-loader.ts +44 -17
package/README.md
CHANGED
|
@@ -34,12 +34,9 @@ Same graph, commands and concurrency: [how it is measured](https://vznjs.github.
|
|
|
34
34
|
|
|
35
35
|
## Why it is faster
|
|
36
36
|
|
|
37
|
-
- **Nothing boots per task
|
|
38
|
-
|
|
39
|
-
- **
|
|
40
|
-
git's index; no file is read.
|
|
41
|
-
- **One pass over the repo.** One git walk per run; each stat and
|
|
42
|
-
lookup happens once.
|
|
37
|
+
- **Nothing boots per task:** no daemon, no Node process; vx spawns your command.
|
|
38
|
+
- **No file reads to hash:** on a clean tree, keys come from git's index.
|
|
39
|
+
- **One pass:** one git walk per run; each stat and lookup happens once.
|
|
43
40
|
|
|
44
41
|
## Install
|
|
45
42
|
|
|
@@ -47,9 +44,8 @@ Same graph, commands and concurrency: [how it is measured](https://vznjs.github.
|
|
|
47
44
|
npm install -D @vzn/vx # or: pnpm add -D -w · yarn add -D (-W on Yarn 1) · bun add -d
|
|
48
45
|
```
|
|
49
46
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
[release](https://github.com/vznjs/vx/releases), needs no Node either.
|
|
47
|
+
A prebuilt binary for Linux and macOS (x64, arm64); no Bun needed. The
|
|
48
|
+
[release](https://github.com/vznjs/vx/releases) binary needs no Node either.
|
|
53
49
|
Windows: use WSL.
|
|
54
50
|
|
|
55
51
|
## Quick start
|
|
@@ -60,7 +56,7 @@ In a git repo with a `package.json` workspace:
|
|
|
60
56
|
npx vx init # a vx.config.ts per package, from its scripts
|
|
61
57
|
```
|
|
62
58
|
|
|
63
|
-
Give a task a cache block
|
|
59
|
+
Give a task a cache block, what it reads and writes:
|
|
64
60
|
|
|
65
61
|
```ts
|
|
66
62
|
// packages/app/vx.config.ts
|
|
@@ -89,8 +85,8 @@ npx vx run build --all # restored from the cache, not rebuilt
|
|
|
89
85
|
|
|
90
86
|
<img src="https://vznjs.github.io/vx/demo.svg" width="760" alt="vx run ci --all on examples/basic: cold, three tasks run; again, three up-to-date.">
|
|
91
87
|
|
|
92
|
-
|
|
93
|
-
|
|
88
|
+
Starter: [`examples/basic`](examples/basic), run by CI on every commit.
|
|
89
|
+
More: [Quickstart](https://vznjs.github.io/vx/quickstart/) ·
|
|
94
90
|
[Configure](https://vznjs.github.io/vx/guides/configure/) ·
|
|
95
91
|
[CLI](packages/vx/docs/cli.md) · [every config field](packages/vx/docs/schema.md) ·
|
|
96
92
|
[playground](https://vznjs.github.io/vx/playground/) (the planner, in your browser).
|
|
@@ -98,24 +94,21 @@ runs on every commit. More: [Quickstart](https://vznjs.github.io/vx/quickstart/)
|
|
|
98
94
|
## Coming from Turbo or Nx
|
|
99
95
|
|
|
100
96
|
`bunx @vzn/vx-migrate` writes a `vx.config.ts` per package from
|
|
101
|
-
`turbo.json` or the Nx graph
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
[Migration guide](https://vznjs.github.io/vx/guides/migrate/).
|
|
97
|
+
`turbo.json` or the Nx graph. `vx init` there writes a `vx.workspace.ts`
|
|
98
|
+
that reads them in place: a temporary start. The numbers above are native
|
|
99
|
+
vx config. [Migration guide](https://vznjs.github.io/vx/guides/migrate/).
|
|
105
100
|
|
|
106
101
|
## More than speed
|
|
107
102
|
|
|
108
|
-
- **A cache you can trust
|
|
109
|
-
hashing, so imports and computed values are
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
- **
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
Vite does it. Core ships none and needs none.
|
|
118
|
-
- **Free.** MIT. No paywall, no cloud, no account.
|
|
103
|
+
- **A cache you can trust:** TypeScript configs are evaluated before
|
|
104
|
+
hashing, so imports and computed values are in the key. Outputs are
|
|
105
|
+
wiped before every run and restore: no stale file survives.
|
|
106
|
+
- **Sandboxed tasks:** opt in per task; an undeclared read fails the
|
|
107
|
+
build instead of poisoning the cache.
|
|
108
|
+
- **Clean exits:** Ctrl-C reaps every child.
|
|
109
|
+
- **Plugins at every stage:** remote cache, remote execution, telemetry,
|
|
110
|
+
CLI verbs. Core ships none and needs none.
|
|
111
|
+
- **Free:** MIT. No paywall, no cloud, no account.
|
|
119
112
|
|
|
120
113
|
## How it compares
|
|
121
114
|
|
|
@@ -136,8 +129,7 @@ Every gap, and when another tool is the better pick:
|
|
|
136
129
|
|
|
137
130
|
## Plugins
|
|
138
131
|
|
|
139
|
-
Declared in `vx.workspace.ts
|
|
140
|
-
machine.
|
|
132
|
+
Declared in `vx.workspace.ts`; without any, vx runs and caches locally.
|
|
141
133
|
|
|
142
134
|
| Package | What it adds |
|
|
143
135
|
| ---------------------------------------------------------- | ----------------------------------------------------------------------------- |
|
|
@@ -156,9 +148,8 @@ Write your own: [plugin guide](https://vznjs.github.io/vx/guides/plugins/) ·
|
|
|
156
148
|
|
|
157
149
|
## Status
|
|
158
150
|
|
|
159
|
-
**Pre-alpha**, dogfooded: this repo lints, tests and builds itself
|
|
160
|
-
|
|
161
|
-
every commit. The schema may still change; what 1.0 will promise is in
|
|
151
|
+
**Pre-alpha**, dogfooded: this repo lints, tests and builds itself with
|
|
152
|
+
vx. The schema may still change; 1.0's promise:
|
|
162
153
|
[versioning-1.0.md](packages/vx/docs/design/versioning-1.0.md).
|
|
163
154
|
|
|
164
155
|
| Surface | Maturity |
|
|
@@ -176,9 +167,9 @@ bun install
|
|
|
176
167
|
bun packages/vx/src/bin.ts run ci --all # lint, test, docs build: every package
|
|
177
168
|
```
|
|
178
169
|
|
|
179
|
-
Needs Bun ≥ 1.4. Every dev task is a vx task;
|
|
180
|
-
|
|
181
|
-
|
|
170
|
+
Needs Bun ≥ 1.4. Every dev task is a vx task; no `package.json`
|
|
171
|
+
scripts. Start with [CONTRIBUTING.md](CONTRIBUTING.md) and
|
|
172
|
+
[STATUS.md](packages/vx/docs/STATUS.md).
|
|
182
173
|
|
|
183
174
|
## License
|
|
184
175
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vzn/vx",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.365",
|
|
4
4
|
"description": "A fast, correct task runner and build cache for JavaScript monorepos.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -21,10 +21,10 @@
|
|
|
21
21
|
"node": ">=18"
|
|
22
22
|
},
|
|
23
23
|
"optionalDependencies": {
|
|
24
|
-
"@vzn/vx-linux-x64": "0.0.
|
|
25
|
-
"@vzn/vx-linux-arm64": "0.0.
|
|
26
|
-
"@vzn/vx-darwin-x64": "0.0.
|
|
27
|
-
"@vzn/vx-darwin-arm64": "0.0.
|
|
24
|
+
"@vzn/vx-linux-x64": "0.0.365",
|
|
25
|
+
"@vzn/vx-linux-arm64": "0.0.365",
|
|
26
|
+
"@vzn/vx-darwin-x64": "0.0.365",
|
|
27
|
+
"@vzn/vx-darwin-arm64": "0.0.365"
|
|
28
28
|
},
|
|
29
29
|
"dependencies": {
|
|
30
30
|
"@anthropic-ai/sandbox-runtime": "^0.0.76"
|
package/src/bin.ts
CHANGED
|
@@ -9,10 +9,14 @@ async function main(): Promise<void> {
|
|
|
9
9
|
// `vx --version` needs one leaf module; the dispatcher and the util
|
|
10
10
|
// barrel it pulled in were 20 ms of its 28 (2026-10-01).
|
|
11
11
|
if (argv.length === 1 && (argv[0] === '--version' || argv[0] === 'version')) {
|
|
12
|
-
process.stdout
|
|
12
|
+
// `Bun.stdout`, not `process.stdout`: the Node stream's first touch is
|
|
13
|
+
// ~7 ms, half of this verb (2026-10-02). A reader gone (EPIPE) is no
|
|
14
|
+
// failure of the line.
|
|
15
|
+
await Bun.write(Bun.stdout, `vx ${(await import('./version.js')).VERSION}\n`).catch(() => {})
|
|
13
16
|
settled = true
|
|
14
17
|
return
|
|
15
18
|
}
|
|
19
|
+
listenForReadersGone()
|
|
16
20
|
const { registerCoreAlias, run } = await import('./cli/index.js')
|
|
17
21
|
// Every `import … from '@vzn/vx'` this process evaluates — a plugin
|
|
18
22
|
// package, a workspace or project config — resolves to THIS core, not to
|
|
@@ -86,8 +90,11 @@ process.on('beforeExit', () => {
|
|
|
86
90
|
// and exits with its own verdict; what was going to the reader goes
|
|
87
91
|
// nowhere. Same for stderr (`2>&1 | head`). Here and not in the logger:
|
|
88
92
|
// an embedder's streams are the embedder's.
|
|
89
|
-
|
|
90
|
-
|
|
93
|
+
// Set up past `--version`, which never touches the Node streams.
|
|
94
|
+
function listenForReadersGone(): void {
|
|
95
|
+
for (const stream of [process.stdout, process.stderr]) {
|
|
96
|
+
stream.on('error', () => {})
|
|
97
|
+
}
|
|
91
98
|
}
|
|
92
99
|
|
|
93
100
|
void main()
|
package/src/cache/cache.ts
CHANGED
|
@@ -439,6 +439,7 @@ export class Cache implements CacheLayer {
|
|
|
439
439
|
private readonly insertEntry: ReturnType<Database['prepare']>
|
|
440
440
|
private readonly deleteEntryRow: ReturnType<Database['prepare']>
|
|
441
441
|
private readonly selectEntry: ReturnType<Database['prepare']>
|
|
442
|
+
private readonly entryExists: ReturnType<Database['prepare']>
|
|
442
443
|
private readonly bumpAccessed: ReturnType<Database['prepare']>
|
|
443
444
|
private readonly touched = new Set<string>()
|
|
444
445
|
private readonly insertEntryInput: ReturnType<Database['prepare']>
|
|
@@ -711,6 +712,12 @@ export class Cache implements CacheLayer {
|
|
|
711
712
|
peak_rss_bytes = excluded.peak_rss_bytes
|
|
712
713
|
`)
|
|
713
714
|
this.selectEntry = this.db.prepare('SELECT * FROM entries WHERE hash = ?')
|
|
715
|
+
// `has` asks only whether the row is there: the whole row, stdout
|
|
716
|
+
// included, was built and dropped once per task of a `--dry` plan. A
|
|
717
|
+
// column off the index, so the table row is still read and a corrupt
|
|
718
|
+
// table refuses here as it does on `get` (`SELECT 1` answers from the
|
|
719
|
+
// index alone); stdout's overflow pages are not.
|
|
720
|
+
this.entryExists = this.db.prepare('SELECT exit_code FROM entries WHERE hash = ?')
|
|
714
721
|
this.bumpAccessed = this.db.prepare('UPDATE entries SET accessed_at = ? WHERE hash = ?')
|
|
715
722
|
// INSERT OR IGNORE: re-saving the same hash (idempotent ingest /
|
|
716
723
|
// overlapping concurrent saves) leaves the existing rows untouched —
|
|
@@ -981,8 +988,7 @@ export class Cache implements CacheLayer {
|
|
|
981
988
|
|
|
982
989
|
private async hasEntry(hash: string): Promise<'local' | 'remote' | null> {
|
|
983
990
|
if (!this.read) return null
|
|
984
|
-
|
|
985
|
-
if (!row) return null
|
|
991
|
+
if (this.entryExists.get(hash) === null) return null
|
|
986
992
|
return existsSync(this.tarPath(hash)) ? 'local' : null
|
|
987
993
|
}
|
|
988
994
|
|
package/src/cache/tar-stream.ts
CHANGED
|
@@ -53,6 +53,8 @@ function octal(h: Uint8Array, off: number, len: number): number {
|
|
|
53
53
|
for (let i = off + 1; i < off + len; i++) n = n * 256 + h[i]!
|
|
54
54
|
return n
|
|
55
55
|
}
|
|
56
|
+
const plain = plainOctal(h, off, len)
|
|
57
|
+
if (plain !== null) return plain
|
|
56
58
|
const s = field(h, off, len).trim()
|
|
57
59
|
if (s === '') return 0
|
|
58
60
|
// Every digit, not the longest parseable prefix: `parseInt` would read
|
|
@@ -61,10 +63,33 @@ function octal(h: Uint8Array, off: number, len: number): number {
|
|
|
61
63
|
return parseInt(s, 8)
|
|
62
64
|
}
|
|
63
65
|
|
|
66
|
+
/**
|
|
67
|
+
* The field every writer emits — octal digits between ASCII spaces, ended
|
|
68
|
+
* by a NUL or the field's end — read off the bytes; null for anything
|
|
69
|
+
* else, which `octal` reads the slow way. Three fields a header through a
|
|
70
|
+
* decoder and a regex were most of a small artifact's read (2026-10-02).
|
|
71
|
+
*/
|
|
72
|
+
function plainOctal(h: Uint8Array, off: number, len: number): number | null {
|
|
73
|
+
const end = off + len
|
|
74
|
+
let i = off
|
|
75
|
+
while (i < end && h[i] === 0x20) i++
|
|
76
|
+
let n = 0
|
|
77
|
+
while (i < end && h[i]! >= 0x30 && h[i]! <= 0x37) n = n * 8 + (h[i++]! - 0x30)
|
|
78
|
+
while (i < end && h[i] === 0x20) i++
|
|
79
|
+
return i === end || h[i] === 0 ? n : null
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function isZeroBlock(h: Uint8Array): boolean {
|
|
83
|
+
for (let i = 0; i < BLOCK; i++) if (h[i] !== 0) return false
|
|
84
|
+
return true
|
|
85
|
+
}
|
|
86
|
+
|
|
64
87
|
function checksumOk(h: Uint8Array): boolean {
|
|
65
88
|
const stored = octal(h, 148, 8)
|
|
66
|
-
|
|
67
|
-
|
|
89
|
+
// The checksum field counts as eight spaces.
|
|
90
|
+
let sum = 8 * 32
|
|
91
|
+
for (let i = 0; i < 148; i++) sum += h[i]!
|
|
92
|
+
for (let i = 156; i < BLOCK; i++) sum += h[i]!
|
|
68
93
|
return sum === stored
|
|
69
94
|
}
|
|
70
95
|
|
|
@@ -107,6 +132,18 @@ class Source {
|
|
|
107
132
|
|
|
108
133
|
/** Exactly `n` bytes, or null at a clean end (nothing buffered), or a TarFormatError mid-entry. */
|
|
109
134
|
async exact(n: number, what: string): Promise<Uint8Array | null> {
|
|
135
|
+
// Within one chunk, a view: the copy below was a third of a small
|
|
136
|
+
// artifact's read. Nothing writes to what this returns.
|
|
137
|
+
const c0 = this.chunks[0]
|
|
138
|
+
if (c0 !== undefined && c0.byteLength - this.head >= n) {
|
|
139
|
+
const view = c0.subarray(this.head, this.head + n)
|
|
140
|
+
this.head += n
|
|
141
|
+
if (this.head === c0.byteLength) {
|
|
142
|
+
this.chunks.shift()
|
|
143
|
+
this.head = 0
|
|
144
|
+
}
|
|
145
|
+
return view
|
|
146
|
+
}
|
|
110
147
|
const out = new Uint8Array(n)
|
|
111
148
|
let got = 0
|
|
112
149
|
while (got < n) {
|
|
@@ -163,7 +200,7 @@ export async function* tarEntries(stream: ReadableStream<Uint8Array>): AsyncGene
|
|
|
163
200
|
if (zeroBlocks === 0) throw new TarFormatError('archive has no end-of-archive marker')
|
|
164
201
|
return
|
|
165
202
|
}
|
|
166
|
-
if (h
|
|
203
|
+
if (isZeroBlock(h)) {
|
|
167
204
|
zeroBlocks++
|
|
168
205
|
if (zeroBlocks >= 2) return
|
|
169
206
|
continue
|
|
@@ -89,6 +89,7 @@ import {
|
|
|
89
89
|
} from './sandbox-deny-scan.js'
|
|
90
90
|
import {
|
|
91
91
|
parseStraceViolations,
|
|
92
|
+
refusedConnections,
|
|
92
93
|
refusedWrites,
|
|
93
94
|
refusedWritesOutside,
|
|
94
95
|
reportableViolations,
|
|
@@ -1732,6 +1733,7 @@ async function runSandboxedOnce(
|
|
|
1732
1733
|
bindableWrites(args.config.allowWrite),
|
|
1733
1734
|
scratch,
|
|
1734
1735
|
),
|
|
1736
|
+
...refusedConnections(records.map((v) => v.line)),
|
|
1735
1737
|
]
|
|
1736
1738
|
: []
|
|
1737
1739
|
if (straceLog) {
|
|
@@ -419,6 +419,8 @@ function matchesIgnore(
|
|
|
419
419
|
* crossing, and the task can grant it.
|
|
420
420
|
*/
|
|
421
421
|
function describeMacViolation(line: string): Partial<SandboxViolation> {
|
|
422
|
+
const proxy = PROXY_DENY_RE.exec(line)
|
|
423
|
+
if (proxy !== null) return { target: proxy[1]!, ignorable: ['network'] }
|
|
422
424
|
const m = /deny\(\d+\)\s+(\S+)\s+(.+?)\s*$/.exec(line)
|
|
423
425
|
if (m === null) return {}
|
|
424
426
|
const [op, target] = [m[1]!, m[2]!]
|
|
@@ -589,6 +591,28 @@ function filterIgnored(
|
|
|
589
591
|
return violations.filter((v) => !matchesIgnore(v, ignore))
|
|
590
592
|
}
|
|
591
593
|
|
|
594
|
+
/**
|
|
595
|
+
* SRT's filtering proxy records a connection it refused as `deny
|
|
596
|
+
* network-outbound <host>:<port> (<reason>)`, on both platforms: the one
|
|
597
|
+
* component that knows the host. Read as its own shape, so `ignore.network`
|
|
598
|
+
* silences it by host; the seatbelt pattern above wants `deny(<n>)`.
|
|
599
|
+
*/
|
|
600
|
+
const PROXY_DENY_RE = /^deny network-outbound (\S+) \([^)]*\)$/
|
|
601
|
+
|
|
602
|
+
/**
|
|
603
|
+
* Linux: the connections the proxy refused, from the store records. Only
|
|
604
|
+
* the write observer's records were read there, so a task denied a host
|
|
605
|
+
* failed with its own `403` and no report, and one that survived the
|
|
606
|
+
* refusal passed, where macOS reports the same record (2026-10-02).
|
|
607
|
+
*/
|
|
608
|
+
export function refusedConnections(records: readonly string[]): SandboxViolation[] {
|
|
609
|
+
return [...new Set(records.filter((r) => PROXY_DENY_RE.test(r)))].map((line) => ({
|
|
610
|
+
line,
|
|
611
|
+
timestamp: new Date(),
|
|
612
|
+
...describeMacViolation(line),
|
|
613
|
+
}))
|
|
614
|
+
}
|
|
615
|
+
|
|
592
616
|
/**
|
|
593
617
|
* The writes SRT's Linux observer saw that this task's binds do not cover.
|
|
594
618
|
* The observer reports every write-intent syscall as `deny <syscall>
|
|
@@ -612,6 +636,11 @@ export function refusedWrites(
|
|
|
612
636
|
const m = /^deny (\S+) (\/.*)$/.exec(record)
|
|
613
637
|
if (m === null) continue
|
|
614
638
|
const [, syscall, raw] = m as unknown as [string, string, string]
|
|
639
|
+
// A descriptor or pseudo-file is the task's, and resolving it here
|
|
640
|
+
// reads vx's own: strace's log is `/dev/fd/5`, and a vx started with
|
|
641
|
+
// `5>out.log` in the project reported a write to `out.log` and failed
|
|
642
|
+
// a clean task (2026-10-02).
|
|
643
|
+
if (/^\/(?:dev|proc)\//.test(raw)) continue
|
|
615
644
|
const abs = toRealPath(raw)
|
|
616
645
|
if (isUnderAny(abs, binds) || underGlob(abs, globs)) continue
|
|
617
646
|
const key = `${syscall}|${abs}`
|
|
@@ -915,7 +915,9 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
915
915
|
signal: res.signal,
|
|
916
916
|
hidden: req.sandbox && ((f: string) => !sandboxReads(req.sandbox!, f)),
|
|
917
917
|
})
|
|
918
|
-
|
|
918
|
+
// The line quotes the command's first word, which a config may have
|
|
919
|
+
// built from a secret: masked as the task's own output is (L-37).
|
|
920
|
+
if (verdict !== undefined) log.taskStderr(node, `\n${secrets?.mask(verdict) ?? verdict}\n`)
|
|
919
921
|
// A committed file another task's clean removed, still gone: vx
|
|
920
922
|
// cleans outputs before a run where Turbo does not, so a reader with
|
|
921
923
|
// no edge to the producer fails naming only the file (A-48).
|
|
@@ -12,8 +12,8 @@
|
|
|
12
12
|
// holds it.
|
|
13
13
|
//
|
|
14
14
|
// Keyed by the workspace, not the cache directory (`--cache-dir` must not
|
|
15
|
-
// make two runs strangers), and kept
|
|
16
|
-
// read-only checkout can take it.
|
|
15
|
+
// make two runs strangers), and kept in this user's own directory under
|
|
16
|
+
// the temp directory (L-47), so a read-only checkout can take it.
|
|
17
17
|
//
|
|
18
18
|
// The lock is a directory, HELD exactly while it is not empty. It holds
|
|
19
19
|
// one entry, `h-<pid>-<start>-<n>`, naming the holder and unique to this
|
|
@@ -60,7 +60,7 @@
|
|
|
60
60
|
// serialize what it chose to overlap. The lock is taken by the first of
|
|
61
61
|
// them and left by the last to release.
|
|
62
62
|
|
|
63
|
-
import { readFileSync, realpathSync } from 'node:fs'
|
|
63
|
+
import { chmodSync, lstatSync, mkdirSync, readFileSync, realpathSync } from 'node:fs'
|
|
64
64
|
import { rmdirSync, unlinkSync } from 'node:fs'
|
|
65
65
|
import { mkdir, readdir, readFile, rename, rm, rmdir, unlink, writeFile } from 'node:fs/promises'
|
|
66
66
|
import os from 'node:os'
|
|
@@ -110,7 +110,7 @@ const SAY_AFTER_MS = 1_000
|
|
|
110
110
|
const LEGACY_PID = 'pid'
|
|
111
111
|
|
|
112
112
|
export interface RunLockOptions {
|
|
113
|
-
/** Where the lock directories live; `os.tmpdir()` unless a test says otherwise. */
|
|
113
|
+
/** Where the lock directories live; this user's own directory in `os.tmpdir()` unless a test says otherwise. */
|
|
114
114
|
dir?: string
|
|
115
115
|
/** A status line for the waiting notice and the unlocked warning. */
|
|
116
116
|
log: (line: string) => void
|
|
@@ -119,12 +119,12 @@ export interface RunLockOptions {
|
|
|
119
119
|
}
|
|
120
120
|
|
|
121
121
|
/**
|
|
122
|
-
* The lock directory for a workspace root: stable across
|
|
122
|
+
* The lock directory for a workspace root: stable across this user's runs,
|
|
123
123
|
* private to this machine. Keyed by the REAL path: a CLI's cwd comes back
|
|
124
124
|
* canonical while a caller may hold a symlinked spelling (macOS's /var ->
|
|
125
125
|
* /private/var), and two spellings of one workspace must meet.
|
|
126
126
|
*/
|
|
127
|
-
export function runLockPath(workspaceRoot: string, dir = os.tmpdir()): string {
|
|
127
|
+
export function runLockPath(workspaceRoot: string, dir = userLockRoot(os.tmpdir())): string {
|
|
128
128
|
const resolved = path.resolve(workspaceRoot)
|
|
129
129
|
let real = resolved
|
|
130
130
|
try {
|
|
@@ -135,6 +135,46 @@ export function runLockPath(workspaceRoot: string, dir = os.tmpdir()): string {
|
|
|
135
135
|
return path.join(dir, `vx-run-${xxh3hex(real)}`)
|
|
136
136
|
}
|
|
137
137
|
|
|
138
|
+
/**
|
|
139
|
+
* This user's own directory for locks under `dir`. A lock named in the
|
|
140
|
+
* shared temp dir itself was anyone's to plant: another local user put a
|
|
141
|
+
* held lock naming a live pid there and every run on the workspace waited
|
|
142
|
+
* on it (L-47). Two users on one checkout no longer exclude each other;
|
|
143
|
+
* the files of one are rarely the other's to write.
|
|
144
|
+
*/
|
|
145
|
+
function userLockRoot(dir: string): string {
|
|
146
|
+
const uid = process.getuid?.()
|
|
147
|
+
return uid === undefined ? dir : path.join(dir, `vx-runs-${uid}`)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** The roots this process has made or checked. */
|
|
151
|
+
const ownedRoots = new Set<string>()
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Make the lock's root, or refuse it when it is not this user's own
|
|
155
|
+
* directory (a link, another owner): a lock read there is anyone's.
|
|
156
|
+
*/
|
|
157
|
+
function ownLockRoot(lockDir: string): void {
|
|
158
|
+
const root = path.dirname(lockDir)
|
|
159
|
+
const uid = process.getuid?.()
|
|
160
|
+
if (uid === undefined || ownedRoots.has(root)) return
|
|
161
|
+
// Not recursive: a temp directory that is not there is the warning
|
|
162
|
+
// that names TMPDIR, never one vx makes.
|
|
163
|
+
try {
|
|
164
|
+
mkdirSync(root, { mode: 0o700 })
|
|
165
|
+
} catch (err) {
|
|
166
|
+
if ((err as NodeJS.ErrnoException).code !== 'EEXIST') throw err
|
|
167
|
+
}
|
|
168
|
+
const st = lstatSync(root)
|
|
169
|
+
if (!st.isDirectory() || st.uid !== uid) {
|
|
170
|
+
throw new Error(
|
|
171
|
+
`${root} is not this user's own directory (a link or another owner's); remove it`,
|
|
172
|
+
)
|
|
173
|
+
}
|
|
174
|
+
if ((st.mode & 0o077) !== 0) chmodSync(root, 0o700)
|
|
175
|
+
ownedRoots.add(root)
|
|
176
|
+
}
|
|
177
|
+
|
|
138
178
|
interface Holder {
|
|
139
179
|
pid: number
|
|
140
180
|
/** The holder's start time as `startTime` read it; null where the lock names none. */
|
|
@@ -309,6 +349,8 @@ export async function acquireRunLock(
|
|
|
309
349
|
}
|
|
310
350
|
let h: Holder | 'free' | null
|
|
311
351
|
try {
|
|
352
|
+
// A caller's own `dir` (a test's) is theirs to vouch for.
|
|
353
|
+
if (opts.dir === undefined) ownLockRoot(lockDir)
|
|
312
354
|
const entry = await place(lockDir)
|
|
313
355
|
if (entry !== null) {
|
|
314
356
|
heldHere.set(lockDir, 1)
|
|
@@ -42,6 +42,24 @@ import { nonJsonMessage, nonJsonPaths, type NonJsonValue } from './json-data.js'
|
|
|
42
42
|
/** Why a config's `process.exit` throws, on both load paths (D-65). */
|
|
43
43
|
export const CONFIG_EXIT = 'a config exports its object; it cannot end the run'
|
|
44
44
|
|
|
45
|
+
/**
|
|
46
|
+
* The built-ins a config must not change: vx reads every other config and
|
|
47
|
+
* makes every cache key through them (D-74, D-75). The loader watches them
|
|
48
|
+
* in this process; the worker, asked to blame, in its own.
|
|
49
|
+
*/
|
|
50
|
+
export const WATCHED_BUILTIN_NAMES = [
|
|
51
|
+
'Object.prototype',
|
|
52
|
+
'Array.prototype',
|
|
53
|
+
'Bun',
|
|
54
|
+
'Bun.hash',
|
|
55
|
+
'JSON',
|
|
56
|
+
'Math',
|
|
57
|
+
'String.prototype',
|
|
58
|
+
'Map.prototype',
|
|
59
|
+
'Set.prototype',
|
|
60
|
+
'Promise.prototype',
|
|
61
|
+
] as const
|
|
62
|
+
|
|
45
63
|
const WORKER_SRC = `
|
|
46
64
|
const nonJsonPaths = ${nonJsonPaths.toString()}
|
|
47
65
|
// The parent's stdout is a verb's JSON or vx mcp's JSON-RPC stream, and a
|
|
@@ -83,6 +101,29 @@ self.onmessage = async (e) => {
|
|
|
83
101
|
const live = globalThis.process.env
|
|
84
102
|
for (const k of Object.keys(live)) if (!(k in env)) delete live[k]
|
|
85
103
|
Object.assign(live, env)
|
|
104
|
+
// A blame request reports the built-ins and env vars this one evaluation
|
|
105
|
+
// changed, for a round whose loads overlapped and saw a change (D-119).
|
|
106
|
+
const watched = e.data.blame
|
|
107
|
+
? ${JSON.stringify(WATCHED_BUILTIN_NAMES)}.map((n) => [n, n.split('.').reduce((o, k) => o[k], globalThis)])
|
|
108
|
+
: null
|
|
109
|
+
const own = (o) => new Map(Reflect.ownKeys(o).map((k) => [k, Object.getOwnPropertyDescriptor(o, k)]))
|
|
110
|
+
const before = watched?.map(([, o]) => own(o))
|
|
111
|
+
const envBefore = watched ? { ...live } : null
|
|
112
|
+
const same = (a, b) =>
|
|
113
|
+
a !== undefined && b !== undefined && a.value === b.value && a.get === b.get && a.set === b.set &&
|
|
114
|
+
a.writable === b.writable && a.enumerable === b.enumerable && a.configurable === b.configurable
|
|
115
|
+
const changed = () => {
|
|
116
|
+
if (watched === null) return undefined
|
|
117
|
+
const out = []
|
|
118
|
+
watched.forEach(([n, o], i) => {
|
|
119
|
+
const now = own(o)
|
|
120
|
+
for (const k of new Set([...before[i].keys(), ...now.keys()]))
|
|
121
|
+
if (!same(before[i].get(k), now.get(k))) out.push(n + '.' + String(k))
|
|
122
|
+
})
|
|
123
|
+
for (const k of new Set([...Object.keys(envBefore), ...Object.keys(live)]))
|
|
124
|
+
if (envBefore[k] !== live[k]) out.push('process.env.' + k)
|
|
125
|
+
return out
|
|
126
|
+
}
|
|
86
127
|
try {
|
|
87
128
|
const ns = await import(path)
|
|
88
129
|
// Awaited as the in-process load's async return flattens it: a Promise
|
|
@@ -96,6 +137,7 @@ self.onmessage = async (e) => {
|
|
|
96
137
|
ok: true,
|
|
97
138
|
nonJson,
|
|
98
139
|
json: isObject && nonJson.length === 0 ? JSON.stringify(mod) : null,
|
|
140
|
+
changed: changed(),
|
|
99
141
|
})
|
|
100
142
|
} catch (err) {
|
|
101
143
|
postMessage({
|
|
@@ -104,6 +146,7 @@ self.onmessage = async (e) => {
|
|
|
104
146
|
name: err?.name ?? 'Error',
|
|
105
147
|
message: err?.message ?? String(err),
|
|
106
148
|
stack: err?.stack ?? null,
|
|
149
|
+
changed: changed(),
|
|
107
150
|
position:
|
|
108
151
|
err?.position && typeof err.position === 'object'
|
|
109
152
|
? { file: err.position.file, line: err.position.line, column: err.position.column }
|
|
@@ -295,3 +338,31 @@ export async function evaluateConfigFresh(configPath: string): Promise<unknown>
|
|
|
295
338
|
retireIfIdle()
|
|
296
339
|
}
|
|
297
340
|
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* What evaluating `configPath` alone changes among the watched built-ins
|
|
344
|
+
* and env vars, in a worker of its own that is then discarded: a round
|
|
345
|
+
* whose overlapping loads saw a change asks each config in turn, so the
|
|
346
|
+
* refusal names the one that made it (D-119). Empty when the evaluation
|
|
347
|
+
* changes nothing, fails before reporting, or outlives the budget.
|
|
348
|
+
*/
|
|
349
|
+
export async function builtinsChangedBy(configPath: string): Promise<string[]> {
|
|
350
|
+
const w = new Worker(WORKER_URL)
|
|
351
|
+
try {
|
|
352
|
+
return await new Promise<string[]>((resolve) => {
|
|
353
|
+
const timer = setTimeout(() => resolve([]), evalBudgetMs())
|
|
354
|
+
timer.unref?.()
|
|
355
|
+
w.onmessage = (event: MessageEvent): void => {
|
|
356
|
+
clearTimeout(timer)
|
|
357
|
+
resolve((event.data as { changed?: string[] }).changed ?? [])
|
|
358
|
+
}
|
|
359
|
+
w.onerror = (): void => {
|
|
360
|
+
clearTimeout(timer)
|
|
361
|
+
resolve([])
|
|
362
|
+
}
|
|
363
|
+
w.postMessage({ id: 0, path: path.resolve(configPath), env: { ...process.env }, blame: true })
|
|
364
|
+
})
|
|
365
|
+
} finally {
|
|
366
|
+
w.terminate()
|
|
367
|
+
}
|
|
368
|
+
}
|
|
@@ -777,6 +777,36 @@ const FOREIGN_FIELDS: ReadonlyMap<ReadonlySet<string>, Readonly<Record<string, s
|
|
|
777
777
|
'a command run outside vx: a task never reads the terminal (its stdin is EOF, or a pipe vx holds under `persistent`)',
|
|
778
778
|
},
|
|
779
779
|
],
|
|
780
|
+
// wireit's `files` / `output` and include-style spellings written into
|
|
781
|
+
// a cache block, refused with no word on where they go (D-118).
|
|
782
|
+
[
|
|
783
|
+
CACHE_FIELDS,
|
|
784
|
+
{
|
|
785
|
+
files: '`inputs.files`',
|
|
786
|
+
output: '`outputs.files`',
|
|
787
|
+
env: '`inputs.env`',
|
|
788
|
+
dependencies: "the task's `dependsOn` (an upstream's key folds into this one)",
|
|
789
|
+
enabled: 'no field: a task with a `cache` block caches, one without runs every time',
|
|
790
|
+
},
|
|
791
|
+
],
|
|
792
|
+
[
|
|
793
|
+
CACHE_INPUT_FIELDS,
|
|
794
|
+
{
|
|
795
|
+
globs: '`files`',
|
|
796
|
+
include: '`files`',
|
|
797
|
+
patterns: '`files`',
|
|
798
|
+
exclude: "`files` with a `!` entry (`'!**/*.test.ts'`)",
|
|
799
|
+
ignore: "`files` with a `!` entry (`'!**/*.test.ts'`)",
|
|
800
|
+
},
|
|
801
|
+
],
|
|
802
|
+
[
|
|
803
|
+
CACHE_OUTPUT_FIELDS,
|
|
804
|
+
{
|
|
805
|
+
globs: '`files`',
|
|
806
|
+
include: '`files`',
|
|
807
|
+
exclude: "`files` with a `!` entry (`'!dist/cache/**'`)",
|
|
808
|
+
},
|
|
809
|
+
],
|
|
780
810
|
// A package's turbo.json and Nx's project.json keys, written into a
|
|
781
811
|
// project's vx.config (D-56). `tags` had read as a typo of `tasks`.
|
|
782
812
|
[
|
|
@@ -3,7 +3,14 @@ import path from 'node:path'
|
|
|
3
3
|
import type { ProjectConfig, WorkspaceConfig } from '../config.js'
|
|
4
4
|
import { UserError, xxh3hex } from '../util/index.js'
|
|
5
5
|
import { validateProjectConfig, validateWorkspace } from './config-schema.js'
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
beginEvalRound,
|
|
8
|
+
builtinsChangedBy,
|
|
9
|
+
CONFIG_EXIT,
|
|
10
|
+
evalBudgetMs,
|
|
11
|
+
evaluateConfigFresh,
|
|
12
|
+
WATCHED_BUILTIN_NAMES,
|
|
13
|
+
} from './config-eval.js'
|
|
7
14
|
import { hasEsmExport, unprovidedBareImports } from './config-imports.js'
|
|
8
15
|
import {
|
|
9
16
|
configEvalKey,
|
|
@@ -434,6 +441,9 @@ export async function loadProjectConfigs(
|
|
|
434
441
|
// `node:stream`), ~7 ms of a two-config warm run where every load hit.
|
|
435
442
|
let builtins: BuiltinSnapshot = []
|
|
436
443
|
let env: Readonly<Record<string, string | undefined>> = {}
|
|
444
|
+
// More than one evaluation in flight: a change seen after one load may
|
|
445
|
+
// be another's.
|
|
446
|
+
let overlapping = false
|
|
437
447
|
const loadOne = async (entry: (typeof prepared)[number]): Promise<Loaded> => {
|
|
438
448
|
const { configPath, cacheKey } = entry
|
|
439
449
|
// A fast key that missed: the closure is stale or the file changed.
|
|
@@ -475,7 +485,12 @@ export async function loadProjectConfigs(
|
|
|
475
485
|
// Before anything reads through them: a replaced `Array.prototype.includes`
|
|
476
486
|
// turned the JSON-data walk's own check into "a cyclic reference".
|
|
477
487
|
const changed = repeat ? [] : [...restoreBuiltins(builtins), ...restoreEnv(env)]
|
|
478
|
-
|
|
488
|
+
// Loads overlap, so another config's change can surface after this
|
|
489
|
+
// one: named here, the refusal blamed the wrong file (D-119). The round
|
|
490
|
+
// finds the one that made it.
|
|
491
|
+
if (changed.length > 0) {
|
|
492
|
+
throw overlapping ? new ChangedInRound(changed) : builtinsChanged(changed, configPath)
|
|
493
|
+
}
|
|
479
494
|
assertDefaultObject(mod, 'Project', configPath)
|
|
480
495
|
// Validation runs HERE, on whichever object we ended up with, so a
|
|
481
496
|
// malformed config reports the identical UserError whether it was
|
|
@@ -522,6 +537,7 @@ export async function loadProjectConfigs(
|
|
|
522
537
|
builtins = builtinSnapshot()
|
|
523
538
|
env = { ...process.env }
|
|
524
539
|
}
|
|
540
|
+
overlapping = misses.length > 1
|
|
525
541
|
let next = 0
|
|
526
542
|
const lane = async (): Promise<void> => {
|
|
527
543
|
while (next < misses.length) {
|
|
@@ -539,6 +555,14 @@ export async function loadProjectConfigs(
|
|
|
539
555
|
}
|
|
540
556
|
}
|
|
541
557
|
const changed = misses.length > 0 ? [...restoreBuiltins(builtins), ...restoreEnv(env)] : []
|
|
558
|
+
if (first?.failed instanceof ChangedInRound) {
|
|
559
|
+
for (const i of misses) {
|
|
560
|
+
const configPath = prepared[i]!.configPath
|
|
561
|
+
const own = await builtinsChangedBy(configPath)
|
|
562
|
+
if (own.length > 0) throw builtinsChanged(own, configPath)
|
|
563
|
+
}
|
|
564
|
+
throw builtinsChanged(first.failed.changed)
|
|
565
|
+
}
|
|
542
566
|
if (first !== undefined) throw first.failed
|
|
543
567
|
if (changed.length > 0) throw builtinsChanged(changed)
|
|
544
568
|
return results.map((r) => (r as Loaded).config)
|
|
@@ -565,20 +589,18 @@ export async function loadProjectConfigs(
|
|
|
565
589
|
* load runs in this process, so the round compares them before and after,
|
|
566
590
|
* puts back what changed (a failed load too) and refuses.
|
|
567
591
|
*/
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
['Promise.prototype', Promise.prototype],
|
|
581
|
-
]
|
|
592
|
+
// `Bun` and `Bun.hash` are what vx itself runs on: `Bun.hash.xxHash3 = ()
|
|
593
|
+
// => 7n` gave every task the key 00000000, and a changed command replayed
|
|
594
|
+
// the old output (D-75).
|
|
595
|
+
const WATCHED_BUILTINS: ReadonlyArray<readonly [string, object]> = WATCHED_BUILTIN_NAMES.map(
|
|
596
|
+
(name) =>
|
|
597
|
+
[
|
|
598
|
+
name,
|
|
599
|
+
name
|
|
600
|
+
.split('.')
|
|
601
|
+
.reduce<unknown>((o, k) => (o as Record<string, unknown>)[k], globalThis) as object,
|
|
602
|
+
] as const,
|
|
603
|
+
)
|
|
582
604
|
|
|
583
605
|
interface OwnProperties {
|
|
584
606
|
keys: PropertyKey[]
|
|
@@ -614,7 +636,12 @@ function unchanged(proto: object, keys: readonly PropertyKey[], was: OwnProperti
|
|
|
614
636
|
return true
|
|
615
637
|
}
|
|
616
638
|
|
|
617
|
-
/**
|
|
639
|
+
/** A change seen by one of several overlapping loads; the round names its config. */
|
|
640
|
+
class ChangedInRound {
|
|
641
|
+
constructor(readonly changed: readonly string[]) {}
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
/** Without `configPath`, no config alone was found to make the change. */
|
|
618
645
|
function builtinsChanged(changed: readonly string[], configPath?: string): UserError {
|
|
619
646
|
const who = configPath === undefined ? 'a project config' : configPath
|
|
620
647
|
const env = changed.some((c) => c.startsWith('process.env.'))
|