@vzn/vx 0.0.16 → 0.0.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +1 -1
- package/README.md +135 -124
- package/index.ts +10 -0
- package/launcher.mjs +10 -11
- package/package.json +11 -7
- package/src/bin.ts +14 -4
- package/src/cache/archive.ts +659 -0
- package/src/cache/cache.ts +769 -1101
- package/src/cache/chained-cache.ts +194 -0
- package/src/cache/config-evals.ts +89 -0
- package/src/cache/file-hashes.ts +262 -0
- package/src/cache/git-inputs.ts +637 -0
- package/src/cache/index.ts +24 -5
- package/src/cache/inputs.ts +373 -456
- package/src/cache/layer.ts +604 -0
- package/src/cache/layered-cache.ts +96 -43
- package/src/cache/output-index.ts +224 -0
- package/src/cache/policy.ts +84 -0
- package/src/cache/run-history.ts +147 -0
- package/src/cache/tar-stream.ts +358 -0
- package/src/cache/zstd.ts +160 -0
- package/src/cli/cache.ts +75 -21
- package/src/cli/completions.ts +104 -0
- package/src/cli/core-alias.ts +25 -0
- package/src/cli/help.ts +195 -128
- package/src/cli/index.ts +116 -46
- package/src/cli/info.ts +122 -63
- package/src/cli/init.ts +75 -0
- package/src/cli/last.ts +172 -0
- package/src/cli/lock.ts +2 -1
- package/src/cli/plan-format.ts +72 -6
- package/src/cli/plugin-commands.ts +103 -0
- package/src/cli/run.ts +245 -271
- package/src/cli/select.ts +332 -0
- package/src/cli/show.ts +144 -69
- package/src/cli/upgrade.ts +9 -7
- package/src/cli/watch.ts +858 -110
- package/src/cli/why.ts +223 -0
- package/src/cli/workspace-config.ts +126 -0
- package/src/config.ts +187 -150
- package/src/exec/env.ts +18 -1
- package/src/exec/executor.ts +224 -0
- package/src/exec/index.ts +24 -2
- package/src/exec/local-executor.ts +41 -0
- package/src/exec/runner.ts +166 -40
- package/src/exec/sandbox-binds.ts +182 -0
- package/src/exec/sandbox-paths.ts +50 -0
- package/src/exec/sandbox-runtime.ts +642 -260
- package/src/exec/sandbox-violations.ts +280 -0
- package/src/graph/index.ts +4 -10
- package/src/graph/scheduler.ts +232 -203
- package/src/graph/task-graph.ts +222 -8
- package/src/index.ts +146 -153
- package/src/orchestrator/admission.ts +137 -0
- package/src/orchestrator/deferred-outputs.ts +147 -0
- package/src/orchestrator/doctor.ts +255 -0
- package/src/orchestrator/download-policy.ts +171 -0
- package/src/orchestrator/events.ts +72 -8
- package/src/orchestrator/execute-task.ts +374 -549
- package/src/orchestrator/failure-mode.ts +202 -0
- package/src/orchestrator/framed-output.ts +126 -69
- package/src/orchestrator/history.ts +126 -59
- package/src/orchestrator/hit-restore.ts +221 -0
- package/src/orchestrator/index.ts +77 -93
- package/src/orchestrator/local-shortcircuit.ts +22 -0
- package/src/orchestrator/lockfile-claim.ts +281 -0
- package/src/orchestrator/logger.ts +208 -35
- package/src/orchestrator/metrics.ts +102 -1388
- package/src/orchestrator/miss-save.ts +169 -0
- package/src/orchestrator/options.ts +55 -34
- package/src/orchestrator/persistent.ts +79 -0
- package/src/orchestrator/placement.ts +182 -0
- package/src/orchestrator/plan.ts +144 -1
- package/src/orchestrator/plugin-host.ts +357 -120
- package/src/orchestrator/plugin.ts +298 -58
- package/src/orchestrator/prepare.ts +202 -106
- package/src/orchestrator/projects.ts +259 -0
- package/src/orchestrator/remote-prefetch.ts +12 -8
- package/src/orchestrator/run-artifacts.ts +63 -16
- package/src/orchestrator/run-context.ts +167 -29
- package/src/orchestrator/run-records.ts +149 -0
- package/src/orchestrator/run-report.ts +74 -47
- package/src/orchestrator/run.ts +534 -417
- package/src/orchestrator/sandbox-request.ts +243 -0
- package/src/orchestrator/save-lane.ts +47 -0
- package/src/orchestrator/signals.ts +111 -0
- package/src/orchestrator/stable-keys.ts +129 -31
- package/src/orchestrator/status-line.ts +49 -9
- package/src/orchestrator/summary.ts +90 -26
- package/src/orchestrator/tally.ts +78 -35
- package/src/orchestrator/task-hash.ts +111 -15
- package/src/orchestrator/task-log-buffer.ts +288 -0
- package/src/orchestrator/telemetry-host.ts +34 -6
- package/src/orchestrator/telemetry.ts +182 -36
- package/src/orchestrator/upstream.ts +72 -31
- package/src/util/cgroup.ts +160 -0
- package/src/util/edit-distance.ts +70 -0
- package/src/util/errors.ts +15 -1
- package/src/util/index.ts +15 -2
- package/src/util/num.ts +37 -0
- package/src/util/paths.ts +64 -0
- package/src/util/settle.ts +67 -0
- package/src/util/size.ts +5 -1
- package/src/util/tail.ts +66 -0
- package/src/util/timing.ts +63 -0
- package/src/util/verbs.ts +39 -0
- package/src/workspace/affected.ts +250 -23
- package/src/workspace/config-cache.ts +313 -0
- package/src/workspace/config-eval.ts +217 -0
- package/src/workspace/config-imports.ts +162 -0
- package/src/workspace/config-schema.ts +837 -0
- package/src/workspace/filter.ts +65 -10
- package/src/workspace/fingerprint.ts +53 -4
- package/src/workspace/index.ts +42 -3
- package/src/workspace/lockfile.ts +25 -9
- package/src/workspace/migrate-scripts.ts +134 -0
- package/src/workspace/migration.ts +314 -0
- package/src/workspace/nested-dirs.ts +21 -7
- package/src/workspace/package-graph.ts +114 -41
- package/src/workspace/project-loader.ts +224 -450
- package/src/workspace/workspace.ts +298 -72
- package/src/cache/cas-backend.ts +0 -97
- package/src/cache/digest.ts +0 -47
- package/src/cache/tar.ts +0 -343
- package/src/cli/backend.ts +0 -23
- package/src/cli/mcp-rpc.ts +0 -292
- package/src/cli/mcp.ts +0 -92
- package/src/cli/migrate-nx.ts +0 -450
- package/src/cli/migrate-turbo.ts +0 -364
- package/src/cli/migrate.ts +0 -219
- package/src/orchestrator/devframe-surface.ts +0 -74
- package/src/orchestrator/predict.ts +0 -106
- package/src/orchestrator/protocol.ts +0 -156
- package/src/orchestrator/resources.ts +0 -59
- package/src/orchestrator/run-state.ts +0 -145
- package/src/orchestrator/verify.ts +0 -164
- package/src/orchestrator/wire-render.ts +0 -92
- package/src/orchestrator/wire.ts +0 -212
package/src/cli/watch.ts
CHANGED
|
@@ -14,15 +14,25 @@
|
|
|
14
14
|
|
|
15
15
|
import fs from 'node:fs'
|
|
16
16
|
import path from 'node:path'
|
|
17
|
+
import { normalizeGlob, staticPrefix, xxh3 } from '../util/index.js'
|
|
18
|
+
import { asTrees } from '../cache/index.js'
|
|
17
19
|
import { parseRunArgs, resolveRunOptions } from './run.js'
|
|
18
20
|
import { run as runOrchestrator, type RunOptions } from '../orchestrator/index.js'
|
|
19
21
|
import {
|
|
22
|
+
buildPackageGraph,
|
|
20
23
|
findWorkspaceRoot,
|
|
21
24
|
listProjects,
|
|
22
25
|
loadProjectConfig,
|
|
23
26
|
loadWorkspace,
|
|
27
|
+
memberBaseDirs,
|
|
28
|
+
WORKSPACE_CONFIG_FILENAMES,
|
|
29
|
+
WORKSPACE_FINGERPRINT_FILES,
|
|
30
|
+
type ProjectEntry,
|
|
24
31
|
type ProjectMeta,
|
|
25
32
|
} from '../workspace/index.js'
|
|
33
|
+
import type { ProjectConfig } from '../config.js'
|
|
34
|
+
import { type CliLoadOptions, loadCliProjects, loadCliWorkspace } from './workspace-config.js'
|
|
35
|
+
import { taskEdges, taskEdgesFrom } from './select.js'
|
|
26
36
|
|
|
27
37
|
/** Wait this long after the last filesystem event before re-running. */
|
|
28
38
|
const DEBOUNCE_MS = 150
|
|
@@ -31,6 +41,263 @@ const DEBOUNCE_MS = 150
|
|
|
31
41
|
const IGNORED_SEGMENTS = ['node_modules', '.git', '.vx']
|
|
32
42
|
const IGNORED_SUFFIXES = ['.tsbuildinfo', '~']
|
|
33
43
|
|
|
44
|
+
/**
|
|
45
|
+
* True for a path the ignore filter drops. Every project dir is watched
|
|
46
|
+
* RECURSIVELY, so without this a `bun install` would re-run the graph on every
|
|
47
|
+
* file it writes, and vx's own `.vx/cache` writes would trigger a cycle that
|
|
48
|
+
* writes to `.vx/cache` again.
|
|
49
|
+
*
|
|
50
|
+
* Segment-wise, not prefix-wise: `node_modules` is ignored wherever it appears
|
|
51
|
+
* in the path, not only at the root.
|
|
52
|
+
*/
|
|
53
|
+
export function isIgnoredWatchPath(rel: string): boolean {
|
|
54
|
+
const segments = rel.split(path.sep)
|
|
55
|
+
if (segments.some((s) => IGNORED_SEGMENTS.includes(s))) return true
|
|
56
|
+
if (IGNORED_SUFFIXES.some((suf) => rel.endsWith(suf))) return true
|
|
57
|
+
return false
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The ignore predicate a watcher applies, closed over the run's ACTUAL cache
|
|
62
|
+
* directory.
|
|
63
|
+
*
|
|
64
|
+
* `IGNORED_SEGMENTS` covers the `.vx/cache` default, but `cacheDir` is a
|
|
65
|
+
* shipped `defineWorkspace` field: point it anywhere else and vx's own cache
|
|
66
|
+
* writes land in a watched subtree, so every cycle triggers the next one.
|
|
67
|
+
* Measured on a relocated cache under a recursive root watcher: ONE edit kicked
|
|
68
|
+
* it and the loop then ran **22 more times during 6 seconds of total silence**
|
|
69
|
+
* (~3.7 re-runs/second, forever) where the default `.vx` cache settles at 0.
|
|
70
|
+
* A hard-coded literal cannot see a configured path — the resolved one can.
|
|
71
|
+
*/
|
|
72
|
+
export function makeWatchIgnore(
|
|
73
|
+
cacheDir: string,
|
|
74
|
+
outputs: ReadonlyMap<string, readonly string[]> = new Map(),
|
|
75
|
+
): (base: string, filename: string) => boolean {
|
|
76
|
+
const cacheAbs = path.resolve(cacheDir)
|
|
77
|
+
// A task's own outputs are not edits: without this every cycle that
|
|
78
|
+
// writes `dist/` (or `out.txt`) re-runs once more, reporting
|
|
79
|
+
// "up-to-date" for the trouble. Matched under the directory the globs
|
|
80
|
+
// are relative to, whichever watcher delivered the event.
|
|
81
|
+
// The directory that HOLDS an output tree is the task's too: `dist/**`
|
|
82
|
+
// does not match `dist`, and since the clean before a miss prunes an
|
|
83
|
+
// emptied `dist` (2026-09-10) the task re-creates it, which the watcher
|
|
84
|
+
// reports as a change to `dist` itself — a second cycle per edit,
|
|
85
|
+
// reporting "up-to-date". The literal prefix of each glob (`dist` for
|
|
86
|
+
// `dist/**`, `build/out` for `build/out/*.js`; nothing for `*.js`) and
|
|
87
|
+
// every ancestor of it under the dir are output containers.
|
|
88
|
+
// A literal entry means the file or its whole tree — the resolver's own
|
|
89
|
+
// rule (`asTrees`), so the tree's files never count as edits.
|
|
90
|
+
const declared = [...outputs].map(
|
|
91
|
+
([dir, globs]) =>
|
|
92
|
+
[
|
|
93
|
+
path.resolve(dir),
|
|
94
|
+
asTrees(globs).map((g) => new Bun.Glob(g)),
|
|
95
|
+
globs.map(outputContainer).filter((c) => c !== ''),
|
|
96
|
+
] as const,
|
|
97
|
+
)
|
|
98
|
+
return (base, filename) => {
|
|
99
|
+
if (isIgnoredWatchPath(filename)) return true
|
|
100
|
+
const abs = path.resolve(base, filename)
|
|
101
|
+
if (abs === cacheAbs || abs.startsWith(cacheAbs + path.sep)) return true
|
|
102
|
+
for (const [dir, globs, containers] of declared) {
|
|
103
|
+
if (!abs.startsWith(dir + path.sep)) continue
|
|
104
|
+
const rel = abs
|
|
105
|
+
.slice(dir.length + 1)
|
|
106
|
+
.split(path.sep)
|
|
107
|
+
.join('/')
|
|
108
|
+
if (globs.some((g) => g.match(rel))) return true
|
|
109
|
+
if (containers.some((c) => c === rel || c.startsWith(`${rel}/`))) return true
|
|
110
|
+
}
|
|
111
|
+
return false
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* The literal directory a glob's matches live under (`''` when the glob
|
|
117
|
+
* starts with a pattern, or negates). A literal entry is a file or its
|
|
118
|
+
* whole tree (schema: literal → tree), so the entry itself is the
|
|
119
|
+
* container; a pattern's is `staticPrefix` — the same rule the sandbox
|
|
120
|
+
* baseline and the deferral gate read.
|
|
121
|
+
*/
|
|
122
|
+
export function outputContainer(raw: string): string {
|
|
123
|
+
const glob = normalizeGlob(raw)
|
|
124
|
+
if (glob.startsWith('!')) return ''
|
|
125
|
+
if (!/[*?[\]{}]/.test(glob)) return glob.replace(/\/+$/, '')
|
|
126
|
+
const prefix = staticPrefix(glob)
|
|
127
|
+
return prefix === '.' || prefix === '/' ? '' : prefix
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* The file `armWatcher` writes under a watched directory to prove the
|
|
132
|
+
* watcher delivers. Intercepted by name before any other handling, so it
|
|
133
|
+
* can never trigger a cycle, and removed before "watching" is printed.
|
|
134
|
+
*/
|
|
135
|
+
export const WATCH_PROBE = '.vx-watch-probe'
|
|
136
|
+
|
|
137
|
+
/** How long a watcher gets to report its own probe before the loop goes on without proof. */
|
|
138
|
+
const WATCH_PROBE_TIMEOUT_MS = 2_000
|
|
139
|
+
/** The file `fsClockNow` writes and removes, under the cache dir the watchers ignore. */
|
|
140
|
+
const WATCH_CLOCK_STAMP = '.vx-watch-clock'
|
|
141
|
+
|
|
142
|
+
/** Anything the loop needs to shut down at exit. */
|
|
143
|
+
export interface WatchHandle {
|
|
144
|
+
close(): void
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export interface ArmedWatcher {
|
|
148
|
+
watcher: fs.FSWatcher
|
|
149
|
+
/** Resolves `true` once the watcher reported the probe, `false` on timeout. */
|
|
150
|
+
ready: Promise<boolean>
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** How often the fallback re-walks a watched tree. */
|
|
154
|
+
const POLL_INTERVAL_MS = 250
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Directory names the fallback never descends into. `makeWatchIgnore`
|
|
158
|
+
* already drops their EVENTS, but a poller pays for the walk itself, and
|
|
159
|
+
* `node_modules` is the difference between a cheap fallback and one that
|
|
160
|
+
* re-stats 40 000 files four times a second.
|
|
161
|
+
*/
|
|
162
|
+
const POLL_SKIP = new Set(['node_modules', '.git', '.vx', 'dist'])
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* A watcher built from `stat`, for when the OS one cannot deliver.
|
|
166
|
+
*
|
|
167
|
+
* `fs.watch` on macOS is FSEvents, which needs `mach-lookup` on
|
|
168
|
+
* `com.apple.FSEvents`; inside a sandbox that does not grant it the call
|
|
169
|
+
* SUCCEEDS and then never fires (measured 2026-09-05: 0 events recursive,
|
|
170
|
+
* 0 non-recursive, against 3 and 2 for the same writes outside — while
|
|
171
|
+
* `fs.watchFile` polling delivered in both). A network filesystem or a
|
|
172
|
+
* container bind mount fails the same way. Polling is slower and coarser,
|
|
173
|
+
* and it is the difference between `vx watch` working there and silently
|
|
174
|
+
* doing nothing.
|
|
175
|
+
*/
|
|
176
|
+
export function pollWatcher(
|
|
177
|
+
dir: string,
|
|
178
|
+
recursive: boolean,
|
|
179
|
+
onEvent: (filename: string) => void,
|
|
180
|
+
intervalMs = POLL_INTERVAL_MS,
|
|
181
|
+
): WatchHandle {
|
|
182
|
+
let previous = new Map<string, number>()
|
|
183
|
+
let first = true
|
|
184
|
+
const scan = (): void => {
|
|
185
|
+
const current = new Map<string, number>()
|
|
186
|
+
const walk = (abs: string, rel: string): void => {
|
|
187
|
+
let entries: fs.Dirent[]
|
|
188
|
+
try {
|
|
189
|
+
entries = fs.readdirSync(abs, { withFileTypes: true })
|
|
190
|
+
} catch {
|
|
191
|
+
return // vanished or unreadable: its files simply stop appearing
|
|
192
|
+
}
|
|
193
|
+
for (const e of entries) {
|
|
194
|
+
if (e.name === WATCH_PROBE) continue
|
|
195
|
+
const childRel = rel === '' ? e.name : `${rel}/${e.name}`
|
|
196
|
+
if (e.isDirectory()) {
|
|
197
|
+
if (recursive && !POLL_SKIP.has(e.name)) walk(path.join(abs, e.name), childRel)
|
|
198
|
+
continue
|
|
199
|
+
}
|
|
200
|
+
if (!e.isFile()) continue
|
|
201
|
+
try {
|
|
202
|
+
current.set(childRel, fs.statSync(path.join(abs, e.name)).mtimeMs)
|
|
203
|
+
} catch {
|
|
204
|
+
// raced with a delete; the next scan settles it
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
walk(dir, '')
|
|
209
|
+
if (!first) {
|
|
210
|
+
for (const [rel, mtime] of current) {
|
|
211
|
+
if (previous.get(rel) !== mtime) onEvent(rel)
|
|
212
|
+
}
|
|
213
|
+
for (const rel of previous.keys()) {
|
|
214
|
+
if (!current.has(rel)) onEvent(rel)
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
previous = current
|
|
218
|
+
first = false
|
|
219
|
+
}
|
|
220
|
+
scan()
|
|
221
|
+
// Deliberately NOT unref'd: once the native watcher is closed this timer
|
|
222
|
+
// is the only thing keeping `vx watch` alive.
|
|
223
|
+
const timer = setInterval(scan, intervalMs)
|
|
224
|
+
return {
|
|
225
|
+
close(): void {
|
|
226
|
+
clearInterval(timer)
|
|
227
|
+
},
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* `fs.watch` plus proof of delivery. On macOS a recursive watcher is an
|
|
233
|
+
* FSEvents stream that another thread schedules AFTER the call returns, and
|
|
234
|
+
* a change landing in that gap is never delivered — MEASURED 2026-09-03: a
|
|
235
|
+
* write made immediately after `fs.watch` was lost 5 times in 30 under CPU
|
|
236
|
+
* load (0 in 30 idle, 0 in 30 after a 50 ms pause). The gap has no fixed
|
|
237
|
+
* width, so no pause is the answer and no timeout on the waiting side ever
|
|
238
|
+
* was (the e2e flake this closes had one of 45 s). A probe file written
|
|
239
|
+
* under the watcher and waited for is: once ITS event arrives, the stream
|
|
240
|
+
* is live for everything after it.
|
|
241
|
+
*
|
|
242
|
+
* `onEvent` never sees the probe (create or unlink), and the probe is
|
|
243
|
+
* removed before `ready` resolves.
|
|
244
|
+
*/
|
|
245
|
+
export function armWatcher(
|
|
246
|
+
dir: string,
|
|
247
|
+
recursive: boolean,
|
|
248
|
+
onEvent: (filename: string) => void,
|
|
249
|
+
timeoutMs = WATCH_PROBE_TIMEOUT_MS,
|
|
250
|
+
): ArmedWatcher {
|
|
251
|
+
let markReady: (ok: boolean) => void = () => {}
|
|
252
|
+
const seen = new Promise<boolean>((resolve) => {
|
|
253
|
+
markReady = resolve
|
|
254
|
+
})
|
|
255
|
+
const watcher = fs.watch(dir, { recursive, persistent: true }, (_event, filename) => {
|
|
256
|
+
if (filename == null || typeof filename !== 'string') return
|
|
257
|
+
// An event naming the watched directory itself (macOS reports the
|
|
258
|
+
// directory a write landed in as its own item) carries nothing a key
|
|
259
|
+
// can see; the write's own event names the file.
|
|
260
|
+
if (filename === '' || filename === '.') return
|
|
261
|
+
if (filename === WATCH_PROBE) {
|
|
262
|
+
markReady(true)
|
|
263
|
+
return
|
|
264
|
+
}
|
|
265
|
+
onEvent(filename)
|
|
266
|
+
})
|
|
267
|
+
const probe = path.join(dir, WATCH_PROBE)
|
|
268
|
+
const ready = (async (): Promise<boolean> => {
|
|
269
|
+
// The probe is subject to the very race it detects: a write that lands
|
|
270
|
+
// in the gap is lost like any other (1 in 20 under a full gate's load,
|
|
271
|
+
// measured 2026-09-03, with every delivered event under 60 ms). So it is
|
|
272
|
+
// re-written on a short backoff until its event arrives — the first write
|
|
273
|
+
// after the stream goes live is the one that proves it.
|
|
274
|
+
const deadline = Date.now() + timeoutMs
|
|
275
|
+
let ok = false
|
|
276
|
+
let step = 50
|
|
277
|
+
while (!ok) {
|
|
278
|
+
try {
|
|
279
|
+
fs.writeFileSync(probe, String(Date.now()))
|
|
280
|
+
} catch {
|
|
281
|
+
break // an unwritable dir gets no proof; the watcher is kept
|
|
282
|
+
}
|
|
283
|
+
const remaining = deadline - Date.now()
|
|
284
|
+
if (remaining <= 0) break
|
|
285
|
+
const pause = new Promise<boolean>((resolve) => {
|
|
286
|
+
setTimeout(() => resolve(false), Math.min(step, remaining)).unref()
|
|
287
|
+
})
|
|
288
|
+
ok = await Promise.race([seen, pause])
|
|
289
|
+
step = Math.min(step * 2, 400)
|
|
290
|
+
}
|
|
291
|
+
try {
|
|
292
|
+
fs.unlinkSync(probe)
|
|
293
|
+
} catch {
|
|
294
|
+
// already gone
|
|
295
|
+
}
|
|
296
|
+
return ok
|
|
297
|
+
})()
|
|
298
|
+
return { watcher, ready }
|
|
299
|
+
}
|
|
300
|
+
|
|
34
301
|
export async function watchCmd(args: readonly string[]): Promise<number> {
|
|
35
302
|
const parsed = parseRunArgs(args)
|
|
36
303
|
if (parsed.error) {
|
|
@@ -48,6 +315,15 @@ export async function watchCmd(args: readonly string[]): Promise<number> {
|
|
|
48
315
|
)
|
|
49
316
|
return 1
|
|
50
317
|
}
|
|
318
|
+
// All three format ONE run's result and are consumed by `runCmd` alone, so
|
|
319
|
+
// a watch loop silently ignored them. `--verbosity 0` is not rejected: it
|
|
320
|
+
// asks for the output watch already gives.
|
|
321
|
+
if (parsed.report !== undefined || parsed.reportFile !== undefined || parsed.verbosity > 0) {
|
|
322
|
+
process.stderr.write(
|
|
323
|
+
`vx watch: --report / --report-file / --verbosity are not supported in watch mode (they report a single run)\n`,
|
|
324
|
+
)
|
|
325
|
+
return 1
|
|
326
|
+
}
|
|
51
327
|
if (parsed.tasks.length === 0) {
|
|
52
328
|
process.stderr.write(`vx watch: missing task name\n`)
|
|
53
329
|
return 1
|
|
@@ -59,23 +335,44 @@ export async function watchCmd(args: readonly string[]): Promise<number> {
|
|
|
59
335
|
process.stderr.write(`vx watch: ${resolved.error}\n`)
|
|
60
336
|
return 1
|
|
61
337
|
}
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
// loop
|
|
67
|
-
|
|
338
|
+
if ('nothingSelected' in resolved) {
|
|
339
|
+
process.stderr.write(`vx watch: ${resolved.nothingSelected}\n`)
|
|
340
|
+
return 0
|
|
341
|
+
}
|
|
342
|
+
// The watch loop owns SIGINT/SIGTERM for its whole lifetime. A cycle's
|
|
343
|
+
// run() must not install its exit-the-process handlers — Ctrl-C
|
|
344
|
+
// mid-cycle would kill the loop with 130 instead of the loop's own
|
|
345
|
+
// clean shutdown — so it gets `signal` instead: on SIGINT/SIGTERM the
|
|
346
|
+
// controller aborts, the in-flight cycle tears its children down
|
|
347
|
+
// (SIGTERM, grace, SIGKILL) and returns, and the loop resolves 0.
|
|
348
|
+
// Installed BEFORE the initial run: until 2026-09-10 the handlers went
|
|
349
|
+
// in with the loop, so a SIGTERM during the initial run took Bun's
|
|
350
|
+
// default (exit 143) and left the cycle's children running under init.
|
|
351
|
+
const stop = new AbortController()
|
|
352
|
+
const opts: RunOptions = { ...resolved, handleSignals: false, signal: stop.signal }
|
|
353
|
+
// A staged load from the selection pass is one run's worth of configs;
|
|
354
|
+
// every cycle after an edit must evaluate live.
|
|
355
|
+
delete opts.staged
|
|
356
|
+
process.once('SIGINT', () => {
|
|
357
|
+
process.stdout.write('\nvx watch: stopped\n')
|
|
358
|
+
stop.abort()
|
|
359
|
+
})
|
|
360
|
+
process.once('SIGTERM', () => stop.abort())
|
|
68
361
|
|
|
69
362
|
// Enumerate projects-in-scope so we know what dirs to watch.
|
|
70
363
|
// `opts.projects` is the resolved scope; undefined means "every
|
|
71
|
-
// project".
|
|
72
|
-
//
|
|
364
|
+
// project". The watched set is what a cycle can RUN: the scope plus
|
|
365
|
+
// its transitive dependencies (a cycle runs `lib#build` for
|
|
366
|
+
// `app#build`'s `^build`, so a `lib` edit is an edit) — the same
|
|
367
|
+
// closure `--filter 'app...'` walks, computed below once the initial
|
|
368
|
+
// run has staged the configs. Plus the workspace root, for lockfile
|
|
369
|
+
// changes.
|
|
73
370
|
const workspaceRoot = await findWorkspaceRoot(cwd)
|
|
74
|
-
const
|
|
75
|
-
const
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
371
|
+
const workspace = await loadWorkspace(workspaceRoot)
|
|
372
|
+
const allProjects = await listProjects(workspace)
|
|
373
|
+
const inScope = (all: readonly ProjectMeta[]): ProjectMeta[] =>
|
|
374
|
+
opts.projects === undefined ? [...all] : all.filter((p) => opts.projects!.includes(p.name))
|
|
375
|
+
const scope = inScope(allProjects)
|
|
79
376
|
|
|
80
377
|
if (scope.length === 0) {
|
|
81
378
|
process.stderr.write(`vx watch: no projects in scope\n`)
|
|
@@ -83,78 +380,351 @@ export async function watchCmd(args: readonly string[]): Promise<number> {
|
|
|
83
380
|
}
|
|
84
381
|
|
|
85
382
|
// Initial run — same code path as `vx run`.
|
|
383
|
+
//
|
|
384
|
+
// Watchers do not exist yet, so an edit made WHILE this run is executing is
|
|
385
|
+
// dropped by the OS and never triggers a re-run. Known and deliberate: the
|
|
386
|
+
// obvious fix (install watchers first) forces `anyTaskUsesWorkspaceFiles`
|
|
387
|
+
// ahead of the run to decide which watchers to install, and that marks every
|
|
388
|
+
// config loaded — so the initial run's own loads become REPEATs and pay a
|
|
389
|
+
// worker round-trip each (see config-eval.ts). Trading a hot-path regression
|
|
390
|
+
// on every `vx watch` for a window a user rarely edits into is a bad deal;
|
|
391
|
+
// closing it properly needs the workspaceWide decision made without loading
|
|
392
|
+
// configs.
|
|
86
393
|
process.stdout.write('vx watch: initial run...\n\n')
|
|
87
394
|
await runOrchestrator(opts)
|
|
395
|
+
if (stop.signal.aborted) return 0
|
|
88
396
|
|
|
397
|
+
const load: CliLoadOptions = {
|
|
398
|
+
...(opts.cacheDir !== undefined ? { cacheDir: opts.cacheDir } : {}),
|
|
399
|
+
...(opts.frozen === true ? { frozen: true } : {}),
|
|
400
|
+
}
|
|
401
|
+
const swept = await sweepConfigs(allProjects, workspaceRoot, load)
|
|
402
|
+
const watched = await watchedProjects(workspaceRoot, allProjects, scope, load, swept.staged)
|
|
89
403
|
return await runWatchLoop({
|
|
90
404
|
opts,
|
|
405
|
+
stop: stop.signal,
|
|
91
406
|
workspaceRoot,
|
|
92
|
-
projects:
|
|
93
|
-
workspaceWide:
|
|
407
|
+
projects: watched,
|
|
408
|
+
workspaceWide: swept.workspaceWide,
|
|
409
|
+
projectDirs: watched.map((p) => p.dir),
|
|
410
|
+
workspaceInputs: swept.workspaceInputs,
|
|
411
|
+
outputs: swept.outputs,
|
|
412
|
+
memberBases: memberBaseDirs(workspace),
|
|
413
|
+
// The workspace as the cycle that just ran saw it: a package added or
|
|
414
|
+
// removed since the loop armed joins or leaves the watched set. The
|
|
415
|
+
// scope is the one resolved at start; a new package joins it only as a
|
|
416
|
+
// dependency of it.
|
|
417
|
+
rediscover: async () => {
|
|
418
|
+
const all = await listProjects(await loadWorkspace(workspaceRoot))
|
|
419
|
+
const sweep = await sweepConfigs(all, workspaceRoot, load)
|
|
420
|
+
const now = await watchedProjects(workspaceRoot, all, inScope(all), load, sweep.staged)
|
|
421
|
+
return { projects: now, workspaceInputs: sweep.workspaceInputs, outputs: sweep.outputs }
|
|
422
|
+
},
|
|
423
|
+
// The RESOLVED cache dir, not the `.vx` literal — see `makeWatchIgnore`.
|
|
424
|
+
cacheDir: opts.cacheDir ?? (await loadCliWorkspace(workspaceRoot)).cacheDir,
|
|
94
425
|
})
|
|
95
426
|
}
|
|
96
427
|
|
|
97
428
|
/**
|
|
98
|
-
*
|
|
99
|
-
* the
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
*
|
|
103
|
-
*
|
|
104
|
-
*
|
|
429
|
+
* The projects a cycle can run, so both watcher arms cover the same dirs:
|
|
430
|
+
* the scope plus its transitive dependencies through the package graph
|
|
431
|
+
* and the cross-project `dependsOn` edges the task graph adds
|
|
432
|
+
* (`e2e` → `app` from `dependsOn: ['app#build']`). Before this, the
|
|
433
|
+
* per-project arm watched the filter's answer only, so
|
|
434
|
+
* `vx watch build --filter app` never re-ran on a `lib` edit that
|
|
435
|
+
* `vx run build --filter app` would have rebuilt. A whole-workspace
|
|
436
|
+
* scope is every project already and walks nothing. A config the loader
|
|
437
|
+
* rejects contributes no edges here; the run that just happened said so.
|
|
105
438
|
*/
|
|
106
|
-
async function
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
439
|
+
export async function watchedProjects(
|
|
440
|
+
workspaceRoot: string,
|
|
441
|
+
allProjects: readonly ProjectMeta[],
|
|
442
|
+
scope: readonly ProjectMeta[],
|
|
443
|
+
load: CliLoadOptions = {},
|
|
444
|
+
staged: ReadonlyMap<string, ProjectEntry> | null = null,
|
|
445
|
+
): Promise<ProjectMeta[]> {
|
|
446
|
+
if (scope.length === allProjects.length) return [...allProjects]
|
|
447
|
+
let edges: Map<string, string[]> | undefined
|
|
448
|
+
try {
|
|
449
|
+
edges =
|
|
450
|
+
staged !== null
|
|
451
|
+
? taskEdgesFrom(staged)
|
|
452
|
+
: (await taskEdges(workspaceRoot, allProjects, load)).edges
|
|
453
|
+
} catch {
|
|
454
|
+
edges = undefined
|
|
455
|
+
}
|
|
456
|
+
const graph = buildPackageGraph([...allProjects], edges)
|
|
457
|
+
const names = new Set(scope.map((p) => p.name))
|
|
458
|
+
for (const p of scope) for (const d of graph.transitiveDeps(p.name)) names.add(d)
|
|
459
|
+
return allProjects.filter((p) => names.has(p.name))
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/**
|
|
463
|
+
* One sweep over every config for the two things the loop needs: whether
|
|
464
|
+
* any task declares `inputs.workspaceFiles` — globs with no project
|
|
465
|
+
* prefix, so the whole tree has to be watched — and every project's
|
|
466
|
+
* declared outputs, so their writes are not taken for edits. The run
|
|
467
|
+
* path's load (`loadProjects`): the plugin `project` stage applies, so an
|
|
468
|
+
* output a plugin gave a config-less package is ignored like a declared
|
|
469
|
+
* one, and a pure config is served from its cached evaluation rather than
|
|
470
|
+
* re-evaluated in a worker. A config that fails to load is out of this
|
|
471
|
+
* concern's scope — the run that just happened already said so, or will —
|
|
472
|
+
* and the sweep falls back to the files that do load, one by one.
|
|
473
|
+
*/
|
|
474
|
+
export async function sweepConfigs(
|
|
475
|
+
projects: readonly ProjectMeta[],
|
|
476
|
+
workspaceRoot: string,
|
|
477
|
+
load: CliLoadOptions = {},
|
|
478
|
+
): Promise<{
|
|
479
|
+
workspaceWide: boolean
|
|
480
|
+
workspaceInputs: string[]
|
|
481
|
+
outputs: Map<string, string[]>
|
|
482
|
+
/** The staged load the sweep read, when the run path's load succeeded; `watchedProjects` reads the same one. */
|
|
483
|
+
staged: Map<string, ProjectEntry> | null
|
|
484
|
+
}> {
|
|
485
|
+
const outputs = new Map<string, string[]>()
|
|
486
|
+
const add = (dir: string, globs: readonly string[] | undefined): void => {
|
|
487
|
+
if (globs === undefined || globs.length === 0) return
|
|
488
|
+
outputs.set(dir, [...(outputs.get(dir) ?? []), ...globs])
|
|
489
|
+
}
|
|
490
|
+
const workspaceInputs = new Set<string>()
|
|
491
|
+
const fold = (dir: string, config: ProjectConfig): void => {
|
|
492
|
+
for (const task of Object.values(config.tasks ?? {})) {
|
|
493
|
+
for (const g of task.cache?.inputs?.workspaceFiles ?? []) workspaceInputs.add(g)
|
|
494
|
+
add(dir, task.cache?.outputs?.files)
|
|
495
|
+
add(workspaceRoot, task.cache?.outputs?.workspaceFiles)
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
const result = (
|
|
499
|
+
staged: Map<string, ProjectEntry> | null,
|
|
500
|
+
): {
|
|
501
|
+
workspaceWide: boolean
|
|
502
|
+
workspaceInputs: string[]
|
|
503
|
+
outputs: Map<string, string[]>
|
|
504
|
+
staged: Map<string, ProjectEntry> | null
|
|
505
|
+
} => ({
|
|
506
|
+
workspaceWide: workspaceInputs.size > 0,
|
|
507
|
+
workspaceInputs: [...workspaceInputs],
|
|
508
|
+
outputs,
|
|
509
|
+
staged,
|
|
510
|
+
})
|
|
511
|
+
let staged: Map<string, ProjectEntry> | null = null
|
|
512
|
+
try {
|
|
513
|
+
staged = await loadCliProjects(workspaceRoot, projects, 'all', load)
|
|
514
|
+
} catch {
|
|
515
|
+
staged = null
|
|
516
|
+
}
|
|
517
|
+
if (staged !== null) {
|
|
518
|
+
for (const p of staged.values()) fold(p.dir, p.config)
|
|
519
|
+
return result(staged)
|
|
520
|
+
}
|
|
521
|
+
await Promise.all(
|
|
522
|
+
projects.map(async (p) => {
|
|
523
|
+
if (p.configPath === null) return
|
|
524
|
+
try {
|
|
525
|
+
fold(p.dir, await loadProjectConfig(p.configPath))
|
|
526
|
+
} catch {
|
|
527
|
+
// broken config — out of this concern's scope
|
|
113
528
|
}
|
|
114
|
-
}
|
|
115
|
-
|
|
529
|
+
}),
|
|
530
|
+
)
|
|
531
|
+
return result(null)
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
/**
|
|
535
|
+
* Under the recursive root watcher, the events that can move a key. The
|
|
536
|
+
* watcher hears every write in the workspace; a key can see three kinds
|
|
537
|
+
* of path and no other: a file inside a project's directory (its own
|
|
538
|
+
* `inputs.files`, or its outputs, which the ignore filter drops next), a
|
|
539
|
+
* workspace fingerprint file at the root, and a match of a declared
|
|
540
|
+
* `inputs.workspaceFiles` glob. Everything else — a log written at the
|
|
541
|
+
* root, a `coverage/` or `.turbo/` tree, an editor's scratch file — is
|
|
542
|
+
* dropped before the trigger, so it costs no cycle. Before this rule the
|
|
543
|
+
* one Turbo idiom that puts every repo on this watcher
|
|
544
|
+
* (`globalDependencies` → `workspaceFiles`) made `vx watch … > build.log`
|
|
545
|
+
* inside the repo a loop that never settled: each cycle's output grew the
|
|
546
|
+
* log, the log was an event, the event was a cycle. Negated globs are not
|
|
547
|
+
* consulted: a `!` only narrows, and an event it would have excluded costs
|
|
548
|
+
* one cache-hit cycle, which is the wrong direction to be clever in.
|
|
549
|
+
*/
|
|
550
|
+
export function makeRootEventFilter(
|
|
551
|
+
workspaceRoot: string,
|
|
552
|
+
projectDirs: readonly string[],
|
|
553
|
+
workspaceInputs: readonly string[],
|
|
554
|
+
): (filename: string) => boolean {
|
|
555
|
+
const dirs = projectDirs.map((d) => path.resolve(d))
|
|
556
|
+
const globs = workspaceInputs
|
|
557
|
+
.map(normalizeGlob)
|
|
558
|
+
.filter((g) => !g.startsWith('!'))
|
|
559
|
+
.map((g) => new Bun.Glob(g))
|
|
560
|
+
return (filename: string): boolean => {
|
|
561
|
+
const rel = filename.split(path.sep).join('/')
|
|
562
|
+
if (!rel.includes('/') && (isWorkspaceFingerprintFile(rel) || isWorkspaceConfigFile(rel))) {
|
|
563
|
+
return true
|
|
116
564
|
}
|
|
565
|
+
const abs = path.resolve(workspaceRoot, filename)
|
|
566
|
+
for (const d of dirs) if (abs === d || abs.startsWith(d + path.sep)) return true
|
|
567
|
+
for (const g of globs) if (g.match(rel)) return true
|
|
568
|
+
return false
|
|
117
569
|
}
|
|
118
|
-
return false
|
|
119
570
|
}
|
|
120
571
|
|
|
121
572
|
interface WatchLoopArgs {
|
|
122
573
|
opts: RunOptions
|
|
574
|
+
/** Aborted by the SIGINT/SIGTERM handlers `watchCmd` installed; the loop drains its cycle and resolves. */
|
|
575
|
+
stop: AbortSignal
|
|
123
576
|
workspaceRoot: string
|
|
124
577
|
projects: readonly ProjectMeta[]
|
|
125
578
|
workspaceWide: boolean
|
|
579
|
+
/** Every project's directory, in scope or not — under the root watcher an edit there is an edit. */
|
|
580
|
+
projectDirs: readonly string[]
|
|
581
|
+
/** Every declared `inputs.workspaceFiles` glob, root-relative. */
|
|
582
|
+
workspaceInputs: readonly string[]
|
|
583
|
+
/** Absolute, already-resolved — the loop must never re-derive it. */
|
|
584
|
+
cacheDir: string
|
|
585
|
+
/** Declared output globs per directory they are relative to (project dir, or the root for `workspaceFiles`). */
|
|
586
|
+
outputs: ReadonlyMap<string, readonly string[]>
|
|
587
|
+
/** The directory each `<dir>/*` package glob names; a member coming or going there is a cycle. */
|
|
588
|
+
memberBases: readonly string[]
|
|
589
|
+
/** The watched set again, after a cycle that followed a member event. */
|
|
590
|
+
rediscover: () => Promise<Rediscovered>
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
interface Rediscovered {
|
|
594
|
+
projects: readonly ProjectMeta[]
|
|
595
|
+
workspaceInputs: readonly string[]
|
|
596
|
+
outputs: ReadonlyMap<string, readonly string[]>
|
|
126
597
|
}
|
|
127
598
|
|
|
128
599
|
async function runWatchLoop(args: WatchLoopArgs): Promise<number> {
|
|
129
|
-
const { opts, workspaceRoot, projects, workspaceWide } = args
|
|
600
|
+
const { opts, stop, workspaceRoot, projects, workspaceWide, cacheDir, memberBases } = args
|
|
601
|
+
// The watched set as of the last cycle: `rearm` replaces these when a
|
|
602
|
+
// member came or went, and every filter below reads the current one.
|
|
603
|
+
let projectDirs = args.projectDirs
|
|
604
|
+
let workspaceInputs = args.workspaceInputs
|
|
605
|
+
let outputs = args.outputs
|
|
130
606
|
|
|
131
|
-
// Reentrancy guard — never two orchestrator runs in flight.
|
|
132
|
-
// one is running
|
|
133
|
-
//
|
|
607
|
+
// Reentrancy guard — never two orchestrator runs in flight. Events that
|
|
608
|
+
// land while one is running wait in `pendingPaths` and are judged, on
|
|
609
|
+
// settled bytes, one debounce window after it ends.
|
|
134
610
|
let running = false
|
|
135
|
-
|
|
611
|
+
/** The cycle in flight, so the stop path can wait for its teardown before resolving. */
|
|
612
|
+
let inFlight: Promise<void> = Promise.resolve()
|
|
136
613
|
let debounceTimer: ReturnType<typeof setTimeout> | null = null
|
|
137
614
|
|
|
138
|
-
|
|
615
|
+
// Declared outputs are ignored by PATH above. A task with no `cache`
|
|
616
|
+
// block declares none and still writes into its project, and the
|
|
617
|
+
// watcher sees the write: run 1 writes dist/x, the event re-runs, run 2
|
|
618
|
+
// writes the same bytes, the event re-runs — forever (the init
|
|
619
|
+
// walkthrough, 2026-09-04: every fresh workspace, since `init` emits no
|
|
620
|
+
// cache block). An undeclared write is caught by STATE, judged on what
|
|
621
|
+
// has settled (see `trigger`): a path whose settled state equals what
|
|
622
|
+
// this loop last saw for it is not a change. A file's state is its
|
|
623
|
+
// bytes; a directory's is its entries' names and sizes (a nested edit
|
|
624
|
+
// arrives as that path's own event); a path that is gone is one more
|
|
625
|
+
// state. A real edit changes the state; a first sighting passes through.
|
|
626
|
+
// So `rm -rf dist && tsc` — the shape of most build scripts — settles
|
|
627
|
+
// to the same `dist` it left and is one redundant cycle, not a loop
|
|
628
|
+
// (2026-09-10: 780 executions in two minutes from one edit, when a
|
|
629
|
+
// deletion and a directory each passed the gate unconditionally).
|
|
630
|
+
const ABSENT = -1n
|
|
631
|
+
const lastState = new Map<string, bigint>()
|
|
632
|
+
const settledState = (abs: string): bigint => {
|
|
633
|
+
let st: fs.Stats
|
|
634
|
+
try {
|
|
635
|
+
st = fs.statSync(abs)
|
|
636
|
+
} catch {
|
|
637
|
+
return ABSENT
|
|
638
|
+
}
|
|
639
|
+
if (!st.isDirectory()) {
|
|
640
|
+
try {
|
|
641
|
+
return xxh3(fs.readFileSync(abs))
|
|
642
|
+
} catch {
|
|
643
|
+
return ABSENT
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
const entries: string[] = []
|
|
647
|
+
try {
|
|
648
|
+
for (const e of fs.readdirSync(abs, { withFileTypes: true })) {
|
|
649
|
+
let size = 0
|
|
650
|
+
if (e.isFile()) {
|
|
651
|
+
try {
|
|
652
|
+
size = fs.statSync(path.join(abs, e.name)).size
|
|
653
|
+
} catch {
|
|
654
|
+
size = -1
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
entries.push(`${e.name}\0${e.isDirectory() ? 'd' : size}`)
|
|
658
|
+
}
|
|
659
|
+
} catch {
|
|
660
|
+
return ABSENT
|
|
661
|
+
}
|
|
662
|
+
entries.sort()
|
|
663
|
+
return xxh3(Buffer.from(entries.join('\n')))
|
|
664
|
+
}
|
|
665
|
+
// A path this loop has never judged is a change only if it moved since
|
|
666
|
+
// the watchers went live. macOS delivers the initial run's own writes
|
|
667
|
+
// AFTER the arm (CI, 2026-09-11: `app dist; re-running...` with no edit
|
|
668
|
+
// made — FSEvents hands a stream what landed just before it started),
|
|
669
|
+
// and a first sighting used to pass unconditionally; the path's mtime
|
|
670
|
+
// says which side of the arm it belongs to. A path already gone is a
|
|
671
|
+
// change: a deletion has no date to read.
|
|
672
|
+
const sameState = (abs: string): boolean => {
|
|
673
|
+
const state = settledState(abs)
|
|
674
|
+
const prev = lastState.get(abs)
|
|
675
|
+
lastState.set(abs, state)
|
|
676
|
+
if (prev !== undefined) return prev === state
|
|
677
|
+
return state !== ABSENT && modifiedBefore(abs, armedAt)
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
// Paths that fired since the last judgement, first label wins. The state
|
|
681
|
+
// check runs when the timer fires, on SETTLED state: per event it is
|
|
682
|
+
// wrong on Linux, where a shell redirect truncates the file (one event,
|
|
683
|
+
// empty) and then writes it (another, full), so consecutive events never
|
|
684
|
+
// agree and a self-write loops anyway (CI, 2026-09-04: 9 re-runs where
|
|
685
|
+
// macOS, which coalesces the two, saw 2). While a cycle runs, nothing is
|
|
686
|
+
// judged: the run's own writes are mid-flight (a `dist` deleted and not
|
|
687
|
+
// yet rebuilt is a state the tree will not keep), so the paths wait and
|
|
688
|
+
// are judged one window after the run ends, all together — an edit made
|
|
689
|
+
// meanwhile still differs from what the loop last saw and re-runs.
|
|
690
|
+
const pendingPaths = new Map<string, string>()
|
|
691
|
+
const judge = (): string | undefined => {
|
|
692
|
+
let first: string | undefined
|
|
693
|
+
for (const [p, l] of pendingPaths) {
|
|
694
|
+
if (!sameState(p)) first ??= l
|
|
695
|
+
}
|
|
696
|
+
pendingPaths.clear()
|
|
697
|
+
return first
|
|
698
|
+
}
|
|
699
|
+
const trigger = (label: string, abs: string): void => {
|
|
700
|
+
if (!pendingPaths.has(abs)) pendingPaths.set(abs, label)
|
|
701
|
+
if (running) return
|
|
139
702
|
if (debounceTimer) clearTimeout(debounceTimer)
|
|
140
703
|
debounceTimer = setTimeout(() => {
|
|
141
704
|
debounceTimer = null
|
|
142
|
-
|
|
705
|
+
if (running) return
|
|
706
|
+
const first = judge()
|
|
707
|
+
if (first !== undefined) void cycle(first)
|
|
143
708
|
}, DEBOUNCE_MS)
|
|
144
709
|
}
|
|
145
710
|
|
|
146
711
|
const cycle = async (label: string): Promise<void> => {
|
|
147
|
-
if (running)
|
|
148
|
-
pending = true
|
|
149
|
-
return
|
|
150
|
-
}
|
|
712
|
+
if (stop.aborted || running) return
|
|
151
713
|
running = true
|
|
714
|
+
const done = (inFlight = runCycle(label))
|
|
715
|
+
await done
|
|
716
|
+
}
|
|
717
|
+
const runCycle = async (first: string): Promise<void> => {
|
|
152
718
|
try {
|
|
153
|
-
|
|
154
|
-
|
|
719
|
+
let label: string | undefined = first
|
|
720
|
+
while (label !== undefined && !stop.aborted) {
|
|
155
721
|
process.stdout.write(`\nvx watch: ${label}; re-running...\n\n`)
|
|
156
722
|
try {
|
|
157
723
|
await runOrchestrator(opts)
|
|
724
|
+
if (membersChanged && !stop.aborted) {
|
|
725
|
+
membersChanged = false
|
|
726
|
+
await rearm()
|
|
727
|
+
}
|
|
158
728
|
} catch (err) {
|
|
159
729
|
// A re-run can fail catastrophically when the workspace
|
|
160
730
|
// itself moved out from under us — e.g. the user deleted
|
|
@@ -166,42 +736,122 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<number> {
|
|
|
166
736
|
const message = err instanceof Error ? err.message : String(err)
|
|
167
737
|
process.stderr.write(`vx watch: cycle failed: ${message}\n`)
|
|
168
738
|
}
|
|
169
|
-
//
|
|
170
|
-
|
|
739
|
+
// What landed mid-run is judged on settled state, one window
|
|
740
|
+
// after the run, under the label of what actually arrived.
|
|
741
|
+
if (pendingPaths.size === 0 || stop.aborted) break
|
|
742
|
+
await Bun.sleep(DEBOUNCE_MS)
|
|
743
|
+
label = judge()
|
|
744
|
+
}
|
|
171
745
|
} finally {
|
|
172
746
|
running = false
|
|
747
|
+
// Anything that landed after the last judgement waits for a timer
|
|
748
|
+
// like any other event.
|
|
749
|
+
if (pendingPaths.size > 0 && !stop.aborted) {
|
|
750
|
+
const [abs, label] = [...pendingPaths][0]!
|
|
751
|
+
trigger(label, abs)
|
|
752
|
+
}
|
|
173
753
|
}
|
|
174
754
|
}
|
|
175
755
|
|
|
176
756
|
// Filter out events for paths we don't care about. We watch each
|
|
177
757
|
// project's dir recursively, so a `node_modules` write under a
|
|
178
|
-
// project would otherwise trigger every save during `bun install
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
758
|
+
// project would otherwise trigger every save during `bun install` —
|
|
759
|
+
// and vx's own cache writes would trigger a cycle that writes again.
|
|
760
|
+
let isIgnoredPath = makeWatchIgnore(cacheDir, outputs)
|
|
761
|
+
let matters = makeRootEventFilter(workspaceRoot, projectDirs, workspaceInputs)
|
|
762
|
+
/** A member came or went under a package glob's directory since the last cycle. */
|
|
763
|
+
let membersChanged = false
|
|
764
|
+
|
|
765
|
+
const watchers: WatchHandle[] = []
|
|
766
|
+
const proofs: Promise<void>[] = []
|
|
767
|
+
// `VX_WATCH_POLL=1` skips the OS watcher entirely. Where it is known not
|
|
768
|
+
// to work — a sandbox with no `machLookup` for `com.apple.FSEvents`, a
|
|
769
|
+
// network mount, a container bind — the attempt costs a denied syscall
|
|
770
|
+
// and a two-second wait before the fallback takes over anyway.
|
|
771
|
+
const forcePoll = (process.env['VX_WATCH_POLL'] ?? '') !== ''
|
|
772
|
+
const arm = (dir: string, recursive: boolean, onEvent: (filename: string) => void): void => {
|
|
773
|
+
if (forcePoll) {
|
|
774
|
+
watchers.push(pollWatcher(dir, recursive, onEvent))
|
|
775
|
+
return
|
|
776
|
+
}
|
|
777
|
+
const armed = armWatcher(dir, recursive, onEvent)
|
|
778
|
+
watchers.push(armed.watcher)
|
|
779
|
+
proofs.push(
|
|
780
|
+
armed.ready.then((ok) => {
|
|
781
|
+
if (ok) return
|
|
782
|
+
// The watcher never proved delivery, so it is not one: an FSEvents
|
|
783
|
+
// stream the OS refused, a filesystem that reports nothing. Swap in
|
|
784
|
+
// the poller rather than run a loop that silently never fires.
|
|
785
|
+
armed.watcher.close()
|
|
786
|
+
watchers[watchers.indexOf(armed.watcher)] = pollWatcher(dir, recursive, onEvent)
|
|
787
|
+
process.stderr.write(
|
|
788
|
+
`vx watch: ${dir}: no OS watch events within ${WATCH_PROBE_TIMEOUT_MS} ms; polling every ${POLL_INTERVAL_MS} ms instead\n`,
|
|
789
|
+
)
|
|
790
|
+
}),
|
|
791
|
+
)
|
|
184
792
|
}
|
|
185
793
|
|
|
186
|
-
|
|
794
|
+
/** The instant the watchers go live, on the mtime clock (see `fsClockNow`): a path last modified before it is the initial run's, not an edit. */
|
|
795
|
+
const armedAt = fsClockNow(cacheDir)
|
|
796
|
+
/** Per-project arms by directory, so `rearm` can add and drop them. */
|
|
797
|
+
const perProject = new Map<string, WatchHandle>()
|
|
798
|
+
const armProject = (proj: ProjectMeta): void => {
|
|
799
|
+
try {
|
|
800
|
+
const at = watchers.length
|
|
801
|
+
arm(proj.dir, true, (filename) => {
|
|
802
|
+
if (isIgnoredPath(proj.dir, filename)) return
|
|
803
|
+
trigger(`${proj.name} ${filename}`, path.join(proj.dir, filename))
|
|
804
|
+
})
|
|
805
|
+
// By slot, not by handle: an OS watcher that never proves delivery is
|
|
806
|
+
// swapped for a poller in place, and a drop must close what is there.
|
|
807
|
+
perProject.set(proj.dir, { close: () => watchers[at]?.close() })
|
|
808
|
+
} catch (err) {
|
|
809
|
+
const msg = err instanceof Error ? err.message : String(err)
|
|
810
|
+
process.stderr.write(`vx watch: cannot watch ${proj.dir}: ${msg}\n`)
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
const rearm = async (): Promise<void> => {
|
|
814
|
+
let next: Rediscovered
|
|
815
|
+
try {
|
|
816
|
+
next = await args.rediscover()
|
|
817
|
+
} catch (err) {
|
|
818
|
+
const msg = err instanceof Error ? err.message : String(err)
|
|
819
|
+
process.stderr.write(`vx watch: cannot re-read the workspace: ${msg}\n`)
|
|
820
|
+
return
|
|
821
|
+
}
|
|
822
|
+
projectDirs = next.projects.map((p) => p.dir)
|
|
823
|
+
workspaceInputs = next.workspaceInputs
|
|
824
|
+
outputs = next.outputs
|
|
825
|
+
isIgnoredPath = makeWatchIgnore(cacheDir, outputs)
|
|
826
|
+
matters = makeRootEventFilter(workspaceRoot, projectDirs, workspaceInputs)
|
|
827
|
+
if (!workspaceWide) {
|
|
828
|
+
const keep = new Set(projectDirs)
|
|
829
|
+
for (const [dir, handle] of perProject) {
|
|
830
|
+
if (keep.has(dir)) continue
|
|
831
|
+
handle.close()
|
|
832
|
+
perProject.delete(dir)
|
|
833
|
+
}
|
|
834
|
+
for (const proj of next.projects) if (!perProject.has(proj.dir)) armProject(proj)
|
|
835
|
+
// A new arm proves delivery like the first ones: an edit in the new
|
|
836
|
+
// package right after this cycle is seen, not lost in the gap.
|
|
837
|
+
await Promise.all(proofs)
|
|
838
|
+
}
|
|
839
|
+
process.stdout.write(`vx watch: watching ${next.projects.length} project(s)\n`)
|
|
840
|
+
}
|
|
187
841
|
|
|
188
842
|
if (workspaceWide) {
|
|
189
|
-
// workspaceFiles inputs in play:
|
|
190
|
-
//
|
|
191
|
-
// ones (it also covers lockfile / pnpm-workspace.yaml
|
|
192
|
-
//
|
|
193
|
-
//
|
|
843
|
+
// workspaceFiles inputs in play: a root-relative glob can name a
|
|
844
|
+
// file anywhere, so one recursive root watcher replaces the
|
|
845
|
+
// per-project ones (it also covers lockfile / pnpm-workspace.yaml
|
|
846
|
+
// edits). `matters` keeps the events a key can see — project
|
|
847
|
+
// trees, root fingerprint files, the declared globs — and drops the
|
|
848
|
+
// rest of the tree; the ignore filter then keeps node_modules /
|
|
849
|
+
// .git / .vx and declared outputs out of what remains.
|
|
194
850
|
try {
|
|
195
|
-
|
|
196
|
-
workspaceRoot,
|
|
197
|
-
{
|
|
198
|
-
|
|
199
|
-
if (filename == null || typeof filename !== 'string') return
|
|
200
|
-
if (isIgnoredPath(filename)) return
|
|
201
|
-
trigger(`root ${filename}`)
|
|
202
|
-
},
|
|
203
|
-
)
|
|
204
|
-
watchers.push(w)
|
|
851
|
+
arm(workspaceRoot, true, (filename) => {
|
|
852
|
+
if (!matters(filename) || isIgnoredPath(workspaceRoot, filename)) return
|
|
853
|
+
trigger(`root ${filename}`, path.join(workspaceRoot, filename))
|
|
854
|
+
})
|
|
205
855
|
} catch (err) {
|
|
206
856
|
const msg = err instanceof Error ? err.message : String(err)
|
|
207
857
|
process.stderr.write(`vx watch: cannot watch workspace root: ${msg}\n`)
|
|
@@ -210,43 +860,59 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<number> {
|
|
|
210
860
|
// One recursive watcher per project. Each project owns its own
|
|
211
861
|
// subtree; we don't watch the workspace root recursively (would
|
|
212
862
|
// cover every project + node_modules + caches).
|
|
213
|
-
for (const proj of projects)
|
|
214
|
-
try {
|
|
215
|
-
const w = fs.watch(proj.dir, { recursive: true, persistent: true }, (_event, filename) => {
|
|
216
|
-
if (filename == null) return
|
|
217
|
-
if (typeof filename !== 'string') return
|
|
218
|
-
if (isIgnoredPath(filename)) return
|
|
219
|
-
trigger(`${proj.name} ${filename}`)
|
|
220
|
-
})
|
|
221
|
-
watchers.push(w)
|
|
222
|
-
} catch (err) {
|
|
223
|
-
const msg = err instanceof Error ? err.message : String(err)
|
|
224
|
-
process.stderr.write(`vx watch: cannot watch ${proj.dir}: ${msg}\n`)
|
|
225
|
-
}
|
|
226
|
-
}
|
|
863
|
+
for (const proj of projects) armProject(proj)
|
|
227
864
|
|
|
228
865
|
// Plus the workspace root itself (non-recursive) so lockfile +
|
|
229
866
|
// pnpm-workspace.yaml edits trigger re-runs even when no project
|
|
230
867
|
// dir saw the change.
|
|
231
868
|
try {
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
if (isWorkspaceFingerprintFile(filename)) trigger(`root ${filename}`)
|
|
238
|
-
},
|
|
239
|
-
)
|
|
240
|
-
watchers.push(w)
|
|
869
|
+
arm(workspaceRoot, false, (filename) => {
|
|
870
|
+
if (isWorkspaceFingerprintFile(filename) || isWorkspaceConfigFile(filename)) {
|
|
871
|
+
trigger(`root ${filename}`, path.join(workspaceRoot, filename))
|
|
872
|
+
}
|
|
873
|
+
})
|
|
241
874
|
} catch (err) {
|
|
242
875
|
const msg = err instanceof Error ? err.message : String(err)
|
|
243
876
|
process.stderr.write(`vx watch: cannot watch workspace root: ${msg}\n`)
|
|
244
877
|
}
|
|
245
878
|
}
|
|
246
879
|
|
|
247
|
-
//
|
|
248
|
-
//
|
|
249
|
-
//
|
|
880
|
+
// A package added while the loop runs is a directory entry appearing
|
|
881
|
+
// under the glob's directory (`packages/` for `packages/*`); one
|
|
882
|
+
// non-recursive watcher there hears it come or go, and the cycle it
|
|
883
|
+
// triggers re-reads the workspace (`rearm`) so the new package's own
|
|
884
|
+
// edits are cycles from then on. Until 2026-09-10 the watched set was
|
|
885
|
+
// fixed when the loop armed: the next cycle ran the new package, and
|
|
886
|
+
// every edit inside it after that was silence.
|
|
887
|
+
for (const base of memberBases) {
|
|
888
|
+
let members = memberEntries(base)
|
|
889
|
+
try {
|
|
890
|
+
arm(base, false, (filename) => {
|
|
891
|
+
if (isIgnoredWatchPath(filename)) return
|
|
892
|
+
// Only a member coming or going. On macOS a non-recursive watcher
|
|
893
|
+
// also reports a member whose CONTENTS changed (FSEvents names the
|
|
894
|
+
// directory a write landed in), so a task writing into its own
|
|
895
|
+
// project — or the arm's own probe file — read as a member event and
|
|
896
|
+
// cost an uncached task one execution per cycle (CI, 2026-09-10).
|
|
897
|
+
const now = memberEntries(base)
|
|
898
|
+
if (sameMembers(members, now)) return
|
|
899
|
+
members = now
|
|
900
|
+
membersChanged = true
|
|
901
|
+
trigger(`${path.relative(workspaceRoot, base)}/${filename}`, path.join(base, filename))
|
|
902
|
+
})
|
|
903
|
+
} catch (err) {
|
|
904
|
+
const msg = err instanceof Error ? err.message : String(err)
|
|
905
|
+
process.stderr.write(`vx watch: cannot watch ${base}: ${msg}\n`)
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
// The orchestrator's own writes never kick the loop: `makeWatchIgnore`
|
|
910
|
+
// closes over the RESOLVED cache dir (relocated or not) and the tasks'
|
|
911
|
+
// declared outputs.
|
|
912
|
+
|
|
913
|
+
// "watching" is a promise that an edit from now on is seen; every
|
|
914
|
+
// watcher has proved (or been given 2 s to prove) delivery first.
|
|
915
|
+
await Promise.all(proofs)
|
|
250
916
|
|
|
251
917
|
process.stdout.write(
|
|
252
918
|
workspaceWide
|
|
@@ -255,7 +921,7 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<number> {
|
|
|
255
921
|
)
|
|
256
922
|
|
|
257
923
|
return await new Promise<number>((resolve) => {
|
|
258
|
-
const cleanup = (): void => {
|
|
924
|
+
const cleanup = async (): Promise<void> => {
|
|
259
925
|
for (const w of watchers) {
|
|
260
926
|
try {
|
|
261
927
|
w.close()
|
|
@@ -264,24 +930,106 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<number> {
|
|
|
264
930
|
}
|
|
265
931
|
}
|
|
266
932
|
if (debounceTimer) clearTimeout(debounceTimer)
|
|
933
|
+
// The aborted cycle is tearing its children down; resolve only once
|
|
934
|
+
// it has returned, so the process never exits over a live child.
|
|
935
|
+
await inFlight
|
|
267
936
|
resolve(0)
|
|
268
937
|
}
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
}
|
|
273
|
-
|
|
938
|
+
if (stop.aborted) {
|
|
939
|
+
void cleanup()
|
|
940
|
+
return
|
|
941
|
+
}
|
|
942
|
+
stop.addEventListener('abort', () => void cleanup(), { once: true })
|
|
274
943
|
})
|
|
275
944
|
}
|
|
276
945
|
|
|
946
|
+
/** True when `abs` was last modified before `t` (epoch ms); false when it cannot be read. */
|
|
947
|
+
export function modifiedBefore(abs: string, t: number): boolean {
|
|
948
|
+
try {
|
|
949
|
+
return fs.statSync(abs).mtimeMs < t
|
|
950
|
+
} catch {
|
|
951
|
+
return false
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
/**
|
|
956
|
+
* "Now" as the filesystem will stamp the next write, not as `Date.now()`
|
|
957
|
+
* reads it. A file's mtime comes from the kernel's coarse clock, which
|
|
958
|
+
* runs up to a tick behind the fine clock `Date.now()` reads — measured
|
|
959
|
+
* on the Linux bench box 2026-09-11: 2 of 3,000 tight writes carried an
|
|
960
|
+
* mtime 5.8 ms EARLIER than a `Date.now()` taken before the write, and
|
|
961
|
+
* more under CPU load, where the tick is skipped. An `armedAt` from the
|
|
962
|
+
* fine clock then judged an edit made right after the ready line as
|
|
963
|
+
* "modified before the arm" and dropped it (the watch e2e flake, three
|
|
964
|
+
* shard runs that day; traced: raw event, trigger, `same=true`). A stamp
|
|
965
|
+
* read off a file written here is on the mtime clock itself, and every
|
|
966
|
+
* later write's mtime is at or after it.
|
|
967
|
+
*/
|
|
968
|
+
export function fsClockNow(dir: string): number {
|
|
969
|
+
const stamp = path.join(dir, WATCH_CLOCK_STAMP)
|
|
970
|
+
try {
|
|
971
|
+
fs.writeFileSync(stamp, '')
|
|
972
|
+
const at = fs.statSync(stamp).mtimeMs
|
|
973
|
+
fs.unlinkSync(stamp)
|
|
974
|
+
return at
|
|
975
|
+
} catch {
|
|
976
|
+
return Date.now()
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
/**
|
|
981
|
+
* The directory entries under a package glob's directory that can be
|
|
982
|
+
* members: directories (or links) whose name is not dotted and not
|
|
983
|
+
* `node_modules`, the same rule discovery applies. A watcher on that
|
|
984
|
+
* directory reacts only when this set changes.
|
|
985
|
+
*/
|
|
986
|
+
export function memberEntries(base: string): ReadonlySet<string> {
|
|
987
|
+
const out = new Set<string>()
|
|
988
|
+
let entries: fs.Dirent[]
|
|
989
|
+
try {
|
|
990
|
+
entries = fs.readdirSync(base, { withFileTypes: true })
|
|
991
|
+
} catch {
|
|
992
|
+
return out
|
|
993
|
+
}
|
|
994
|
+
for (const e of entries) {
|
|
995
|
+
if (e.name.startsWith('.') || e.name === 'node_modules') continue
|
|
996
|
+
if (e.isDirectory() || e.isSymbolicLink()) out.add(e.name)
|
|
997
|
+
}
|
|
998
|
+
return out
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
function sameMembers(a: ReadonlySet<string>, b: ReadonlySet<string>): boolean {
|
|
1002
|
+
if (a.size !== b.size) return false
|
|
1003
|
+
for (const name of a) if (!b.has(name)) return false
|
|
1004
|
+
return true
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
/**
|
|
1008
|
+
* True for a root file that re-keys EVERY task, so a change to one must trigger
|
|
1009
|
+
* a cycle even though it lives in no project dir.
|
|
1010
|
+
*
|
|
1011
|
+
* Reads the SHARED constant the fingerprint itself walks — this used to be a
|
|
1012
|
+
* third hand-rolled copy of that list, which is precisely the drift the
|
|
1013
|
+
* `--affected` wave exported the constant to prevent. A name added there but
|
|
1014
|
+
* not here would re-key every task while `vx watch` silently never re-ran on
|
|
1015
|
+
* it: the loop looks alive, and the one edit that invalidates the whole
|
|
1016
|
+
* workspace is the one it ignores.
|
|
1017
|
+
*/
|
|
1018
|
+
const FINGERPRINT_FILES: ReadonlySet<string> = new Set(WORKSPACE_FINGERPRINT_FILES)
|
|
1019
|
+
|
|
277
1020
|
function isWorkspaceFingerprintFile(name: string): boolean {
|
|
278
|
-
return (
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
1021
|
+
return FINGERPRINT_FILES.has(name)
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
/**
|
|
1025
|
+
* The workspace config is the one root file that shapes a run without
|
|
1026
|
+
* being any task's input — its plugins, `config` stage, concurrency, cache
|
|
1027
|
+
* dir — and a cycle re-evaluates it (its import is keyed on its bytes).
|
|
1028
|
+
* Until 2026-09-10 neither root arm listened for it: a plugin added under
|
|
1029
|
+
* `vx watch` waited for a restart while the loop looked alive.
|
|
1030
|
+
*/
|
|
1031
|
+
const WORKSPACE_CONFIGS: ReadonlySet<string> = new Set(WORKSPACE_CONFIG_FILENAMES)
|
|
1032
|
+
|
|
1033
|
+
function isWorkspaceConfigFile(name: string): boolean {
|
|
1034
|
+
return WORKSPACE_CONFIGS.has(name)
|
|
287
1035
|
}
|