@vzn/vx 0.0.91 → 0.0.92
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 +6 -0
- package/package.json +5 -5
- package/src/cli/init.ts +11 -1
- package/src/cli/select.ts +12 -1
- package/src/cli/show.ts +7 -4
- package/src/cli/watch-fs.ts +248 -0
- package/src/cli/watch.ts +10 -241
- package/src/orchestrator/plugin-host.ts +9 -1
- package/src/orchestrator/plugin.ts +24 -3
- package/src/workspace/migrate-scripts.ts +9 -0
package/README.md
CHANGED
|
@@ -88,6 +88,8 @@ rm -rf packages/*/dist
|
|
|
88
88
|
npx vx run build --all # restored from the cache, not rebuilt
|
|
89
89
|
```
|
|
90
90
|
|
|
91
|
+
<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.">
|
|
92
|
+
|
|
91
93
|
Or copy [`examples/basic`](examples/basic), a two-package starter CI
|
|
92
94
|
runs on every commit. More: [Quickstart](https://vznjs.github.io/vx/quickstart/) ·
|
|
93
95
|
[Configure](https://vznjs.github.io/vx/guides/configure/) ·
|
|
@@ -112,6 +114,10 @@ When you want configs of your own, one command writes them:
|
|
|
112
114
|
bunx @vzn/vx-migrate # vx.config.ts per package, from turbo.json or an Nx graph
|
|
113
115
|
```
|
|
114
116
|
|
|
117
|
+
The written configs derive the same cache keys, so what `turbo()` cached
|
|
118
|
+
still hits: [`examples/turbo`](examples/turbo) runs both steps on every
|
|
119
|
+
commit.
|
|
120
|
+
|
|
115
121
|
`turboCache()` and `nxCache()` keep the remote cache you have (Turbo's
|
|
116
122
|
`/v8/artifacts` API, Vercel's included, or Nx's `/v1/cache`). What each
|
|
117
123
|
Turbo and Nx feature is in vx, pinned by a test:
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vzn/vx",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.92",
|
|
4
4
|
"description": "A fast, correct task runner and build cache for JavaScript monorepos.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -17,10 +17,10 @@
|
|
|
17
17
|
"node": ">=18"
|
|
18
18
|
},
|
|
19
19
|
"optionalDependencies": {
|
|
20
|
-
"@vzn/vx-linux-x64": "0.0.
|
|
21
|
-
"@vzn/vx-linux-arm64": "0.0.
|
|
22
|
-
"@vzn/vx-darwin-x64": "0.0.
|
|
23
|
-
"@vzn/vx-darwin-arm64": "0.0.
|
|
20
|
+
"@vzn/vx-linux-x64": "0.0.92",
|
|
21
|
+
"@vzn/vx-linux-arm64": "0.0.92",
|
|
22
|
+
"@vzn/vx-darwin-x64": "0.0.92",
|
|
23
|
+
"@vzn/vx-darwin-arm64": "0.0.92"
|
|
24
24
|
},
|
|
25
25
|
"dependencies": {
|
|
26
26
|
"@anthropic-ai/sandbox-runtime": "^0.0.76"
|
package/src/cli/init.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
import path from 'node:path'
|
|
7
7
|
import { flagHint, seeHelp } from './help.js'
|
|
8
|
+
import { isUserError, UserError } from '../util/index.js'
|
|
8
9
|
import {
|
|
9
10
|
applyMigration,
|
|
10
11
|
findWorkspaceRoot,
|
|
@@ -42,7 +43,16 @@ export async function initCmd(args: readonly string[]): Promise<number> {
|
|
|
42
43
|
return 1
|
|
43
44
|
}
|
|
44
45
|
const reads: LoadReads = new Map()
|
|
45
|
-
|
|
46
|
+
// "a workspace from nowhere" still needs a package.json to start from,
|
|
47
|
+
// and the lookup's own refusal named no next step.
|
|
48
|
+
const root = await findWorkspaceRoot(process.cwd(), reads).catch((err: unknown) => {
|
|
49
|
+
if (isUserError(err) && err.message.startsWith('Could not find a workspace root')) {
|
|
50
|
+
throw new UserError(
|
|
51
|
+
'vx init: no package.json here or in any parent directory; create one (`bun init` or `npm init -y`) and run vx init again',
|
|
52
|
+
)
|
|
53
|
+
}
|
|
54
|
+
throw err
|
|
55
|
+
})
|
|
46
56
|
const metas = await listProjects(await loadWorkspace(root, reads))
|
|
47
57
|
// `init` reads scripts only; a runner's own config beside them is the
|
|
48
58
|
// richer source (dependsOn, inputs, outputs) and was ignored without a
|
package/src/cli/select.ts
CHANGED
|
@@ -413,9 +413,20 @@ function didYouMeanProject(
|
|
|
413
413
|
projects: Iterable<{ name: string }>,
|
|
414
414
|
): string {
|
|
415
415
|
const names = [...projects].map((p) => p.name)
|
|
416
|
+
// A scoped name typed without its scope (`vx-mcp` for `@vzn/vx-mcp`) is
|
|
417
|
+
// many edits from the whole name and none from the part after the `/`.
|
|
418
|
+
const byBare = new Map<string, string[]>()
|
|
419
|
+
for (const n of names) {
|
|
420
|
+
const bare = n.slice(n.indexOf('/') + 1)
|
|
421
|
+
if (bare !== n) byBare.set(bare, [...(byBare.get(bare) ?? []), n])
|
|
422
|
+
}
|
|
416
423
|
for (const pattern of unmatched) {
|
|
417
|
-
const
|
|
424
|
+
const typed = pattern.replace(/^!|\.\.\.$|^\.\.\.|\^/g, '')
|
|
425
|
+
const best = nearest(typed, names)
|
|
418
426
|
if (best !== undefined) return `. Did you mean ${best}?`
|
|
427
|
+
const bare = byBare.has(typed) ? typed : nearest(typed, byBare.keys())
|
|
428
|
+
const scoped = bare === undefined ? undefined : byBare.get(bare)
|
|
429
|
+
if (scoped?.length === 1) return `. Did you mean ${scoped[0]}?`
|
|
419
430
|
}
|
|
420
431
|
return ''
|
|
421
432
|
}
|
package/src/cli/show.ts
CHANGED
|
@@ -118,22 +118,25 @@ export async function showCmd(args: readonly string[]): Promise<number> {
|
|
|
118
118
|
const task = declaredTask(config, taskName)
|
|
119
119
|
if (task === undefined) {
|
|
120
120
|
throw new UserError(
|
|
121
|
-
`unknown task: "${meta.name}#${taskName}"${suggest(taskName, Object.keys(config?.tasks ?? {}))}`,
|
|
121
|
+
`unknown task: "${meta.name}#${taskName}"${suggest(taskName, Object.keys(config?.tasks ?? {}), `${meta.name}#`)}`,
|
|
122
122
|
)
|
|
123
123
|
}
|
|
124
124
|
process.stdout.write(renderTask(meta.name, dir, taskName, task, parsed.format))
|
|
125
125
|
return 0
|
|
126
126
|
}
|
|
127
127
|
|
|
128
|
-
/**
|
|
129
|
-
|
|
128
|
+
/**
|
|
129
|
+
* Near misses by edit distance, plus partial names in either direction.
|
|
130
|
+
* `prefix` makes each a spec the user can paste (`app#build`, not `build`).
|
|
131
|
+
*/
|
|
132
|
+
function suggest(query: string, candidates: readonly string[], prefix = ''): string {
|
|
130
133
|
const q = query.toLowerCase()
|
|
131
134
|
const near = new Set(nearMatches(query, candidates))
|
|
132
135
|
for (const c of candidates) {
|
|
133
136
|
const n = c.toLowerCase()
|
|
134
137
|
if (n.includes(q) || q.includes(n)) near.add(c)
|
|
135
138
|
}
|
|
136
|
-
return near.size > 0 ? ` — did you mean ${[...near].join(', ')}?` : ''
|
|
139
|
+
return near.size > 0 ? ` — did you mean ${[...near].map((n) => prefix + n).join(', ')}?` : ''
|
|
137
140
|
}
|
|
138
141
|
|
|
139
142
|
function projectDir(root: string, meta: ProjectMeta): string {
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
// `vx watch`'s file-system side: the OS watcher and the proof it delivers,
|
|
2
|
+
// the stat poller that stands in where it cannot, and the clock both are
|
|
3
|
+
// judged against. Nothing here knows about tasks, cycles or the cache.
|
|
4
|
+
|
|
5
|
+
import fs from 'node:fs'
|
|
6
|
+
import path from 'node:path'
|
|
7
|
+
|
|
8
|
+
/** Paths whose changes never trigger a re-run. */
|
|
9
|
+
export const IGNORED_SEGMENTS = ['node_modules', '.git', '.vx']
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The file `armWatcher` writes under a watched directory to prove the
|
|
13
|
+
* watcher delivers. Intercepted by name before any other handling, so it
|
|
14
|
+
* can never trigger a cycle, and removed before "watching" is printed.
|
|
15
|
+
*/
|
|
16
|
+
export const WATCH_PROBE = '.vx-watch-probe'
|
|
17
|
+
|
|
18
|
+
/** How long a watcher gets to report its own probe before the loop goes on without proof. */
|
|
19
|
+
export const WATCH_PROBE_TIMEOUT_MS = 2_000
|
|
20
|
+
/** The file `fsClockNow` writes and removes, under the cache dir the watchers ignore. */
|
|
21
|
+
const WATCH_CLOCK_STAMP = '.vx-watch-clock'
|
|
22
|
+
|
|
23
|
+
/** Anything the loop needs to shut down at exit. */
|
|
24
|
+
export interface WatchHandle {
|
|
25
|
+
close(): void
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface ArmedWatcher {
|
|
29
|
+
watcher: fs.FSWatcher
|
|
30
|
+
/** Resolves `true` once the watcher reported the probe, `false` on timeout. */
|
|
31
|
+
ready: Promise<boolean>
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** How often the fallback re-walks a watched tree. */
|
|
35
|
+
export const POLL_INTERVAL_MS = 250
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Directory names the fallback never descends into WHEN no better filter is
|
|
39
|
+
* supplied. The justification is that `makeWatchIgnore` already drops their
|
|
40
|
+
* EVENTS, so the walk buys nothing — a poller pays for it either way, and
|
|
41
|
+
* `node_modules` is the difference between a cheap fallback and one that
|
|
42
|
+
* re-stats 40 000 files four times a second.
|
|
43
|
+
*
|
|
44
|
+
* That justification is exactly `IGNORED_SEGMENTS`, so this IS that set.
|
|
45
|
+
* It used to carry a fourth name, `dist`, which the justification does NOT
|
|
46
|
+
* cover: `dist` is dropped only when a project DECLARES it as an output, and
|
|
47
|
+
* a project that does not declare it had its `dist/` sources silently
|
|
48
|
+
* invisible to `vx watch` on every host the poller exists for — a macOS
|
|
49
|
+
* sandbox, a network mount, a container bind — while the native watcher
|
|
50
|
+
* delivered them. Two watchers disagreeing about what an edit is (item 482).
|
|
51
|
+
* The real per-project answer is the caller's `skipDir`, below.
|
|
52
|
+
*/
|
|
53
|
+
const POLL_SKIP = new Set(IGNORED_SEGMENTS)
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* A watcher built from `stat`, for when the OS one cannot deliver.
|
|
57
|
+
*
|
|
58
|
+
* `fs.watch` on macOS is FSEvents, which needs `mach-lookup` on
|
|
59
|
+
* `com.apple.FSEvents`; inside a sandbox that does not grant it the call
|
|
60
|
+
* SUCCEEDS and then never fires (measured 2026-09-05: 0 events recursive,
|
|
61
|
+
* 0 non-recursive, against 3 and 2 for the same writes outside — while
|
|
62
|
+
* `fs.watchFile` polling delivered in both). A network filesystem or a
|
|
63
|
+
* container bind mount fails the same way. Polling is slower and coarser,
|
|
64
|
+
* and it is the difference between `vx watch` working there and silently
|
|
65
|
+
* doing nothing.
|
|
66
|
+
*/
|
|
67
|
+
export function pollWatcher(
|
|
68
|
+
dir: string,
|
|
69
|
+
recursive: boolean,
|
|
70
|
+
onEvent: (filename: string) => void,
|
|
71
|
+
intervalMs = POLL_INTERVAL_MS,
|
|
72
|
+
/**
|
|
73
|
+
* Directories not worth descending into, by their path relative to `dir`.
|
|
74
|
+
* The watch loop passes its own event filter, so the poller skips exactly
|
|
75
|
+
* what the filter would drop anyway — every declared output container, not
|
|
76
|
+
* a hard-coded name. The default covers the unconditional segments alone.
|
|
77
|
+
*/
|
|
78
|
+
skipDir: (rel: string) => boolean = (rel) => POLL_SKIP.has(path.basename(rel)),
|
|
79
|
+
): WatchHandle {
|
|
80
|
+
let previous = new Map<string, number>()
|
|
81
|
+
let first = true
|
|
82
|
+
const scan = (): void => {
|
|
83
|
+
const current = new Map<string, number>()
|
|
84
|
+
const walk = (abs: string, rel: string): void => {
|
|
85
|
+
let entries: fs.Dirent[]
|
|
86
|
+
try {
|
|
87
|
+
entries = fs.readdirSync(abs, { withFileTypes: true })
|
|
88
|
+
} catch {
|
|
89
|
+
return // vanished or unreadable: its files simply stop appearing
|
|
90
|
+
}
|
|
91
|
+
for (const e of entries) {
|
|
92
|
+
if (e.name === WATCH_PROBE) continue
|
|
93
|
+
const childRel = rel === '' ? e.name : `${rel}/${e.name}`
|
|
94
|
+
if (e.isDirectory()) {
|
|
95
|
+
if (recursive && !skipDir(childRel)) walk(path.join(abs, e.name), childRel)
|
|
96
|
+
continue
|
|
97
|
+
}
|
|
98
|
+
if (!e.isFile()) continue
|
|
99
|
+
try {
|
|
100
|
+
// The later of the two clocks, as `modifiedBefore` reads them: a
|
|
101
|
+
// replacement that carries the old file's mtime (`cp -p`, `rsync
|
|
102
|
+
// -a`, `mv` of a file stamped the same) moved nothing under mtime
|
|
103
|
+
// alone, and the poller never ran it where the native watcher did.
|
|
104
|
+
// A rename or a write moves ctime, and no process can set it.
|
|
105
|
+
const st = fs.statSync(path.join(abs, e.name))
|
|
106
|
+
current.set(childRel, Math.max(st.mtimeMs, st.ctimeMs))
|
|
107
|
+
} catch {
|
|
108
|
+
// raced with a delete; the next scan settles it
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
walk(dir, '')
|
|
113
|
+
if (!first) {
|
|
114
|
+
for (const [rel, mtime] of current) {
|
|
115
|
+
if (previous.get(rel) !== mtime) onEvent(rel)
|
|
116
|
+
}
|
|
117
|
+
for (const rel of previous.keys()) {
|
|
118
|
+
if (!current.has(rel)) onEvent(rel)
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
previous = current
|
|
122
|
+
first = false
|
|
123
|
+
}
|
|
124
|
+
scan()
|
|
125
|
+
// Deliberately NOT unref'd: once the native watcher is closed this timer
|
|
126
|
+
// is the only thing keeping `vx watch` alive.
|
|
127
|
+
const timer = setInterval(scan, intervalMs)
|
|
128
|
+
return {
|
|
129
|
+
close(): void {
|
|
130
|
+
clearInterval(timer)
|
|
131
|
+
},
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* `fs.watch` plus proof of delivery. On macOS a recursive watcher is an
|
|
137
|
+
* FSEvents stream that another thread schedules AFTER the call returns, and
|
|
138
|
+
* a change landing in that gap is never delivered — MEASURED 2026-09-03: a
|
|
139
|
+
* write made immediately after `fs.watch` was lost 5 times in 30 under CPU
|
|
140
|
+
* load (0 in 30 idle, 0 in 30 after a 50 ms pause). The gap has no fixed
|
|
141
|
+
* width, so no pause is the answer and no timeout on the waiting side ever
|
|
142
|
+
* was (the e2e flake this closes had one of 45 s). A probe file written
|
|
143
|
+
* under the watcher and waited for is: once ITS event arrives, the stream
|
|
144
|
+
* is live for everything after it.
|
|
145
|
+
*
|
|
146
|
+
* `onEvent` never sees the probe (create or unlink), and the probe is
|
|
147
|
+
* removed before `ready` resolves.
|
|
148
|
+
*/
|
|
149
|
+
export function armWatcher(
|
|
150
|
+
dir: string,
|
|
151
|
+
recursive: boolean,
|
|
152
|
+
onEvent: (filename: string) => void,
|
|
153
|
+
timeoutMs = WATCH_PROBE_TIMEOUT_MS,
|
|
154
|
+
): ArmedWatcher {
|
|
155
|
+
let markReady: (ok: boolean) => void = () => {}
|
|
156
|
+
const seen = new Promise<boolean>((resolve) => {
|
|
157
|
+
markReady = resolve
|
|
158
|
+
})
|
|
159
|
+
const watcher = fs.watch(dir, { recursive, persistent: true }, (_event, filename) => {
|
|
160
|
+
if (filename == null || typeof filename !== 'string') return
|
|
161
|
+
// An event naming the watched directory itself (macOS reports the
|
|
162
|
+
// directory a write landed in as its own item) carries nothing a key
|
|
163
|
+
// can see; the write's own event names the file.
|
|
164
|
+
if (filename === '' || filename === '.') return
|
|
165
|
+
if (filename === WATCH_PROBE) {
|
|
166
|
+
markReady(true)
|
|
167
|
+
return
|
|
168
|
+
}
|
|
169
|
+
// Another arm's probe, seen by this recursive watcher under a nested
|
|
170
|
+
// directory (a nested project's, a member base inside a project): no
|
|
171
|
+
// key sees it, and passing it on ran a cycle, and restarted a dev
|
|
172
|
+
// server, with no edit made (item 1016). Only the watcher's own probe
|
|
173
|
+
// proves this stream live.
|
|
174
|
+
if (path.basename(filename) === WATCH_PROBE) return
|
|
175
|
+
onEvent(filename)
|
|
176
|
+
})
|
|
177
|
+
const probe = path.join(dir, WATCH_PROBE)
|
|
178
|
+
const ready = (async (): Promise<boolean> => {
|
|
179
|
+
// The probe is subject to the very race it detects: a write that lands
|
|
180
|
+
// in the gap is lost like any other (1 in 20 under a full gate's load,
|
|
181
|
+
// measured 2026-09-03, with every delivered event under 60 ms). So it is
|
|
182
|
+
// re-written on a short backoff until its event arrives — the first write
|
|
183
|
+
// after the stream goes live is the one that proves it.
|
|
184
|
+
const deadline = Date.now() + timeoutMs
|
|
185
|
+
let ok = false
|
|
186
|
+
let step = 50
|
|
187
|
+
while (!ok) {
|
|
188
|
+
try {
|
|
189
|
+
fs.writeFileSync(probe, String(Date.now()))
|
|
190
|
+
} catch {
|
|
191
|
+
break // an unwritable dir gets no proof; the watcher is kept
|
|
192
|
+
}
|
|
193
|
+
const remaining = deadline - Date.now()
|
|
194
|
+
if (remaining <= 0) break
|
|
195
|
+
const pause = new Promise<boolean>((resolve) => {
|
|
196
|
+
setTimeout(() => resolve(false), Math.min(step, remaining)).unref()
|
|
197
|
+
})
|
|
198
|
+
ok = await Promise.race([seen, pause])
|
|
199
|
+
step = Math.min(step * 2, 400)
|
|
200
|
+
}
|
|
201
|
+
try {
|
|
202
|
+
fs.unlinkSync(probe)
|
|
203
|
+
} catch {
|
|
204
|
+
// already gone
|
|
205
|
+
}
|
|
206
|
+
return ok
|
|
207
|
+
})()
|
|
208
|
+
return { watcher, ready }
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** True when `abs` was last modified before `t` (epoch ms); false when it cannot be read. */
|
|
212
|
+
export function modifiedBefore(abs: string, t: number): boolean {
|
|
213
|
+
try {
|
|
214
|
+
// The later of the two clocks: `mv`, `cp -p`, `rsync -a` and `tar x`
|
|
215
|
+
// carry a file's OLD mtime onto the new one, and judged by mtime alone
|
|
216
|
+
// such an edit was "before the arm" and never ran (item 945). No
|
|
217
|
+
// process can set a ctime, and a rename or a write moves it.
|
|
218
|
+
const st = fs.statSync(abs)
|
|
219
|
+
return Math.max(st.mtimeMs, st.ctimeMs) < t
|
|
220
|
+
} catch {
|
|
221
|
+
return false
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* "Now" as the filesystem will stamp the next write, not as `Date.now()`
|
|
227
|
+
* reads it. A file's mtime comes from the kernel's coarse clock, which
|
|
228
|
+
* runs up to a tick behind the fine clock `Date.now()` reads — measured
|
|
229
|
+
* on the Linux bench box 2026-09-11: 2 of 3,000 tight writes carried an
|
|
230
|
+
* mtime 5.8 ms EARLIER than a `Date.now()` taken before the write, and
|
|
231
|
+
* more under CPU load, where the tick is skipped. An `armedAt` from the
|
|
232
|
+
* fine clock then judged an edit made right after the ready line as
|
|
233
|
+
* "modified before the arm" and dropped it (the watch e2e flake, three
|
|
234
|
+
* shard runs that day; traced: raw event, trigger, `same=true`). A stamp
|
|
235
|
+
* read off a file written here is on the mtime clock itself, and every
|
|
236
|
+
* later write's mtime is at or after it.
|
|
237
|
+
*/
|
|
238
|
+
export function fsClockNow(dir: string): number {
|
|
239
|
+
const stamp = path.join(dir, WATCH_CLOCK_STAMP)
|
|
240
|
+
try {
|
|
241
|
+
fs.writeFileSync(stamp, '')
|
|
242
|
+
const at = fs.statSync(stamp).mtimeMs
|
|
243
|
+
fs.unlinkSync(stamp)
|
|
244
|
+
return at
|
|
245
|
+
} catch {
|
|
246
|
+
return Date.now()
|
|
247
|
+
}
|
|
248
|
+
}
|
package/src/cli/watch.ts
CHANGED
|
@@ -50,12 +50,20 @@ import {
|
|
|
50
50
|
import type { ProjectConfig } from '../config.js'
|
|
51
51
|
import { type CliLoadOptions, loadCliProjects, loadCliWorkspace } from './workspace-config.js'
|
|
52
52
|
import { taskEdges, taskEdgesFrom } from './select.js'
|
|
53
|
+
import {
|
|
54
|
+
armWatcher,
|
|
55
|
+
fsClockNow,
|
|
56
|
+
IGNORED_SEGMENTS,
|
|
57
|
+
modifiedBefore,
|
|
58
|
+
pollWatcher,
|
|
59
|
+
POLL_INTERVAL_MS,
|
|
60
|
+
WATCH_PROBE_TIMEOUT_MS,
|
|
61
|
+
type WatchHandle,
|
|
62
|
+
} from './watch-fs.js'
|
|
53
63
|
|
|
54
64
|
/** Wait this long after the last filesystem event before re-running. */
|
|
55
65
|
const DEBOUNCE_MS = 150
|
|
56
66
|
|
|
57
|
-
/** Paths whose changes never trigger a re-run. */
|
|
58
|
-
const IGNORED_SEGMENTS = ['node_modules', '.git', '.vx']
|
|
59
67
|
const IGNORED_SUFFIXES = ['.tsbuildinfo', '~']
|
|
60
68
|
|
|
61
69
|
/**
|
|
@@ -167,13 +175,6 @@ function outputContainer(raw: string): string {
|
|
|
167
175
|
return prefix === '.' || prefix === '/' ? '' : prefix
|
|
168
176
|
}
|
|
169
177
|
|
|
170
|
-
/**
|
|
171
|
-
* The file `armWatcher` writes under a watched directory to prove the
|
|
172
|
-
* watcher delivers. Intercepted by name before any other handling, so it
|
|
173
|
-
* can never trigger a cycle, and removed before "watching" is printed.
|
|
174
|
-
*/
|
|
175
|
-
export const WATCH_PROBE = '.vx-watch-probe'
|
|
176
|
-
|
|
177
178
|
/**
|
|
178
179
|
* The subset of `paths` git ignores, asked once per judgement (one
|
|
179
180
|
* `git check-ignore` per debounce window that has candidates, never per
|
|
@@ -210,199 +211,6 @@ export function gitIgnored(workspaceRoot: string, paths: readonly string[]): Set
|
|
|
210
211
|
return ignored
|
|
211
212
|
}
|
|
212
213
|
|
|
213
|
-
/** How long a watcher gets to report its own probe before the loop goes on without proof. */
|
|
214
|
-
const WATCH_PROBE_TIMEOUT_MS = 2_000
|
|
215
|
-
/** The file `fsClockNow` writes and removes, under the cache dir the watchers ignore. */
|
|
216
|
-
const WATCH_CLOCK_STAMP = '.vx-watch-clock'
|
|
217
|
-
|
|
218
|
-
/** Anything the loop needs to shut down at exit. */
|
|
219
|
-
export interface WatchHandle {
|
|
220
|
-
close(): void
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
export interface ArmedWatcher {
|
|
224
|
-
watcher: fs.FSWatcher
|
|
225
|
-
/** Resolves `true` once the watcher reported the probe, `false` on timeout. */
|
|
226
|
-
ready: Promise<boolean>
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
/** How often the fallback re-walks a watched tree. */
|
|
230
|
-
const POLL_INTERVAL_MS = 250
|
|
231
|
-
|
|
232
|
-
/**
|
|
233
|
-
* Directory names the fallback never descends into WHEN no better filter is
|
|
234
|
-
* supplied. The justification is that `makeWatchIgnore` already drops their
|
|
235
|
-
* EVENTS, so the walk buys nothing — a poller pays for it either way, and
|
|
236
|
-
* `node_modules` is the difference between a cheap fallback and one that
|
|
237
|
-
* re-stats 40 000 files four times a second.
|
|
238
|
-
*
|
|
239
|
-
* That justification is exactly `IGNORED_SEGMENTS`, so this IS that set.
|
|
240
|
-
* It used to carry a fourth name, `dist`, which the justification does NOT
|
|
241
|
-
* cover: `dist` is dropped only when a project DECLARES it as an output, and
|
|
242
|
-
* a project that does not declare it had its `dist/` sources silently
|
|
243
|
-
* invisible to `vx watch` on every host the poller exists for — a macOS
|
|
244
|
-
* sandbox, a network mount, a container bind — while the native watcher
|
|
245
|
-
* delivered them. Two watchers disagreeing about what an edit is (item 482).
|
|
246
|
-
* The real per-project answer is the caller's `skipDir`, below.
|
|
247
|
-
*/
|
|
248
|
-
const POLL_SKIP = new Set(IGNORED_SEGMENTS)
|
|
249
|
-
|
|
250
|
-
/**
|
|
251
|
-
* A watcher built from `stat`, for when the OS one cannot deliver.
|
|
252
|
-
*
|
|
253
|
-
* `fs.watch` on macOS is FSEvents, which needs `mach-lookup` on
|
|
254
|
-
* `com.apple.FSEvents`; inside a sandbox that does not grant it the call
|
|
255
|
-
* SUCCEEDS and then never fires (measured 2026-09-05: 0 events recursive,
|
|
256
|
-
* 0 non-recursive, against 3 and 2 for the same writes outside — while
|
|
257
|
-
* `fs.watchFile` polling delivered in both). A network filesystem or a
|
|
258
|
-
* container bind mount fails the same way. Polling is slower and coarser,
|
|
259
|
-
* and it is the difference between `vx watch` working there and silently
|
|
260
|
-
* doing nothing.
|
|
261
|
-
*/
|
|
262
|
-
export function pollWatcher(
|
|
263
|
-
dir: string,
|
|
264
|
-
recursive: boolean,
|
|
265
|
-
onEvent: (filename: string) => void,
|
|
266
|
-
intervalMs = POLL_INTERVAL_MS,
|
|
267
|
-
/**
|
|
268
|
-
* Directories not worth descending into, by their path relative to `dir`.
|
|
269
|
-
* The watch loop passes its own event filter, so the poller skips exactly
|
|
270
|
-
* what the filter would drop anyway — every declared output container, not
|
|
271
|
-
* a hard-coded name. The default covers the unconditional segments alone.
|
|
272
|
-
*/
|
|
273
|
-
skipDir: (rel: string) => boolean = (rel) => POLL_SKIP.has(path.basename(rel)),
|
|
274
|
-
): WatchHandle {
|
|
275
|
-
let previous = new Map<string, number>()
|
|
276
|
-
let first = true
|
|
277
|
-
const scan = (): void => {
|
|
278
|
-
const current = new Map<string, number>()
|
|
279
|
-
const walk = (abs: string, rel: string): void => {
|
|
280
|
-
let entries: fs.Dirent[]
|
|
281
|
-
try {
|
|
282
|
-
entries = fs.readdirSync(abs, { withFileTypes: true })
|
|
283
|
-
} catch {
|
|
284
|
-
return // vanished or unreadable: its files simply stop appearing
|
|
285
|
-
}
|
|
286
|
-
for (const e of entries) {
|
|
287
|
-
if (e.name === WATCH_PROBE) continue
|
|
288
|
-
const childRel = rel === '' ? e.name : `${rel}/${e.name}`
|
|
289
|
-
if (e.isDirectory()) {
|
|
290
|
-
if (recursive && !skipDir(childRel)) walk(path.join(abs, e.name), childRel)
|
|
291
|
-
continue
|
|
292
|
-
}
|
|
293
|
-
if (!e.isFile()) continue
|
|
294
|
-
try {
|
|
295
|
-
// The later of the two clocks, as `modifiedBefore` reads them: a
|
|
296
|
-
// replacement that carries the old file's mtime (`cp -p`, `rsync
|
|
297
|
-
// -a`, `mv` of a file stamped the same) moved nothing under mtime
|
|
298
|
-
// alone, and the poller never ran it where the native watcher did.
|
|
299
|
-
// A rename or a write moves ctime, and no process can set it.
|
|
300
|
-
const st = fs.statSync(path.join(abs, e.name))
|
|
301
|
-
current.set(childRel, Math.max(st.mtimeMs, st.ctimeMs))
|
|
302
|
-
} catch {
|
|
303
|
-
// raced with a delete; the next scan settles it
|
|
304
|
-
}
|
|
305
|
-
}
|
|
306
|
-
}
|
|
307
|
-
walk(dir, '')
|
|
308
|
-
if (!first) {
|
|
309
|
-
for (const [rel, mtime] of current) {
|
|
310
|
-
if (previous.get(rel) !== mtime) onEvent(rel)
|
|
311
|
-
}
|
|
312
|
-
for (const rel of previous.keys()) {
|
|
313
|
-
if (!current.has(rel)) onEvent(rel)
|
|
314
|
-
}
|
|
315
|
-
}
|
|
316
|
-
previous = current
|
|
317
|
-
first = false
|
|
318
|
-
}
|
|
319
|
-
scan()
|
|
320
|
-
// Deliberately NOT unref'd: once the native watcher is closed this timer
|
|
321
|
-
// is the only thing keeping `vx watch` alive.
|
|
322
|
-
const timer = setInterval(scan, intervalMs)
|
|
323
|
-
return {
|
|
324
|
-
close(): void {
|
|
325
|
-
clearInterval(timer)
|
|
326
|
-
},
|
|
327
|
-
}
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
/**
|
|
331
|
-
* `fs.watch` plus proof of delivery. On macOS a recursive watcher is an
|
|
332
|
-
* FSEvents stream that another thread schedules AFTER the call returns, and
|
|
333
|
-
* a change landing in that gap is never delivered — MEASURED 2026-09-03: a
|
|
334
|
-
* write made immediately after `fs.watch` was lost 5 times in 30 under CPU
|
|
335
|
-
* load (0 in 30 idle, 0 in 30 after a 50 ms pause). The gap has no fixed
|
|
336
|
-
* width, so no pause is the answer and no timeout on the waiting side ever
|
|
337
|
-
* was (the e2e flake this closes had one of 45 s). A probe file written
|
|
338
|
-
* under the watcher and waited for is: once ITS event arrives, the stream
|
|
339
|
-
* is live for everything after it.
|
|
340
|
-
*
|
|
341
|
-
* `onEvent` never sees the probe (create or unlink), and the probe is
|
|
342
|
-
* removed before `ready` resolves.
|
|
343
|
-
*/
|
|
344
|
-
export function armWatcher(
|
|
345
|
-
dir: string,
|
|
346
|
-
recursive: boolean,
|
|
347
|
-
onEvent: (filename: string) => void,
|
|
348
|
-
timeoutMs = WATCH_PROBE_TIMEOUT_MS,
|
|
349
|
-
): ArmedWatcher {
|
|
350
|
-
let markReady: (ok: boolean) => void = () => {}
|
|
351
|
-
const seen = new Promise<boolean>((resolve) => {
|
|
352
|
-
markReady = resolve
|
|
353
|
-
})
|
|
354
|
-
const watcher = fs.watch(dir, { recursive, persistent: true }, (_event, filename) => {
|
|
355
|
-
if (filename == null || typeof filename !== 'string') return
|
|
356
|
-
// An event naming the watched directory itself (macOS reports the
|
|
357
|
-
// directory a write landed in as its own item) carries nothing a key
|
|
358
|
-
// can see; the write's own event names the file.
|
|
359
|
-
if (filename === '' || filename === '.') return
|
|
360
|
-
if (filename === WATCH_PROBE) {
|
|
361
|
-
markReady(true)
|
|
362
|
-
return
|
|
363
|
-
}
|
|
364
|
-
// Another arm's probe, seen by this recursive watcher under a nested
|
|
365
|
-
// directory (a nested project's, a member base inside a project): no
|
|
366
|
-
// key sees it, and passing it on ran a cycle, and restarted a dev
|
|
367
|
-
// server, with no edit made (item 1016). Only the watcher's own probe
|
|
368
|
-
// proves this stream live.
|
|
369
|
-
if (path.basename(filename) === WATCH_PROBE) return
|
|
370
|
-
onEvent(filename)
|
|
371
|
-
})
|
|
372
|
-
const probe = path.join(dir, WATCH_PROBE)
|
|
373
|
-
const ready = (async (): Promise<boolean> => {
|
|
374
|
-
// The probe is subject to the very race it detects: a write that lands
|
|
375
|
-
// in the gap is lost like any other (1 in 20 under a full gate's load,
|
|
376
|
-
// measured 2026-09-03, with every delivered event under 60 ms). So it is
|
|
377
|
-
// re-written on a short backoff until its event arrives — the first write
|
|
378
|
-
// after the stream goes live is the one that proves it.
|
|
379
|
-
const deadline = Date.now() + timeoutMs
|
|
380
|
-
let ok = false
|
|
381
|
-
let step = 50
|
|
382
|
-
while (!ok) {
|
|
383
|
-
try {
|
|
384
|
-
fs.writeFileSync(probe, String(Date.now()))
|
|
385
|
-
} catch {
|
|
386
|
-
break // an unwritable dir gets no proof; the watcher is kept
|
|
387
|
-
}
|
|
388
|
-
const remaining = deadline - Date.now()
|
|
389
|
-
if (remaining <= 0) break
|
|
390
|
-
const pause = new Promise<boolean>((resolve) => {
|
|
391
|
-
setTimeout(() => resolve(false), Math.min(step, remaining)).unref()
|
|
392
|
-
})
|
|
393
|
-
ok = await Promise.race([seen, pause])
|
|
394
|
-
step = Math.min(step * 2, 400)
|
|
395
|
-
}
|
|
396
|
-
try {
|
|
397
|
-
fs.unlinkSync(probe)
|
|
398
|
-
} catch {
|
|
399
|
-
// already gone
|
|
400
|
-
}
|
|
401
|
-
return ok
|
|
402
|
-
})()
|
|
403
|
-
return { watcher, ready }
|
|
404
|
-
}
|
|
405
|
-
|
|
406
214
|
/**
|
|
407
215
|
* The run flags a watch loop cannot honour, as the refusal line it prints; null
|
|
408
216
|
* when there is none. `WATCH_REFUSED_FLAGS` (help.ts) is the same list for
|
|
@@ -1516,45 +1324,6 @@ export function pendingAfterCycle(
|
|
|
1516
1324
|
return [abs, label]
|
|
1517
1325
|
}
|
|
1518
1326
|
|
|
1519
|
-
/** True when `abs` was last modified before `t` (epoch ms); false when it cannot be read. */
|
|
1520
|
-
export function modifiedBefore(abs: string, t: number): boolean {
|
|
1521
|
-
try {
|
|
1522
|
-
// The later of the two clocks: `mv`, `cp -p`, `rsync -a` and `tar x`
|
|
1523
|
-
// carry a file's OLD mtime onto the new one, and judged by mtime alone
|
|
1524
|
-
// such an edit was "before the arm" and never ran (item 945). No
|
|
1525
|
-
// process can set a ctime, and a rename or a write moves it.
|
|
1526
|
-
const st = fs.statSync(abs)
|
|
1527
|
-
return Math.max(st.mtimeMs, st.ctimeMs) < t
|
|
1528
|
-
} catch {
|
|
1529
|
-
return false
|
|
1530
|
-
}
|
|
1531
|
-
}
|
|
1532
|
-
|
|
1533
|
-
/**
|
|
1534
|
-
* "Now" as the filesystem will stamp the next write, not as `Date.now()`
|
|
1535
|
-
* reads it. A file's mtime comes from the kernel's coarse clock, which
|
|
1536
|
-
* runs up to a tick behind the fine clock `Date.now()` reads — measured
|
|
1537
|
-
* on the Linux bench box 2026-09-11: 2 of 3,000 tight writes carried an
|
|
1538
|
-
* mtime 5.8 ms EARLIER than a `Date.now()` taken before the write, and
|
|
1539
|
-
* more under CPU load, where the tick is skipped. An `armedAt` from the
|
|
1540
|
-
* fine clock then judged an edit made right after the ready line as
|
|
1541
|
-
* "modified before the arm" and dropped it (the watch e2e flake, three
|
|
1542
|
-
* shard runs that day; traced: raw event, trigger, `same=true`). A stamp
|
|
1543
|
-
* read off a file written here is on the mtime clock itself, and every
|
|
1544
|
-
* later write's mtime is at or after it.
|
|
1545
|
-
*/
|
|
1546
|
-
export function fsClockNow(dir: string): number {
|
|
1547
|
-
const stamp = path.join(dir, WATCH_CLOCK_STAMP)
|
|
1548
|
-
try {
|
|
1549
|
-
fs.writeFileSync(stamp, '')
|
|
1550
|
-
const at = fs.statSync(stamp).mtimeMs
|
|
1551
|
-
fs.unlinkSync(stamp)
|
|
1552
|
-
return at
|
|
1553
|
-
} catch {
|
|
1554
|
-
return Date.now()
|
|
1555
|
-
}
|
|
1556
|
-
}
|
|
1557
|
-
|
|
1558
1327
|
/**
|
|
1559
1328
|
* The directory entries under a package glob's directory that can be
|
|
1560
1329
|
* members: directories (or links) whose name is not dotted and not
|
|
@@ -323,7 +323,15 @@ export function buildAdmission(
|
|
|
323
323
|
for (const plugin of answering) {
|
|
324
324
|
if (broken.has(plugin)) continue
|
|
325
325
|
try {
|
|
326
|
-
|
|
326
|
+
const verdict: unknown = plugin.admit!(task, ctx)
|
|
327
|
+
// An `async admit` answers a Promise: truthy, so it admitted every
|
|
328
|
+
// task and its policy never ran, and its rejection was unhandled and
|
|
329
|
+
// ended the run with a stack (H-16). Named once, admitting after.
|
|
330
|
+
if (verdict instanceof Promise) {
|
|
331
|
+
verdict.catch(() => {})
|
|
332
|
+
throw new Error('returned a Promise; admit is synchronous')
|
|
333
|
+
}
|
|
334
|
+
if (verdict !== false) continue
|
|
327
335
|
if (running.size > 0) return false
|
|
328
336
|
if (!overridden.has(plugin)) {
|
|
329
337
|
overridden.add(plugin)
|
|
@@ -130,8 +130,9 @@ export interface VxPlugin {
|
|
|
130
130
|
* Admission over the worker count: asked for every task about to start
|
|
131
131
|
* on this machine, with the tasks running here right now. Return `false`
|
|
132
132
|
* to hold it until something finishes (it is asked again then). Asked
|
|
133
|
-
* many times per run, so it must be cheap and synchronous; a throw
|
|
134
|
-
*
|
|
133
|
+
* many times per run, so it must be cheap and synchronous; a throw, or
|
|
134
|
+
* an answer that is a Promise, is reported once and the plugin admits
|
|
135
|
+
* from then on — a policy never
|
|
135
136
|
* breaks a run. Restore-tier hits and tasks on an executor pool hold no
|
|
136
137
|
* local resources and are never asked. When several plugins answer, all
|
|
137
138
|
* must admit. Core keeps no notion of what a task needs: a plugin that
|
|
@@ -144,7 +145,8 @@ export interface VxPlugin {
|
|
|
144
145
|
|
|
145
146
|
/**
|
|
146
147
|
* Verbs this plugin adds to the `vx` CLI, keyed by name. Consulted only
|
|
147
|
-
* for a verb core does not know —
|
|
148
|
+
* for a verb core does not know — a verb named like a core verb is
|
|
149
|
+
* refused at load — and only
|
|
148
150
|
* when the cwd is inside a workspace that declares the plugin. `vx help`
|
|
149
151
|
* lists them under "Plugin commands". A verb's exit code is the process
|
|
150
152
|
* exit code; a thrown `UserError` prints cleanly, like core's own.
|
|
@@ -348,6 +350,18 @@ export type PluginHookName =
|
|
|
348
350
|
| 'onRunStatus'
|
|
349
351
|
| 'onRunEnd'
|
|
350
352
|
|
|
353
|
+
// A Record so the compiler holds it to `PluginHookName` both ways: a name
|
|
354
|
+
// missing here, or one the type lacks, does not compile.
|
|
355
|
+
const HOOK_NAMES: Record<PluginHookName, true> = {
|
|
356
|
+
onRunStart: true,
|
|
357
|
+
onTaskStart: true,
|
|
358
|
+
onTaskStdout: true,
|
|
359
|
+
onTaskStderr: true,
|
|
360
|
+
onTaskComplete: true,
|
|
361
|
+
onRunStatus: true,
|
|
362
|
+
onRunEnd: true,
|
|
363
|
+
}
|
|
364
|
+
|
|
351
365
|
export interface PluginHookHandlers {
|
|
352
366
|
onRunStart: (info: RunStartInfo) => void | Promise<void>
|
|
353
367
|
onTaskStart: (node: TaskNode) => void | Promise<void>
|
|
@@ -508,6 +522,13 @@ export async function installPlugins(args: InstallPluginsArgs): Promise<() => vo
|
|
|
508
522
|
},
|
|
509
523
|
},
|
|
510
524
|
on(hook, handler) {
|
|
525
|
+
// A misspelt name ('taskComplete') subscribed a handler that never
|
|
526
|
+
// ran, and no word of it; a config file has no type-checker (H-16).
|
|
527
|
+
if (!Object.hasOwn(HOOK_NAMES, hook)) {
|
|
528
|
+
throw new Error(
|
|
529
|
+
`ctx.on: unknown hook '${String(hook)}' (one of ${Object.keys(HOOK_NAMES).join(', ')})`,
|
|
530
|
+
)
|
|
531
|
+
}
|
|
511
532
|
const fail = (err: unknown): void => {
|
|
512
533
|
if (disabled.has(plugin.name)) return
|
|
513
534
|
disabled.add(plugin.name)
|
|
@@ -116,6 +116,15 @@ function upstreamBuildOnWorker(tasks: GeneratedTask[]): void {
|
|
|
116
116
|
at = Array.isArray(deps) ? byName.get(String(deps[0])) : undefined
|
|
117
117
|
}
|
|
118
118
|
if (at === undefined || at.name === 'build' || at.task === null || at.task === undefined) return
|
|
119
|
+
// The worker IS `build`'s work, and so is every group on the way to it:
|
|
120
|
+
// a `typecheck` behind `build: npm run typecheck` waited for `build`,
|
|
121
|
+
// and the run refused the cycle (D-16).
|
|
122
|
+
for (const name of [...seen, at.name]) {
|
|
123
|
+
const t = byName.get(name)!.task!
|
|
124
|
+
if (name !== 'build' && Array.isArray(t['dependsOn'])) {
|
|
125
|
+
t['dependsOn'] = (t['dependsOn'] as string[]).filter((d) => d !== 'build')
|
|
126
|
+
}
|
|
127
|
+
}
|
|
119
128
|
const deps = Array.isArray(at.task['dependsOn']) ? (at.task['dependsOn'] as string[]) : []
|
|
120
129
|
if (!deps.includes('^build')) at.task['dependsOn'] = ['^build', ...deps]
|
|
121
130
|
if (!at.todos.includes(CACHE_TODO)) at.todos.push(CACHE_TODO)
|