@vzn/vx 0.0.18 → 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 +99 -65
- package/package.json +10 -20
- package/src/bin.ts +12 -2
- package/src/cache/archive.ts +120 -18
- package/src/cache/cache.ts +313 -1324
- package/src/cache/chained-cache.ts +0 -8
- 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 +16 -7
- package/src/cache/inputs.ts +246 -733
- package/src/cache/layer.ts +604 -0
- package/src/cache/layered-cache.ts +12 -17
- 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/zstd.ts +160 -0
- package/src/cli/cache.ts +39 -11
- package/src/cli/completions.ts +104 -0
- package/src/cli/core-alias.ts +25 -0
- package/src/cli/help.ts +64 -36
- package/src/cli/index.ts +54 -15
- package/src/cli/info.ts +120 -91
- package/src/cli/init.ts +75 -0
- package/src/cli/last.ts +38 -6
- package/src/cli/lock.ts +2 -1
- package/src/cli/plan-format.ts +14 -3
- package/src/cli/plugin-commands.ts +15 -8
- package/src/cli/run.ts +84 -302
- package/src/cli/select.ts +332 -0
- package/src/cli/show.ts +144 -69
- package/src/cli/upgrade.ts +2 -1
- package/src/cli/watch.ts +647 -97
- package/src/cli/why.ts +44 -14
- package/src/cli/workspace-config.ts +126 -0
- package/src/config.ts +156 -136
- package/src/exec/env.ts +11 -0
- package/src/exec/executor.ts +11 -26
- package/src/exec/index.ts +6 -2
- package/src/{plugins/local-executor/index.ts → exec/local-executor.ts} +12 -16
- package/src/exec/runner.ts +57 -18
- package/src/exec/sandbox-binds.ts +182 -0
- package/src/exec/sandbox-paths.ts +50 -0
- package/src/exec/sandbox-runtime.ts +582 -370
- package/src/exec/sandbox-violations.ts +280 -0
- package/src/graph/index.ts +1 -9
- package/src/graph/scheduler.ts +158 -203
- package/src/graph/task-graph.ts +2 -2
- package/src/index.ts +97 -102
- package/src/orchestrator/admission.ts +137 -0
- package/src/orchestrator/deferred-outputs.ts +3 -0
- package/src/orchestrator/doctor.ts +255 -0
- package/src/orchestrator/events.ts +42 -8
- package/src/orchestrator/execute-task.ts +145 -554
- package/src/orchestrator/failure-mode.ts +169 -28
- package/src/orchestrator/framed-output.ts +41 -36
- package/src/orchestrator/history.ts +118 -98
- package/src/orchestrator/hit-restore.ts +221 -0
- package/src/orchestrator/index.ts +35 -4
- package/src/orchestrator/lockfile-claim.ts +281 -0
- package/src/orchestrator/logger.ts +13 -22
- package/src/orchestrator/metrics.ts +53 -20
- package/src/orchestrator/miss-save.ts +169 -0
- package/src/orchestrator/options.ts +34 -31
- package/src/orchestrator/persistent.ts +79 -0
- package/src/orchestrator/placement.ts +182 -0
- package/src/orchestrator/plugin-host.ts +195 -38
- package/src/orchestrator/plugin.ts +162 -9
- package/src/orchestrator/prepare.ts +77 -132
- package/src/orchestrator/projects.ts +259 -0
- package/src/orchestrator/remote-prefetch.ts +1 -1
- package/src/orchestrator/run-artifacts.ts +25 -3
- package/src/orchestrator/run-context.ts +1 -1
- package/src/orchestrator/run-records.ts +149 -0
- package/src/orchestrator/run-report.ts +2 -2
- package/src/orchestrator/run.ts +246 -597
- 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 +60 -3
- package/src/orchestrator/status-line.ts +2 -1
- package/src/orchestrator/summary.ts +68 -25
- package/src/orchestrator/task-hash.ts +20 -18
- package/src/orchestrator/task-log-buffer.ts +8 -11
- package/src/orchestrator/telemetry-host.ts +11 -1
- package/src/orchestrator/telemetry.ts +3 -17
- package/src/util/cgroup.ts +160 -0
- package/src/util/edit-distance.ts +49 -0
- package/src/util/index.ts +11 -3
- package/src/util/paths.ts +23 -2
- package/src/util/settle.ts +19 -0
- package/src/util/verbs.ts +39 -0
- package/src/workspace/affected.ts +111 -11
- package/src/workspace/config-cache.ts +11 -2
- package/src/workspace/config-eval.ts +11 -4
- package/src/workspace/config-imports.ts +5 -4
- package/src/workspace/config-schema.ts +837 -0
- package/src/workspace/filter.ts +43 -6
- package/src/workspace/fingerprint.ts +37 -3
- package/src/workspace/index.ts +30 -3
- package/src/workspace/lockfile.ts +12 -7
- package/src/{cli → workspace}/migrate-scripts.ts +26 -19
- package/src/{cli/migrate.ts → workspace/migration.ts} +133 -124
- package/src/workspace/package-graph.ts +98 -28
- package/src/workspace/project-loader.ts +76 -746
- package/src/workspace/workspace.ts +98 -13
- package/plugins/local-cache/index.ts +0 -2
- package/plugins/local-executor/index.ts +0 -2
- package/plugins/schedule-history/index.ts +0 -2
- package/src/cache/cas-backend.ts +0 -115
- package/src/cache/digest.ts +0 -32
- package/src/cli/migrate-emit.ts +0 -21
- package/src/cli/migrate-nx.ts +0 -473
- package/src/cli/migrate-turbo.ts +0 -401
- package/src/cli/prune.ts +0 -291
- package/src/orchestrator/missing-plugin.ts +0 -9
- package/src/orchestrator/resources.ts +0 -45
- package/src/orchestrator/verify.ts +0 -174
- package/src/plugins/local-cache/index.ts +0 -11
- package/src/plugins/schedule-history/index.ts +0 -109
package/src/cli/watch.ts
CHANGED
|
@@ -14,18 +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,
|
|
24
|
-
|
|
25
|
-
|
|
27
|
+
memberBaseDirs,
|
|
28
|
+
WORKSPACE_CONFIG_FILENAMES,
|
|
26
29
|
WORKSPACE_FINGERPRINT_FILES,
|
|
30
|
+
type ProjectEntry,
|
|
27
31
|
type ProjectMeta,
|
|
28
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'
|
|
29
36
|
|
|
30
37
|
/** Wait this long after the last filesystem event before re-running. */
|
|
31
38
|
const DEBOUNCE_MS = 150
|
|
@@ -71,25 +78,55 @@ export function makeWatchIgnore(
|
|
|
71
78
|
// writes `dist/` (or `out.txt`) re-runs once more, reporting
|
|
72
79
|
// "up-to-date" for the trouble. Matched under the directory the globs
|
|
73
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.
|
|
74
90
|
const declared = [...outputs].map(
|
|
75
|
-
([dir, globs]) =>
|
|
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,
|
|
76
97
|
)
|
|
77
98
|
return (base, filename) => {
|
|
78
99
|
if (isIgnoredWatchPath(filename)) return true
|
|
79
100
|
const abs = path.resolve(base, filename)
|
|
80
101
|
if (abs === cacheAbs || abs.startsWith(cacheAbs + path.sep)) return true
|
|
81
|
-
for (const [dir, globs] of declared) {
|
|
102
|
+
for (const [dir, globs, containers] of declared) {
|
|
82
103
|
if (!abs.startsWith(dir + path.sep)) continue
|
|
83
104
|
const rel = abs
|
|
84
105
|
.slice(dir.length + 1)
|
|
85
106
|
.split(path.sep)
|
|
86
107
|
.join('/')
|
|
87
108
|
if (globs.some((g) => g.match(rel))) return true
|
|
109
|
+
if (containers.some((c) => c === rel || c.startsWith(`${rel}/`))) return true
|
|
88
110
|
}
|
|
89
111
|
return false
|
|
90
112
|
}
|
|
91
113
|
}
|
|
92
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
|
+
|
|
93
130
|
/**
|
|
94
131
|
* The file `armWatcher` writes under a watched directory to prove the
|
|
95
132
|
* watcher delivers. Intercepted by name before any other handling, so it
|
|
@@ -99,6 +136,13 @@ export const WATCH_PROBE = '.vx-watch-probe'
|
|
|
99
136
|
|
|
100
137
|
/** How long a watcher gets to report its own probe before the loop goes on without proof. */
|
|
101
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
|
+
}
|
|
102
146
|
|
|
103
147
|
export interface ArmedWatcher {
|
|
104
148
|
watcher: fs.FSWatcher
|
|
@@ -106,6 +150,84 @@ export interface ArmedWatcher {
|
|
|
106
150
|
ready: Promise<boolean>
|
|
107
151
|
}
|
|
108
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
|
+
|
|
109
231
|
/**
|
|
110
232
|
* `fs.watch` plus proof of delivery. On macOS a recursive watcher is an
|
|
111
233
|
* FSEvents stream that another thread schedules AFTER the call returns, and
|
|
@@ -132,6 +254,10 @@ export function armWatcher(
|
|
|
132
254
|
})
|
|
133
255
|
const watcher = fs.watch(dir, { recursive, persistent: true }, (_event, filename) => {
|
|
134
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
|
|
135
261
|
if (filename === WATCH_PROBE) {
|
|
136
262
|
markReady(true)
|
|
137
263
|
return
|
|
@@ -213,23 +339,40 @@ export async function watchCmd(args: readonly string[]): Promise<number> {
|
|
|
213
339
|
process.stderr.write(`vx watch: ${resolved.nothingSelected}\n`)
|
|
214
340
|
return 0
|
|
215
341
|
}
|
|
216
|
-
// The watch loop owns SIGINT/SIGTERM for its whole lifetime
|
|
217
|
-
//
|
|
218
|
-
// cycle
|
|
219
|
-
//
|
|
220
|
-
//
|
|
221
|
-
|
|
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())
|
|
222
361
|
|
|
223
362
|
// Enumerate projects-in-scope so we know what dirs to watch.
|
|
224
363
|
// `opts.projects` is the resolved scope; undefined means "every
|
|
225
|
-
// project".
|
|
226
|
-
//
|
|
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.
|
|
227
370
|
const workspaceRoot = await findWorkspaceRoot(cwd)
|
|
228
|
-
const
|
|
229
|
-
const
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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)
|
|
233
376
|
|
|
234
377
|
if (scope.length === 0) {
|
|
235
378
|
process.stderr.write(`vx watch: no projects in scope\n`)
|
|
@@ -249,106 +392,339 @@ export async function watchCmd(args: readonly string[]): Promise<number> {
|
|
|
249
392
|
// configs.
|
|
250
393
|
process.stdout.write('vx watch: initial run...\n\n')
|
|
251
394
|
await runOrchestrator(opts)
|
|
395
|
+
if (stop.signal.aborted) return 0
|
|
252
396
|
|
|
253
|
-
const
|
|
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)
|
|
254
403
|
return await runWatchLoop({
|
|
255
404
|
opts,
|
|
405
|
+
stop: stop.signal,
|
|
256
406
|
workspaceRoot,
|
|
257
|
-
projects:
|
|
407
|
+
projects: watched,
|
|
258
408
|
workspaceWide: swept.workspaceWide,
|
|
409
|
+
projectDirs: watched.map((p) => p.dir),
|
|
410
|
+
workspaceInputs: swept.workspaceInputs,
|
|
259
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
|
+
},
|
|
260
423
|
// The RESOLVED cache dir, not the `.vx` literal — see `makeWatchIgnore`.
|
|
261
|
-
cacheDir:
|
|
262
|
-
opts.cacheDir ?? resolveCacheDir(workspaceRoot, await loadWorkspaceConfig(workspaceRoot)),
|
|
424
|
+
cacheDir: opts.cacheDir ?? (await loadCliWorkspace(workspaceRoot)).cacheDir,
|
|
263
425
|
})
|
|
264
426
|
}
|
|
265
427
|
|
|
428
|
+
/**
|
|
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.
|
|
438
|
+
*/
|
|
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
|
+
|
|
266
462
|
/**
|
|
267
463
|
* One sweep over every config for the two things the loop needs: whether
|
|
268
464
|
* any task declares `inputs.workspaceFiles` — globs with no project
|
|
269
|
-
*
|
|
270
|
-
*
|
|
271
|
-
*
|
|
272
|
-
*
|
|
273
|
-
*
|
|
274
|
-
*
|
|
275
|
-
* scope
|
|
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.
|
|
276
473
|
*/
|
|
277
|
-
async function sweepConfigs(
|
|
474
|
+
export async function sweepConfigs(
|
|
278
475
|
projects: readonly ProjectMeta[],
|
|
279
476
|
workspaceRoot: string,
|
|
280
|
-
|
|
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
|
+
}> {
|
|
281
485
|
const outputs = new Map<string, string[]>()
|
|
282
486
|
const add = (dir: string, globs: readonly string[] | undefined): void => {
|
|
283
487
|
if (globs === undefined || globs.length === 0) return
|
|
284
488
|
outputs.set(dir, [...(outputs.get(dir) ?? []), ...globs])
|
|
285
489
|
}
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
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
|
|
293
524
|
try {
|
|
294
|
-
|
|
295
|
-
let wide = false
|
|
296
|
-
for (const task of Object.values(config.tasks ?? {})) {
|
|
297
|
-
if ((task.cache?.inputs?.workspaceFiles?.length ?? 0) > 0) wide = true
|
|
298
|
-
add(p.dir, task.cache?.outputs?.files)
|
|
299
|
-
add(workspaceRoot, task.cache?.outputs?.workspaceFiles)
|
|
300
|
-
}
|
|
301
|
-
return wide
|
|
525
|
+
fold(p.dir, await loadProjectConfig(p.configPath))
|
|
302
526
|
} catch {
|
|
303
527
|
// broken config — out of this concern's scope
|
|
304
|
-
return false
|
|
305
528
|
}
|
|
306
529
|
}),
|
|
307
530
|
)
|
|
308
|
-
return
|
|
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
|
|
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
|
|
569
|
+
}
|
|
309
570
|
}
|
|
310
571
|
|
|
311
572
|
interface WatchLoopArgs {
|
|
312
573
|
opts: RunOptions
|
|
574
|
+
/** Aborted by the SIGINT/SIGTERM handlers `watchCmd` installed; the loop drains its cycle and resolves. */
|
|
575
|
+
stop: AbortSignal
|
|
313
576
|
workspaceRoot: string
|
|
314
577
|
projects: readonly ProjectMeta[]
|
|
315
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[]
|
|
316
583
|
/** Absolute, already-resolved — the loop must never re-derive it. */
|
|
317
584
|
cacheDir: string
|
|
318
585
|
/** Declared output globs per directory they are relative to (project dir, or the root for `workspaceFiles`). */
|
|
319
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[]>
|
|
320
597
|
}
|
|
321
598
|
|
|
322
599
|
async function runWatchLoop(args: WatchLoopArgs): Promise<number> {
|
|
323
|
-
const { opts, workspaceRoot, projects, workspaceWide, cacheDir,
|
|
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
|
|
324
606
|
|
|
325
|
-
// Reentrancy guard — never two orchestrator runs in flight.
|
|
326
|
-
// one is running
|
|
327
|
-
//
|
|
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.
|
|
328
610
|
let running = false
|
|
329
|
-
|
|
611
|
+
/** The cycle in flight, so the stop path can wait for its teardown before resolving. */
|
|
612
|
+
let inFlight: Promise<void> = Promise.resolve()
|
|
330
613
|
let debounceTimer: ReturnType<typeof setTimeout> | null = null
|
|
331
614
|
|
|
332
|
-
|
|
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
|
|
333
702
|
if (debounceTimer) clearTimeout(debounceTimer)
|
|
334
703
|
debounceTimer = setTimeout(() => {
|
|
335
704
|
debounceTimer = null
|
|
336
|
-
|
|
705
|
+
if (running) return
|
|
706
|
+
const first = judge()
|
|
707
|
+
if (first !== undefined) void cycle(first)
|
|
337
708
|
}, DEBOUNCE_MS)
|
|
338
709
|
}
|
|
339
710
|
|
|
340
711
|
const cycle = async (label: string): Promise<void> => {
|
|
341
|
-
if (running)
|
|
342
|
-
pending = true
|
|
343
|
-
return
|
|
344
|
-
}
|
|
712
|
+
if (stop.aborted || running) return
|
|
345
713
|
running = true
|
|
714
|
+
const done = (inFlight = runCycle(label))
|
|
715
|
+
await done
|
|
716
|
+
}
|
|
717
|
+
const runCycle = async (first: string): Promise<void> => {
|
|
346
718
|
try {
|
|
347
|
-
|
|
348
|
-
|
|
719
|
+
let label: string | undefined = first
|
|
720
|
+
while (label !== undefined && !stop.aborted) {
|
|
349
721
|
process.stdout.write(`\nvx watch: ${label}; re-running...\n\n`)
|
|
350
722
|
try {
|
|
351
723
|
await runOrchestrator(opts)
|
|
724
|
+
if (membersChanged && !stop.aborted) {
|
|
725
|
+
membersChanged = false
|
|
726
|
+
await rearm()
|
|
727
|
+
}
|
|
352
728
|
} catch (err) {
|
|
353
729
|
// A re-run can fail catastrophically when the workspace
|
|
354
730
|
// itself moved out from under us — e.g. the user deleted
|
|
@@ -360,10 +736,20 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<number> {
|
|
|
360
736
|
const message = err instanceof Error ? err.message : String(err)
|
|
361
737
|
process.stderr.write(`vx watch: cycle failed: ${message}\n`)
|
|
362
738
|
}
|
|
363
|
-
//
|
|
364
|
-
|
|
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
|
+
}
|
|
365
745
|
} finally {
|
|
366
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
|
+
}
|
|
367
753
|
}
|
|
368
754
|
}
|
|
369
755
|
|
|
@@ -371,34 +757,100 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<number> {
|
|
|
371
757
|
// project's dir recursively, so a `node_modules` write under a
|
|
372
758
|
// project would otherwise trigger every save during `bun install` —
|
|
373
759
|
// and vx's own cache writes would trigger a cycle that writes again.
|
|
374
|
-
|
|
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
|
|
375
764
|
|
|
376
|
-
const watchers:
|
|
765
|
+
const watchers: WatchHandle[] = []
|
|
377
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'] ?? '') !== ''
|
|
378
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
|
+
}
|
|
379
777
|
const armed = armWatcher(dir, recursive, onEvent)
|
|
380
778
|
watchers.push(armed.watcher)
|
|
381
779
|
proofs.push(
|
|
382
780
|
armed.ready.then((ok) => {
|
|
383
|
-
if (
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
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
|
+
)
|
|
388
790
|
}),
|
|
389
791
|
)
|
|
390
792
|
}
|
|
391
793
|
|
|
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
|
+
}
|
|
841
|
+
|
|
392
842
|
if (workspaceWide) {
|
|
393
|
-
// workspaceFiles inputs in play:
|
|
394
|
-
//
|
|
395
|
-
// ones (it also covers lockfile / pnpm-workspace.yaml
|
|
396
|
-
//
|
|
397
|
-
//
|
|
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.
|
|
398
850
|
try {
|
|
399
851
|
arm(workspaceRoot, true, (filename) => {
|
|
400
|
-
if (isIgnoredPath(workspaceRoot, filename)) return
|
|
401
|
-
trigger(`root ${filename}
|
|
852
|
+
if (!matters(filename) || isIgnoredPath(workspaceRoot, filename)) return
|
|
853
|
+
trigger(`root ${filename}`, path.join(workspaceRoot, filename))
|
|
402
854
|
})
|
|
403
855
|
} catch (err) {
|
|
404
856
|
const msg = err instanceof Error ? err.message : String(err)
|
|
@@ -408,24 +860,16 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<number> {
|
|
|
408
860
|
// One recursive watcher per project. Each project owns its own
|
|
409
861
|
// subtree; we don't watch the workspace root recursively (would
|
|
410
862
|
// cover every project + node_modules + caches).
|
|
411
|
-
for (const proj of projects)
|
|
412
|
-
try {
|
|
413
|
-
arm(proj.dir, true, (filename) => {
|
|
414
|
-
if (isIgnoredPath(proj.dir, filename)) return
|
|
415
|
-
trigger(`${proj.name} ${filename}`)
|
|
416
|
-
})
|
|
417
|
-
} catch (err) {
|
|
418
|
-
const msg = err instanceof Error ? err.message : String(err)
|
|
419
|
-
process.stderr.write(`vx watch: cannot watch ${proj.dir}: ${msg}\n`)
|
|
420
|
-
}
|
|
421
|
-
}
|
|
863
|
+
for (const proj of projects) armProject(proj)
|
|
422
864
|
|
|
423
865
|
// Plus the workspace root itself (non-recursive) so lockfile +
|
|
424
866
|
// pnpm-workspace.yaml edits trigger re-runs even when no project
|
|
425
867
|
// dir saw the change.
|
|
426
868
|
try {
|
|
427
869
|
arm(workspaceRoot, false, (filename) => {
|
|
428
|
-
if (isWorkspaceFingerprintFile(filename)
|
|
870
|
+
if (isWorkspaceFingerprintFile(filename) || isWorkspaceConfigFile(filename)) {
|
|
871
|
+
trigger(`root ${filename}`, path.join(workspaceRoot, filename))
|
|
872
|
+
}
|
|
429
873
|
})
|
|
430
874
|
} catch (err) {
|
|
431
875
|
const msg = err instanceof Error ? err.message : String(err)
|
|
@@ -433,9 +877,38 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<number> {
|
|
|
433
877
|
}
|
|
434
878
|
}
|
|
435
879
|
|
|
436
|
-
//
|
|
437
|
-
//
|
|
438
|
-
//
|
|
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.
|
|
439
912
|
|
|
440
913
|
// "watching" is a promise that an edit from now on is seen; every
|
|
441
914
|
// watcher has proved (or been given 2 s to prove) delivery first.
|
|
@@ -448,7 +921,7 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<number> {
|
|
|
448
921
|
)
|
|
449
922
|
|
|
450
923
|
return await new Promise<number>((resolve) => {
|
|
451
|
-
const cleanup = (): void => {
|
|
924
|
+
const cleanup = async (): Promise<void> => {
|
|
452
925
|
for (const w of watchers) {
|
|
453
926
|
try {
|
|
454
927
|
w.close()
|
|
@@ -457,16 +930,80 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<number> {
|
|
|
457
930
|
}
|
|
458
931
|
}
|
|
459
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
|
|
460
936
|
resolve(0)
|
|
461
937
|
}
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
}
|
|
466
|
-
|
|
938
|
+
if (stop.aborted) {
|
|
939
|
+
void cleanup()
|
|
940
|
+
return
|
|
941
|
+
}
|
|
942
|
+
stop.addEventListener('abort', () => void cleanup(), { once: true })
|
|
467
943
|
})
|
|
468
944
|
}
|
|
469
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
|
+
|
|
470
1007
|
/**
|
|
471
1008
|
* True for a root file that re-keys EVERY task, so a change to one must trigger
|
|
472
1009
|
* a cycle even though it lives in no project dir.
|
|
@@ -483,3 +1020,16 @@ const FINGERPRINT_FILES: ReadonlySet<string> = new Set(WORKSPACE_FINGERPRINT_FIL
|
|
|
483
1020
|
function isWorkspaceFingerprintFile(name: string): boolean {
|
|
484
1021
|
return FINGERPRINT_FILES.has(name)
|
|
485
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)
|
|
1035
|
+
}
|