@vzn/vx 0.0.18 → 0.0.22
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +1 -1
- package/README.md +107 -70
- package/package.json +10 -20
- package/src/bin.ts +64 -5
- package/src/cache/archive.ts +279 -28
- package/src/cache/cache.ts +952 -1584
- package/src/cache/chained-cache.ts +14 -10
- package/src/cache/config-evals.ts +112 -0
- package/src/cache/file-hashes.ts +290 -0
- package/src/cache/git-inputs.ts +966 -0
- package/src/cache/index.ts +23 -7
- package/src/cache/inputs.ts +553 -739
- package/src/cache/key-fold.ts +232 -0
- package/src/cache/layer.ts +674 -0
- package/src/cache/layered-cache.ts +211 -49
- package/src/cache/output-index.ts +323 -0
- package/src/cache/policy.ts +84 -0
- package/src/cache/run-history.ts +152 -0
- package/src/cache/zstd.ts +183 -0
- package/src/cli/cache.ts +88 -30
- package/src/cli/completions.ts +104 -0
- package/src/cli/core-alias.ts +25 -0
- package/src/cli/format.ts +4 -15
- package/src/cli/help.ts +76 -38
- package/src/cli/index.ts +82 -17
- package/src/cli/info.ts +162 -91
- package/src/cli/init.ts +87 -0
- package/src/cli/last.ts +130 -23
- package/src/cli/lock.ts +36 -18
- package/src/cli/plan-format.ts +18 -7
- package/src/cli/plugin-commands.ts +34 -12
- package/src/cli/run.ts +138 -313
- package/src/cli/select.ts +342 -0
- package/src/cli/show.ts +151 -71
- package/src/cli/upgrade.ts +136 -15
- package/src/cli/watch.ts +1175 -116
- package/src/cli/why.ts +51 -29
- package/src/cli/workspace-config.ts +138 -0
- package/src/config.ts +183 -126
- package/src/exec/env.ts +11 -0
- package/src/exec/executor.ts +62 -31
- package/src/exec/index.ts +16 -2
- package/src/exec/kill-tree.ts +281 -0
- package/src/exec/local-executor.ts +41 -0
- package/src/exec/runner.ts +361 -77
- package/src/exec/sandbox-binds.ts +211 -0
- package/src/exec/sandbox-paths.ts +70 -0
- package/src/exec/sandbox-runtime.ts +1152 -415
- package/src/exec/sandbox-violations.ts +293 -0
- package/src/graph/dependency-spec.ts +14 -14
- package/src/graph/index.ts +6 -4
- package/src/graph/priorities.ts +130 -0
- package/src/graph/scheduler.ts +332 -329
- package/src/graph/task-graph.ts +552 -188
- package/src/index.ts +111 -102
- package/src/orchestrator/admission.ts +161 -0
- package/src/orchestrator/colors.ts +8 -3
- package/src/orchestrator/deferred-outputs.ts +25 -7
- package/src/orchestrator/doctor.ts +363 -0
- package/src/orchestrator/events.ts +111 -8
- package/src/orchestrator/excluded-keys.ts +155 -0
- package/src/orchestrator/execute-task.ts +450 -565
- package/src/orchestrator/failure-mode.ts +169 -28
- package/src/orchestrator/failure-recap.ts +121 -0
- package/src/orchestrator/fingerprint-watch.ts +89 -0
- package/src/orchestrator/framed-output.ts +121 -49
- package/src/orchestrator/history.ts +123 -104
- package/src/orchestrator/hit-restore.ts +285 -0
- package/src/orchestrator/index.ts +42 -5
- package/src/orchestrator/keyed-projects.ts +89 -0
- package/src/orchestrator/local-shortcircuit.ts +83 -23
- package/src/orchestrator/lockfile-claim.ts +401 -0
- package/src/orchestrator/logger.ts +128 -41
- package/src/orchestrator/metrics.ts +201 -72
- package/src/orchestrator/miss-save.ts +232 -0
- package/src/orchestrator/options.ts +63 -34
- package/src/orchestrator/persistent.ts +99 -0
- package/src/orchestrator/placement.ts +212 -0
- package/src/orchestrator/plan.ts +24 -11
- package/src/orchestrator/plugin-host.ts +200 -49
- package/src/orchestrator/plugin.ts +190 -14
- package/src/orchestrator/prepare.ts +168 -137
- package/src/orchestrator/projects.ts +270 -0
- package/src/orchestrator/remote-prefetch.ts +22 -2
- package/src/orchestrator/run-artifacts.ts +34 -3
- package/src/orchestrator/run-context.ts +20 -5
- package/src/orchestrator/run-lock.ts +349 -0
- package/src/orchestrator/run-records.ts +132 -0
- package/src/orchestrator/run-report.ts +6 -4
- package/src/orchestrator/run.ts +467 -611
- package/src/orchestrator/sandbox-request.ts +569 -0
- package/src/orchestrator/save-lane.ts +44 -0
- package/src/orchestrator/shell-verdict.ts +117 -0
- package/src/orchestrator/signals.ts +167 -0
- package/src/orchestrator/stable-keys.ts +259 -10
- package/src/orchestrator/status-line.ts +35 -2
- package/src/orchestrator/summary.ts +115 -25
- package/src/orchestrator/task-hash.ts +103 -21
- package/src/orchestrator/task-log-buffer.ts +15 -18
- package/src/orchestrator/telemetry-host.ts +27 -8
- package/src/orchestrator/telemetry.ts +71 -44
- package/src/orchestrator/upstream.ts +91 -45
- package/src/util/bun-version.ts +43 -0
- package/src/util/cgroup.ts +160 -0
- package/src/util/edit-distance.ts +49 -0
- package/src/util/errors.ts +86 -0
- package/src/util/hash.ts +10 -1
- package/src/util/index.ts +42 -5
- package/src/util/paths.ts +168 -5
- package/src/util/procfs.ts +20 -0
- package/src/util/settle.ts +23 -2
- package/src/util/size.ts +33 -0
- package/src/util/task-id.ts +20 -0
- package/src/util/timing.ts +11 -1
- package/src/util/verbs.ts +39 -0
- package/src/util/which.ts +48 -0
- package/src/workspace/affected.ts +368 -60
- package/src/workspace/config-cache.ts +271 -32
- package/src/workspace/config-eval.ts +60 -16
- package/src/workspace/config-imports.ts +131 -9
- package/src/workspace/config-schema.ts +1033 -0
- package/src/workspace/filter.ts +69 -17
- package/src/workspace/fingerprint.ts +59 -7
- package/src/workspace/index.ts +37 -3
- package/src/workspace/json-data.ts +90 -0
- package/src/workspace/load-reads.ts +32 -0
- package/src/workspace/lockfile.ts +41 -14
- package/src/workspace/migrate-scripts.ts +187 -0
- package/src/workspace/migration.ts +353 -0
- package/src/workspace/package-graph.ts +239 -31
- package/src/workspace/project-loader.ts +295 -815
- package/src/workspace/workspace.ts +266 -31
- package/plugins/local-cache/index.ts +0 -2
- package/plugins/local-executor/index.ts +0 -2
- package/plugins/schedule-history/index.ts +0 -2
- package/src/cache/cas-backend.ts +0 -115
- package/src/cache/digest.ts +0 -32
- package/src/cli/migrate-emit.ts +0 -21
- package/src/cli/migrate-nx.ts +0 -473
- package/src/cli/migrate-scripts.ts +0 -127
- package/src/cli/migrate-turbo.ts +0 -401
- package/src/cli/migrate.ts +0 -305
- package/src/cli/prune.ts +0 -291
- package/src/orchestrator/missing-plugin.ts +0 -9
- package/src/orchestrator/resources.ts +0 -45
- package/src/orchestrator/verify.ts +0 -174
- package/src/plugins/local-cache/index.ts +0 -11
- package/src/plugins/local-executor/index.ts +0 -45
- package/src/plugins/schedule-history/index.ts +0 -109
package/src/cli/watch.ts
CHANGED
|
@@ -14,18 +14,42 @@
|
|
|
14
14
|
|
|
15
15
|
import fs from 'node:fs'
|
|
16
16
|
import path from 'node:path'
|
|
17
|
+
import {
|
|
18
|
+
executablePath,
|
|
19
|
+
isLiteralPattern,
|
|
20
|
+
normalizeGlob,
|
|
21
|
+
staticPrefix,
|
|
22
|
+
taskGlob,
|
|
23
|
+
xxh3,
|
|
24
|
+
} from '../util/index.js'
|
|
25
|
+
import { asTrees } from '../cache/index.js'
|
|
17
26
|
import { parseRunArgs, resolveRunOptions } from './run.js'
|
|
18
|
-
import { run as runOrchestrator, type RunOptions } from '../orchestrator/index.js'
|
|
19
27
|
import {
|
|
28
|
+
fingerprintClaims,
|
|
29
|
+
forwardedSignal,
|
|
30
|
+
run as runOrchestrator,
|
|
31
|
+
type HeldPersistent,
|
|
32
|
+
type RunOptions,
|
|
33
|
+
} from '../orchestrator/index.js'
|
|
34
|
+
import {
|
|
35
|
+
buildPackageGraph,
|
|
20
36
|
findWorkspaceRoot,
|
|
37
|
+
type LoadReads,
|
|
21
38
|
listProjects,
|
|
22
39
|
loadProjectConfig,
|
|
23
40
|
loadWorkspace,
|
|
24
|
-
|
|
25
|
-
|
|
41
|
+
LOCKFILE_NAME,
|
|
42
|
+
memberBaseDirs,
|
|
43
|
+
PROJECT_CONFIG_FILENAMES,
|
|
44
|
+
WORKSPACE_CONFIG_FILENAMES,
|
|
45
|
+
configImports,
|
|
26
46
|
WORKSPACE_FINGERPRINT_FILES,
|
|
47
|
+
type ProjectEntry,
|
|
27
48
|
type ProjectMeta,
|
|
28
49
|
} from '../workspace/index.js'
|
|
50
|
+
import type { ProjectConfig } from '../config.js'
|
|
51
|
+
import { type CliLoadOptions, loadCliProjects, loadCliWorkspace } from './workspace-config.js'
|
|
52
|
+
import { taskEdges, taskEdgesFrom } from './select.js'
|
|
29
53
|
|
|
30
54
|
/** Wait this long after the last filesystem event before re-running. */
|
|
31
55
|
const DEBOUNCE_MS = 150
|
|
@@ -65,31 +89,84 @@ export function isIgnoredWatchPath(rel: string): boolean {
|
|
|
65
89
|
export function makeWatchIgnore(
|
|
66
90
|
cacheDir: string,
|
|
67
91
|
outputs: ReadonlyMap<string, readonly string[]> = new Map(),
|
|
92
|
+
inputs: ReadonlyMap<string, readonly string[]> = new Map(),
|
|
68
93
|
): (base: string, filename: string) => boolean {
|
|
69
94
|
const cacheAbs = path.resolve(cacheDir)
|
|
70
95
|
// A task's own outputs are not edits: without this every cycle that
|
|
71
96
|
// writes `dist/` (or `out.txt`) re-runs once more, reporting
|
|
72
97
|
// "up-to-date" for the trouble. Matched under the directory the globs
|
|
73
98
|
// are relative to, whichever watcher delivered the event.
|
|
99
|
+
// The directory that HOLDS an output tree is the task's too: `dist/**`
|
|
100
|
+
// does not match `dist`, and since the clean before a miss prunes an
|
|
101
|
+
// emptied `dist` (2026-09-10) the task re-creates it, which the watcher
|
|
102
|
+
// reports as a change to `dist` itself — a second cycle per edit,
|
|
103
|
+
// reporting "up-to-date". The literal prefix of each glob (`dist` for
|
|
104
|
+
// `dist/**`, `build/out` for `build/out/*.js`; nothing for `*.js`) and
|
|
105
|
+
// every ancestor of it under the dir are output containers.
|
|
106
|
+
// A literal entry means the file or its whole tree — the resolver's own
|
|
107
|
+
// rule (`asTrees`), so the tree's files never count as edits.
|
|
74
108
|
const declared = [...outputs].map(
|
|
75
|
-
([dir, globs]) =>
|
|
109
|
+
([dir, globs]) =>
|
|
110
|
+
[
|
|
111
|
+
path.resolve(dir),
|
|
112
|
+
asTrees(globs).map((g) => taskGlob(g)),
|
|
113
|
+
globs.map(outputContainer).filter((c) => c !== ''),
|
|
114
|
+
] as const,
|
|
115
|
+
)
|
|
116
|
+
// A path some task takes as an INPUT is never an output to ignore, even
|
|
117
|
+
// when another task declares it one: an in-place formatter declaring
|
|
118
|
+
// `src/**` hid every `src` edit from a `build` watched beside it, and no
|
|
119
|
+
// cycle ran (item 946). Negations are not consulted — they only narrow,
|
|
120
|
+
// and a path they would exclude costs one cache-hit cycle.
|
|
121
|
+
const read = [...inputs].map(
|
|
122
|
+
([dir, globs]) =>
|
|
123
|
+
[
|
|
124
|
+
path.resolve(dir),
|
|
125
|
+
asTrees(globs.filter((g) => !g.startsWith('!'))).map((g) => taskGlob(g)),
|
|
126
|
+
] as const,
|
|
76
127
|
)
|
|
128
|
+
const isInput = (abs: string): boolean =>
|
|
129
|
+
read.some(([dir, globs]) => {
|
|
130
|
+
if (!abs.startsWith(dir + path.sep)) return false
|
|
131
|
+
const rel = abs
|
|
132
|
+
.slice(dir.length + 1)
|
|
133
|
+
.split(path.sep)
|
|
134
|
+
.join('/')
|
|
135
|
+
return globs.some((g) => g.match(rel))
|
|
136
|
+
})
|
|
77
137
|
return (base, filename) => {
|
|
78
138
|
if (isIgnoredWatchPath(filename)) return true
|
|
79
139
|
const abs = path.resolve(base, filename)
|
|
80
140
|
if (abs === cacheAbs || abs.startsWith(cacheAbs + path.sep)) return true
|
|
81
|
-
|
|
141
|
+
if (isInput(abs)) return false
|
|
142
|
+
for (const [dir, globs, containers] of declared) {
|
|
82
143
|
if (!abs.startsWith(dir + path.sep)) continue
|
|
83
144
|
const rel = abs
|
|
84
145
|
.slice(dir.length + 1)
|
|
85
146
|
.split(path.sep)
|
|
86
147
|
.join('/')
|
|
87
148
|
if (globs.some((g) => g.match(rel))) return true
|
|
149
|
+
if (containers.some((c) => c === rel || c.startsWith(`${rel}/`))) return true
|
|
88
150
|
}
|
|
89
151
|
return false
|
|
90
152
|
}
|
|
91
153
|
}
|
|
92
154
|
|
|
155
|
+
/**
|
|
156
|
+
* The literal directory a glob's matches live under (`''` when the glob
|
|
157
|
+
* starts with a pattern, or negates). A literal entry is a file or its
|
|
158
|
+
* whole tree (schema: literal → tree), so the entry itself is the
|
|
159
|
+
* container; a pattern's is `staticPrefix` — the same rule the sandbox
|
|
160
|
+
* baseline and the deferral gate read.
|
|
161
|
+
*/
|
|
162
|
+
function outputContainer(raw: string): string {
|
|
163
|
+
const glob = normalizeGlob(raw)
|
|
164
|
+
if (glob.startsWith('!')) return ''
|
|
165
|
+
if (isLiteralPattern(glob)) return glob.replace(/\/+$/, '')
|
|
166
|
+
const prefix = staticPrefix(glob)
|
|
167
|
+
return prefix === '.' || prefix === '/' ? '' : prefix
|
|
168
|
+
}
|
|
169
|
+
|
|
93
170
|
/**
|
|
94
171
|
* The file `armWatcher` writes under a watched directory to prove the
|
|
95
172
|
* watcher delivers. Intercepted by name before any other handling, so it
|
|
@@ -97,8 +174,51 @@ export function makeWatchIgnore(
|
|
|
97
174
|
*/
|
|
98
175
|
export const WATCH_PROBE = '.vx-watch-probe'
|
|
99
176
|
|
|
177
|
+
/**
|
|
178
|
+
* The subset of `paths` git ignores, asked once per judgement (one
|
|
179
|
+
* `git check-ignore` per debounce window that has candidates, never per
|
|
180
|
+
* event). A git-ignored path is invisible to every cache key — inputs
|
|
181
|
+
* are tracked + untracked-not-ignored — so a cycle it starts can change
|
|
182
|
+
* nothing, and a task that writes one on every run (a pid file, a
|
|
183
|
+
* timestamped log, `.next/trace`) made the loop re-run itself forever
|
|
184
|
+
* (2026-09-16: 29 cycles in 8 s from one edit). A TRACKED file that
|
|
185
|
+
* matches a pattern is not reported, by git's own rule, so it stays an
|
|
186
|
+
* edit. Outside a repository (exit 128) nothing is ignored, as before.
|
|
187
|
+
*/
|
|
188
|
+
export function gitIgnored(workspaceRoot: string, paths: readonly string[]): Set<string> {
|
|
189
|
+
const ignored = new Set<string>()
|
|
190
|
+
if (paths.length === 0) return ignored
|
|
191
|
+
let proc: ReturnType<typeof Bun.spawnSync>
|
|
192
|
+
try {
|
|
193
|
+
proc = Bun.spawnSync({
|
|
194
|
+
cmd: [executablePath('git'), 'check-ignore', '-z', '--stdin'],
|
|
195
|
+
cwd: workspaceRoot,
|
|
196
|
+
stdin: Buffer.from(paths.map((p) => `${p}\0`).join('')),
|
|
197
|
+
stdout: 'pipe',
|
|
198
|
+
stderr: 'ignore',
|
|
199
|
+
})
|
|
200
|
+
} catch {
|
|
201
|
+
// No git on PATH: nothing is ignored, and the loop keeps judging —
|
|
202
|
+
// the initial run already said what vx requires.
|
|
203
|
+
return ignored
|
|
204
|
+
}
|
|
205
|
+
// 0: some ignored; 1: none. Anything else is git refusing (not a
|
|
206
|
+
// repository, a path inside a nested one): no path is ignored.
|
|
207
|
+
if (proc.exitCode !== 0) return ignored
|
|
208
|
+
for (const p of new TextDecoder().decode(proc.stdout).split('\0'))
|
|
209
|
+
if (p.length > 0) ignored.add(p)
|
|
210
|
+
return ignored
|
|
211
|
+
}
|
|
212
|
+
|
|
100
213
|
/** How long a watcher gets to report its own probe before the loop goes on without proof. */
|
|
101
214
|
const WATCH_PROBE_TIMEOUT_MS = 2_000
|
|
215
|
+
/** The file `fsClockNow` writes and removes, under the cache dir the watchers ignore. */
|
|
216
|
+
const WATCH_CLOCK_STAMP = '.vx-watch-clock'
|
|
217
|
+
|
|
218
|
+
/** Anything the loop needs to shut down at exit. */
|
|
219
|
+
export interface WatchHandle {
|
|
220
|
+
close(): void
|
|
221
|
+
}
|
|
102
222
|
|
|
103
223
|
export interface ArmedWatcher {
|
|
104
224
|
watcher: fs.FSWatcher
|
|
@@ -106,6 +226,101 @@ export interface ArmedWatcher {
|
|
|
106
226
|
ready: Promise<boolean>
|
|
107
227
|
}
|
|
108
228
|
|
|
229
|
+
/** How often the fallback re-walks a watched tree. */
|
|
230
|
+
const POLL_INTERVAL_MS = 250
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Directory names the fallback never descends into WHEN no better filter is
|
|
234
|
+
* supplied. The justification is that `makeWatchIgnore` already drops their
|
|
235
|
+
* EVENTS, so the walk buys nothing — a poller pays for it either way, and
|
|
236
|
+
* `node_modules` is the difference between a cheap fallback and one that
|
|
237
|
+
* re-stats 40 000 files four times a second.
|
|
238
|
+
*
|
|
239
|
+
* That justification is exactly `IGNORED_SEGMENTS`, so this IS that set.
|
|
240
|
+
* It used to carry a fourth name, `dist`, which the justification does NOT
|
|
241
|
+
* cover: `dist` is dropped only when a project DECLARES it as an output, and
|
|
242
|
+
* a project that does not declare it had its `dist/` sources silently
|
|
243
|
+
* invisible to `vx watch` on every host the poller exists for — a macOS
|
|
244
|
+
* sandbox, a network mount, a container bind — while the native watcher
|
|
245
|
+
* delivered them. Two watchers disagreeing about what an edit is (item 482).
|
|
246
|
+
* The real per-project answer is the caller's `skipDir`, below.
|
|
247
|
+
*/
|
|
248
|
+
const POLL_SKIP = new Set(IGNORED_SEGMENTS)
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* A watcher built from `stat`, for when the OS one cannot deliver.
|
|
252
|
+
*
|
|
253
|
+
* `fs.watch` on macOS is FSEvents, which needs `mach-lookup` on
|
|
254
|
+
* `com.apple.FSEvents`; inside a sandbox that does not grant it the call
|
|
255
|
+
* SUCCEEDS and then never fires (measured 2026-09-05: 0 events recursive,
|
|
256
|
+
* 0 non-recursive, against 3 and 2 for the same writes outside — while
|
|
257
|
+
* `fs.watchFile` polling delivered in both). A network filesystem or a
|
|
258
|
+
* container bind mount fails the same way. Polling is slower and coarser,
|
|
259
|
+
* and it is the difference between `vx watch` working there and silently
|
|
260
|
+
* doing nothing.
|
|
261
|
+
*/
|
|
262
|
+
export function pollWatcher(
|
|
263
|
+
dir: string,
|
|
264
|
+
recursive: boolean,
|
|
265
|
+
onEvent: (filename: string) => void,
|
|
266
|
+
intervalMs = POLL_INTERVAL_MS,
|
|
267
|
+
/**
|
|
268
|
+
* Directories not worth descending into, by their path relative to `dir`.
|
|
269
|
+
* The watch loop passes its own event filter, so the poller skips exactly
|
|
270
|
+
* what the filter would drop anyway — every declared output container, not
|
|
271
|
+
* a hard-coded name. The default covers the unconditional segments alone.
|
|
272
|
+
*/
|
|
273
|
+
skipDir: (rel: string) => boolean = (rel) => POLL_SKIP.has(path.basename(rel)),
|
|
274
|
+
): WatchHandle {
|
|
275
|
+
let previous = new Map<string, number>()
|
|
276
|
+
let first = true
|
|
277
|
+
const scan = (): void => {
|
|
278
|
+
const current = new Map<string, number>()
|
|
279
|
+
const walk = (abs: string, rel: string): void => {
|
|
280
|
+
let entries: fs.Dirent[]
|
|
281
|
+
try {
|
|
282
|
+
entries = fs.readdirSync(abs, { withFileTypes: true })
|
|
283
|
+
} catch {
|
|
284
|
+
return // vanished or unreadable: its files simply stop appearing
|
|
285
|
+
}
|
|
286
|
+
for (const e of entries) {
|
|
287
|
+
if (e.name === WATCH_PROBE) continue
|
|
288
|
+
const childRel = rel === '' ? e.name : `${rel}/${e.name}`
|
|
289
|
+
if (e.isDirectory()) {
|
|
290
|
+
if (recursive && !skipDir(childRel)) walk(path.join(abs, e.name), childRel)
|
|
291
|
+
continue
|
|
292
|
+
}
|
|
293
|
+
if (!e.isFile()) continue
|
|
294
|
+
try {
|
|
295
|
+
current.set(childRel, fs.statSync(path.join(abs, e.name)).mtimeMs)
|
|
296
|
+
} catch {
|
|
297
|
+
// raced with a delete; the next scan settles it
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
walk(dir, '')
|
|
302
|
+
if (!first) {
|
|
303
|
+
for (const [rel, mtime] of current) {
|
|
304
|
+
if (previous.get(rel) !== mtime) onEvent(rel)
|
|
305
|
+
}
|
|
306
|
+
for (const rel of previous.keys()) {
|
|
307
|
+
if (!current.has(rel)) onEvent(rel)
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
previous = current
|
|
311
|
+
first = false
|
|
312
|
+
}
|
|
313
|
+
scan()
|
|
314
|
+
// Deliberately NOT unref'd: once the native watcher is closed this timer
|
|
315
|
+
// is the only thing keeping `vx watch` alive.
|
|
316
|
+
const timer = setInterval(scan, intervalMs)
|
|
317
|
+
return {
|
|
318
|
+
close(): void {
|
|
319
|
+
clearInterval(timer)
|
|
320
|
+
},
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
109
324
|
/**
|
|
110
325
|
* `fs.watch` plus proof of delivery. On macOS a recursive watcher is an
|
|
111
326
|
* FSEvents stream that another thread schedules AFTER the call returns, and
|
|
@@ -132,10 +347,20 @@ export function armWatcher(
|
|
|
132
347
|
})
|
|
133
348
|
const watcher = fs.watch(dir, { recursive, persistent: true }, (_event, filename) => {
|
|
134
349
|
if (filename == null || typeof filename !== 'string') return
|
|
350
|
+
// An event naming the watched directory itself (macOS reports the
|
|
351
|
+
// directory a write landed in as its own item) carries nothing a key
|
|
352
|
+
// can see; the write's own event names the file.
|
|
353
|
+
if (filename === '' || filename === '.') return
|
|
135
354
|
if (filename === WATCH_PROBE) {
|
|
136
355
|
markReady(true)
|
|
137
356
|
return
|
|
138
357
|
}
|
|
358
|
+
// Another arm's probe, seen by this recursive watcher under a nested
|
|
359
|
+
// directory (a nested project's, a member base inside a project): no
|
|
360
|
+
// key sees it, and passing it on ran a cycle, and restarted a dev
|
|
361
|
+
// server, with no edit made (item 1016). Only the watcher's own probe
|
|
362
|
+
// proves this stream live.
|
|
363
|
+
if (path.basename(filename) === WATCH_PROBE) return
|
|
139
364
|
onEvent(filename)
|
|
140
365
|
})
|
|
141
366
|
const probe = path.join(dir, WATCH_PROBE)
|
|
@@ -213,23 +438,54 @@ export async function watchCmd(args: readonly string[]): Promise<number> {
|
|
|
213
438
|
process.stderr.write(`vx watch: ${resolved.nothingSelected}\n`)
|
|
214
439
|
return 0
|
|
215
440
|
}
|
|
216
|
-
// The watch loop owns SIGINT/SIGTERM for its whole lifetime
|
|
217
|
-
//
|
|
218
|
-
// cycle
|
|
219
|
-
//
|
|
220
|
-
//
|
|
221
|
-
|
|
441
|
+
// The watch loop owns SIGINT/SIGTERM for its whole lifetime. A cycle's
|
|
442
|
+
// run() must not install its exit-the-process handlers — Ctrl-C
|
|
443
|
+
// mid-cycle would kill the loop with 130 instead of the loop's own
|
|
444
|
+
// clean shutdown — so it gets `signal` instead: on SIGINT/SIGTERM the
|
|
445
|
+
// controller aborts with the signal's name as the reason, the in-flight
|
|
446
|
+
// cycle forwards it to its children (a SIGINT as SIGINT, the rest as
|
|
447
|
+
// SIGTERM; grace, SIGKILL) and returns, and the loop resolves 0.
|
|
448
|
+
// Installed BEFORE the initial run: until 2026-09-10 the handlers went
|
|
449
|
+
// in with the loop, so a SIGTERM during the initial run took Bun's
|
|
450
|
+
// default (exit 143) and left the cycle's children running under init.
|
|
451
|
+
const stop = new AbortController()
|
|
452
|
+
const opts: RunOptions = {
|
|
453
|
+
...resolved,
|
|
454
|
+
handleSignals: false,
|
|
455
|
+
signal: stop.signal,
|
|
456
|
+
holdPersistent: true,
|
|
457
|
+
}
|
|
458
|
+
// Every cycle's `invocations` row names the verb, as `vx run`'s does;
|
|
459
|
+
// run()'s process.argv fallback put the bin's absolute path there, so
|
|
460
|
+
// `vx last --list` showed `$ /…/bin.ts watch build` beside `$ vx run build`.
|
|
461
|
+
opts.command = ['vx', 'watch', ...args].join(' ')
|
|
462
|
+
// A staged load from the selection pass is one run's worth of configs;
|
|
463
|
+
// every cycle after an edit must evaluate live.
|
|
464
|
+
delete opts.staged
|
|
465
|
+
process.once('SIGINT', () => {
|
|
466
|
+
process.stdout.write('\nvx watch: stopped\n')
|
|
467
|
+
stop.abort('SIGINT')
|
|
468
|
+
})
|
|
469
|
+
process.once('SIGTERM', () => stop.abort('SIGTERM'))
|
|
470
|
+
// A task runs in its own session (exec/kill-tree.ts): the terminal
|
|
471
|
+
// closing reaches the loop alone, and the loop passes it on.
|
|
472
|
+
process.once('SIGHUP', () => stop.abort('SIGHUP'))
|
|
222
473
|
|
|
223
474
|
// Enumerate projects-in-scope so we know what dirs to watch.
|
|
224
475
|
// `opts.projects` is the resolved scope; undefined means "every
|
|
225
|
-
// project".
|
|
226
|
-
//
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
476
|
+
// project". The watched set is what a cycle can RUN: the scope plus
|
|
477
|
+
// its transitive dependencies (a cycle runs `lib#build` for
|
|
478
|
+
// `app#build`'s `^build`, so a `lib` edit is an edit) — the same
|
|
479
|
+
// closure `--filter 'app...'` walks, computed below once the initial
|
|
480
|
+
// run has staged the configs. Plus the workspace root, for lockfile
|
|
481
|
+
// changes.
|
|
482
|
+
const reads: LoadReads = new Map()
|
|
483
|
+
const workspaceRoot = await findWorkspaceRoot(cwd, reads)
|
|
484
|
+
const workspace = await loadWorkspace(workspaceRoot, reads)
|
|
485
|
+
const allProjects = await listProjects(workspace)
|
|
486
|
+
const inScope = (all: readonly ProjectMeta[]): ProjectMeta[] =>
|
|
487
|
+
opts.projects === undefined ? [...all] : all.filter((p) => opts.projects!.includes(p.name))
|
|
488
|
+
const scope = inScope(allProjects)
|
|
233
489
|
|
|
234
490
|
if (scope.length === 0) {
|
|
235
491
|
process.stderr.write(`vx watch: no projects in scope\n`)
|
|
@@ -248,107 +504,540 @@ export async function watchCmd(args: readonly string[]): Promise<number> {
|
|
|
248
504
|
// closing it properly needs the workspaceWide decision made without loading
|
|
249
505
|
// configs.
|
|
250
506
|
process.stdout.write('vx watch: initial run...\n\n')
|
|
251
|
-
await runOrchestrator(opts)
|
|
507
|
+
const initial = await runOrchestrator(opts)
|
|
508
|
+
if (stop.signal.aborted) {
|
|
509
|
+
await initial.persistent?.stop(forwardedSignal(stop.signal.reason))
|
|
510
|
+
return 0
|
|
511
|
+
}
|
|
252
512
|
|
|
253
|
-
const
|
|
513
|
+
const load: CliLoadOptions = {
|
|
514
|
+
...(opts.cacheDir !== undefined ? { cacheDir: opts.cacheDir } : {}),
|
|
515
|
+
...(opts.frozen === true ? { frozen: true } : {}),
|
|
516
|
+
}
|
|
517
|
+
const swept = await sweepConfigs(allProjects, workspaceRoot, load)
|
|
518
|
+
const watched = await watchedProjects(workspaceRoot, allProjects, scope, load, swept.staged)
|
|
519
|
+
const ws = await loadCliWorkspace(workspaceRoot)
|
|
254
520
|
return await runWatchLoop({
|
|
255
521
|
opts,
|
|
522
|
+
held: initial.persistent,
|
|
523
|
+
stop: stop.signal,
|
|
256
524
|
workspaceRoot,
|
|
257
|
-
projects:
|
|
525
|
+
projects: watched,
|
|
258
526
|
workspaceWide: swept.workspaceWide,
|
|
527
|
+
projectDirs: watched.map((p) => p.dir),
|
|
528
|
+
workspaceInputs: swept.workspaceInputs,
|
|
259
529
|
outputs: swept.outputs,
|
|
530
|
+
inputs: swept.inputs,
|
|
531
|
+
uncached: swept.uncached,
|
|
532
|
+
configImports: swept.configImports,
|
|
533
|
+
workspaceConfigImports: swept.workspaceConfigImports,
|
|
534
|
+
memberBases: memberBaseDirs(workspace),
|
|
535
|
+
packageDirs: new Set(allProjects.map((p) => p.dir)),
|
|
536
|
+
// The workspace as the cycle that just ran saw it: a package added or
|
|
537
|
+
// removed since the loop armed joins or leaves the watched set. The
|
|
538
|
+
// scope is the one resolved at start; a new package joins it only as a
|
|
539
|
+
// dependency of it.
|
|
540
|
+
rediscover: async () => {
|
|
541
|
+
const all = await listProjects(await loadWorkspace(workspaceRoot))
|
|
542
|
+
const sweep = await sweepConfigs(all, workspaceRoot, load)
|
|
543
|
+
const now = await watchedProjects(workspaceRoot, all, inScope(all), load, sweep.staged)
|
|
544
|
+
return {
|
|
545
|
+
projects: now,
|
|
546
|
+
workspaceWide: sweep.workspaceWide,
|
|
547
|
+
workspaceInputs: sweep.workspaceInputs,
|
|
548
|
+
outputs: sweep.outputs,
|
|
549
|
+
inputs: sweep.inputs,
|
|
550
|
+
uncached: sweep.uncached,
|
|
551
|
+
configImports: sweep.configImports,
|
|
552
|
+
workspaceConfigImports: sweep.workspaceConfigImports,
|
|
553
|
+
packageDirs: new Set(all.map((p) => p.dir)),
|
|
554
|
+
}
|
|
555
|
+
},
|
|
556
|
+
// Under --frozen every cycle's configs are the lock's, so a re-lock is
|
|
557
|
+
// the one edit that changes what a cycle runs; unheard, the loop ran
|
|
558
|
+
// the old lock until a restart (item 971).
|
|
559
|
+
claimedRootFiles: new Set([
|
|
560
|
+
...fingerprintClaims(ws.plugins).keys(),
|
|
561
|
+
...(opts.frozen === true ? [LOCKFILE_NAME] : []),
|
|
562
|
+
]),
|
|
260
563
|
// The RESOLVED cache dir, not the `.vx` literal — see `makeWatchIgnore`.
|
|
261
|
-
cacheDir:
|
|
262
|
-
opts.cacheDir ?? resolveCacheDir(workspaceRoot, await loadWorkspaceConfig(workspaceRoot)),
|
|
564
|
+
cacheDir: opts.cacheDir ?? ws.cacheDir,
|
|
263
565
|
})
|
|
264
566
|
}
|
|
265
567
|
|
|
568
|
+
/**
|
|
569
|
+
* The projects a cycle can run, so both watcher arms cover the same dirs:
|
|
570
|
+
* the scope plus its transitive dependencies through the package graph
|
|
571
|
+
* and the cross-project `dependsOn` edges the task graph adds
|
|
572
|
+
* (`e2e` → `app` from `dependsOn: ['app#build']`). Before this, the
|
|
573
|
+
* per-project arm watched the filter's answer only, so
|
|
574
|
+
* `vx watch build --filter app` never re-ran on a `lib` edit that
|
|
575
|
+
* `vx run build --filter app` would have rebuilt. A whole-workspace
|
|
576
|
+
* scope is every project already and walks nothing. A config the loader
|
|
577
|
+
* rejects contributes no edges here; the run that just happened said so.
|
|
578
|
+
*/
|
|
579
|
+
export async function watchedProjects(
|
|
580
|
+
workspaceRoot: string,
|
|
581
|
+
allProjects: readonly ProjectMeta[],
|
|
582
|
+
scope: readonly ProjectMeta[],
|
|
583
|
+
load: CliLoadOptions = {},
|
|
584
|
+
staged: ReadonlyMap<string, ProjectEntry> | null = null,
|
|
585
|
+
): Promise<ProjectMeta[]> {
|
|
586
|
+
if (scope.length === allProjects.length) return [...allProjects]
|
|
587
|
+
let edges: Map<string, string[]> | undefined
|
|
588
|
+
try {
|
|
589
|
+
edges =
|
|
590
|
+
staged !== null
|
|
591
|
+
? taskEdgesFrom(staged)
|
|
592
|
+
: (await taskEdges(workspaceRoot, allProjects, load)).edges
|
|
593
|
+
} catch {
|
|
594
|
+
edges = undefined
|
|
595
|
+
}
|
|
596
|
+
const graph = buildPackageGraph([...allProjects], edges)
|
|
597
|
+
const names = new Set(scope.map((p) => p.name))
|
|
598
|
+
for (const p of scope) for (const d of graph.transitiveDeps(p.name)) names.add(d)
|
|
599
|
+
return allProjects.filter((p) => names.has(p.name))
|
|
600
|
+
}
|
|
601
|
+
|
|
266
602
|
/**
|
|
267
603
|
* One sweep over every config for the two things the loop needs: whether
|
|
268
604
|
* any task declares `inputs.workspaceFiles` — globs with no project
|
|
269
|
-
*
|
|
270
|
-
*
|
|
271
|
-
*
|
|
272
|
-
*
|
|
273
|
-
*
|
|
274
|
-
*
|
|
275
|
-
* scope
|
|
605
|
+
* prefix, so the whole tree has to be watched — and every project's
|
|
606
|
+
* declared outputs, so their writes are not taken for edits. The run
|
|
607
|
+
* path's load (`loadProjects`): the plugin `project` stage applies, so an
|
|
608
|
+
* output a plugin gave a config-less package is ignored like a declared
|
|
609
|
+
* one, and a pure config is served from its cached evaluation rather than
|
|
610
|
+
* re-evaluated in a worker. A config that fails to load is out of this
|
|
611
|
+
* concern's scope — the run that just happened already said so, or will —
|
|
612
|
+
* and the sweep falls back to the files that do load, one by one.
|
|
276
613
|
*/
|
|
277
|
-
async function sweepConfigs(
|
|
614
|
+
export async function sweepConfigs(
|
|
278
615
|
projects: readonly ProjectMeta[],
|
|
279
616
|
workspaceRoot: string,
|
|
280
|
-
|
|
617
|
+
load: CliLoadOptions = {},
|
|
618
|
+
): Promise<{
|
|
619
|
+
workspaceWide: boolean
|
|
620
|
+
workspaceInputs: string[]
|
|
621
|
+
outputs: Map<string, string[]>
|
|
622
|
+
/** Declared input globs per directory they are relative to, the counterweight to `outputs`. */
|
|
623
|
+
inputs: Map<string, string[]>
|
|
624
|
+
/** Projects with a task that has a command and no cache: it reads what it likes, git-ignored files included. */
|
|
625
|
+
uncached: Set<string>
|
|
626
|
+
/** Files the project configs import by relative path (item 949). */
|
|
627
|
+
configImports: string[]
|
|
628
|
+
/** Files the workspace config imports by relative path: loaded once per process (item 949). */
|
|
629
|
+
workspaceConfigImports: string[]
|
|
630
|
+
/** The staged load the sweep read, when the run path's load succeeded; `watchedProjects` reads the same one. */
|
|
631
|
+
staged: Map<string, ProjectEntry> | null
|
|
632
|
+
}> {
|
|
281
633
|
const outputs = new Map<string, string[]>()
|
|
282
|
-
const
|
|
634
|
+
const inputs = new Map<string, string[]>()
|
|
635
|
+
const addTo = (
|
|
636
|
+
into: Map<string, string[]>,
|
|
637
|
+
dir: string,
|
|
638
|
+
globs: readonly string[] | undefined,
|
|
639
|
+
): void => {
|
|
283
640
|
if (globs === undefined || globs.length === 0) return
|
|
284
|
-
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
const
|
|
291
|
-
|
|
292
|
-
if (
|
|
641
|
+
into.set(dir, [...(into.get(dir) ?? []), ...globs])
|
|
642
|
+
}
|
|
643
|
+
const add = (dir: string, globs: readonly string[] | undefined): void =>
|
|
644
|
+
addTo(outputs, dir, globs)
|
|
645
|
+
const workspaceInputs = new Set<string>()
|
|
646
|
+
const uncached = new Set<string>()
|
|
647
|
+
const fold = (dir: string, config: ProjectConfig): void => {
|
|
648
|
+
for (const task of Object.values(config.tasks ?? {})) {
|
|
649
|
+
if (task.cache === undefined && task.exec !== undefined && task.exec.persistent === undefined)
|
|
650
|
+
uncached.add(dir)
|
|
651
|
+
for (const g of task.cache?.inputs?.workspaceFiles ?? []) workspaceInputs.add(g)
|
|
652
|
+
addTo(inputs, dir, task.cache?.inputs?.files)
|
|
653
|
+
addTo(inputs, workspaceRoot, task.cache?.inputs?.workspaceFiles)
|
|
654
|
+
add(dir, task.cache?.outputs?.files)
|
|
655
|
+
add(workspaceRoot, task.cache?.outputs?.workspaceFiles)
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
const result = (
|
|
659
|
+
staged: Map<string, ProjectEntry> | null,
|
|
660
|
+
): {
|
|
661
|
+
workspaceWide: boolean
|
|
662
|
+
workspaceInputs: string[]
|
|
663
|
+
outputs: Map<string, string[]>
|
|
664
|
+
inputs: Map<string, string[]>
|
|
665
|
+
uncached: Set<string>
|
|
666
|
+
configImports: string[]
|
|
667
|
+
workspaceConfigImports: string[]
|
|
668
|
+
staged: Map<string, ProjectEntry> | null
|
|
669
|
+
} => ({
|
|
670
|
+
workspaceWide: workspaceInputs.size > 0,
|
|
671
|
+
workspaceInputs: [...workspaceInputs],
|
|
672
|
+
outputs,
|
|
673
|
+
inputs,
|
|
674
|
+
uncached,
|
|
675
|
+
configImports: [...projectImports],
|
|
676
|
+
workspaceConfigImports: [...wsImports],
|
|
677
|
+
staged,
|
|
678
|
+
})
|
|
679
|
+
const projectImports = new Set<string>()
|
|
680
|
+
const wsImports = new Set<string>()
|
|
681
|
+
await Promise.all([
|
|
682
|
+
...projects.map(async (p) => {
|
|
683
|
+
if (p.configPath === null) return
|
|
684
|
+
for (const f of await configImports(p.configPath)) projectImports.add(f)
|
|
685
|
+
}),
|
|
686
|
+
...WORKSPACE_CONFIG_FILENAMES.map(async (name) => {
|
|
687
|
+
for (const f of await configImports(path.join(workspaceRoot, name))) wsImports.add(f)
|
|
688
|
+
}),
|
|
689
|
+
])
|
|
690
|
+
let staged: Map<string, ProjectEntry> | null = null
|
|
691
|
+
try {
|
|
692
|
+
staged = await loadCliProjects(workspaceRoot, projects, 'all', load)
|
|
693
|
+
} catch {
|
|
694
|
+
staged = null
|
|
695
|
+
}
|
|
696
|
+
if (staged !== null) {
|
|
697
|
+
for (const p of staged.values()) fold(p.dir, p.config)
|
|
698
|
+
return result(staged)
|
|
699
|
+
}
|
|
700
|
+
await Promise.all(
|
|
701
|
+
projects.map(async (p) => {
|
|
702
|
+
if (p.configPath === null) return
|
|
293
703
|
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
|
|
704
|
+
fold(p.dir, await loadProjectConfig(p.configPath))
|
|
302
705
|
} catch {
|
|
303
706
|
// broken config — out of this concern's scope
|
|
304
|
-
return false
|
|
305
707
|
}
|
|
306
708
|
}),
|
|
307
709
|
)
|
|
308
|
-
return
|
|
710
|
+
return result(null)
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
/**
|
|
714
|
+
* Under the recursive root watcher, the events that can move a key. The
|
|
715
|
+
* watcher hears every write in the workspace; a key can see three kinds
|
|
716
|
+
* of path and no other: a file inside a project's directory (its own
|
|
717
|
+
* `inputs.files`, or its outputs, which the ignore filter drops next), a
|
|
718
|
+
* workspace fingerprint file at the root, and a match of a declared
|
|
719
|
+
* `inputs.workspaceFiles` glob. Everything else — a log written at the
|
|
720
|
+
* root, a `coverage/` or `.turbo/` tree, an editor's scratch file — is
|
|
721
|
+
* dropped before the trigger, so it costs no cycle. Before this rule the
|
|
722
|
+
* one Turbo idiom that puts every repo on this watcher
|
|
723
|
+
* (`globalDependencies` → `workspaceFiles`) made `vx watch … > build.log`
|
|
724
|
+
* inside the repo a loop that never settled: each cycle's output grew the
|
|
725
|
+
* log, the log was an event, the event was a cycle. Negated globs are not
|
|
726
|
+
* consulted: a `!` only narrows, and an event it would have excluded costs
|
|
727
|
+
* one cache-hit cycle, which is the wrong direction to be clever in.
|
|
728
|
+
*/
|
|
729
|
+
export function makeRootEventFilter(
|
|
730
|
+
workspaceRoot: string,
|
|
731
|
+
projectDirs: readonly string[],
|
|
732
|
+
workspaceInputs: readonly string[],
|
|
733
|
+
claimedRootFiles: ReadonlySet<string> = new Set(),
|
|
734
|
+
): (filename: string) => boolean {
|
|
735
|
+
const dirs = projectDirs.map((d) => path.resolve(d))
|
|
736
|
+
const globs = workspaceInputs
|
|
737
|
+
.map(normalizeGlob)
|
|
738
|
+
.filter((g) => !g.startsWith('!'))
|
|
739
|
+
.map((g) => taskGlob(g))
|
|
740
|
+
return (filename: string): boolean => {
|
|
741
|
+
const rel = filename.split(path.sep).join('/')
|
|
742
|
+
// The depth test is a READING AID, not a guard: both predicates below
|
|
743
|
+
// are exact membership in a set of BARE names, so a `rel` carrying a
|
|
744
|
+
// slash can never be in one. Measured with it removed (item 502) over
|
|
745
|
+
// `nested/bun.lock`, `./bun.lock`, `a/b/c/pnpm-workspace.yaml` and
|
|
746
|
+
// eight more: every answer identical. The two table rows that look
|
|
747
|
+
// like they pin it — `nested/pnpm-lock.yaml → false` and
|
|
748
|
+
// `nested/vx.workspace.ts → false` — pass either way; what they
|
|
749
|
+
// actually pin is that the predicates stay exact rather than becoming
|
|
750
|
+
// a basename or suffix match, which is the change that WOULD make
|
|
751
|
+
// this line load-bearing.
|
|
752
|
+
if (
|
|
753
|
+
!rel.includes('/') &&
|
|
754
|
+
(isWorkspaceFingerprintFile(rel) || isWorkspaceConfigFile(rel) || claimedRootFiles.has(rel))
|
|
755
|
+
) {
|
|
756
|
+
return true
|
|
757
|
+
}
|
|
758
|
+
const abs = path.resolve(workspaceRoot, filename)
|
|
759
|
+
for (const d of dirs) if (abs === d || abs.startsWith(d + path.sep)) return true
|
|
760
|
+
for (const g of globs) if (g.match(rel)) return true
|
|
761
|
+
return false
|
|
762
|
+
}
|
|
309
763
|
}
|
|
310
764
|
|
|
311
765
|
interface WatchLoopArgs {
|
|
312
766
|
opts: RunOptions
|
|
767
|
+
/** The persistent tasks the initial run left running; the next cycle stops them before it starts. */
|
|
768
|
+
held: HeldPersistent | undefined
|
|
769
|
+
/** Aborted by the SIGINT/SIGTERM handlers `watchCmd` installed; the loop drains its cycle and resolves. */
|
|
770
|
+
stop: AbortSignal
|
|
313
771
|
workspaceRoot: string
|
|
314
772
|
projects: readonly ProjectMeta[]
|
|
315
773
|
workspaceWide: boolean
|
|
774
|
+
/** Every project's directory, in scope or not — under the root watcher an edit there is an edit. */
|
|
775
|
+
projectDirs: readonly string[]
|
|
776
|
+
/** Every declared `inputs.workspaceFiles` glob, root-relative. */
|
|
777
|
+
workspaceInputs: readonly string[]
|
|
316
778
|
/** Absolute, already-resolved — the loop must never re-derive it. */
|
|
317
779
|
cacheDir: string
|
|
318
780
|
/** Declared output globs per directory they are relative to (project dir, or the root for `workspaceFiles`). */
|
|
319
781
|
outputs: ReadonlyMap<string, readonly string[]>
|
|
782
|
+
/** Declared input globs per directory: never ignored as another task's output. */
|
|
783
|
+
inputs: ReadonlyMap<string, readonly string[]>
|
|
784
|
+
/** Projects whose uncached task may read a git-ignored file: such a path there is still an edit. */
|
|
785
|
+
uncached: ReadonlySet<string>
|
|
786
|
+
/** Files the configs import: an edit to one outside the watched projects is a cycle that re-reads them. */
|
|
787
|
+
configImports: readonly string[]
|
|
788
|
+
/** Files the workspace config imports: loaded once per process, so an edit is named, not run. */
|
|
789
|
+
workspaceConfigImports: readonly string[]
|
|
790
|
+
/**
|
|
791
|
+
* Root files a plugin claims (`VxPlugin.fingerprint`): a lockfile, or a
|
|
792
|
+
* file its stages read (`turbo.json`, item 961). The workspace config is
|
|
793
|
+
* loaded once per process, so the set is fixed for the loop.
|
|
794
|
+
*/
|
|
795
|
+
claimedRootFiles: ReadonlySet<string>
|
|
796
|
+
/** The directory each `<dir>/*` package glob names; a member coming or going there is a cycle. */
|
|
797
|
+
memberBases: readonly string[]
|
|
798
|
+
/** Every package's directory, in scope or not: a member base's other entries are packages still to come. */
|
|
799
|
+
packageDirs: ReadonlySet<string>
|
|
800
|
+
/** The watched set again, after a cycle that followed an event which can change it. */
|
|
801
|
+
rediscover: () => Promise<Rediscovered>
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
interface Rediscovered {
|
|
805
|
+
projects: readonly ProjectMeta[]
|
|
806
|
+
workspaceWide: boolean
|
|
807
|
+
workspaceInputs: readonly string[]
|
|
808
|
+
outputs: ReadonlyMap<string, readonly string[]>
|
|
809
|
+
inputs: ReadonlyMap<string, readonly string[]>
|
|
810
|
+
uncached: ReadonlySet<string>
|
|
811
|
+
configImports: readonly string[]
|
|
812
|
+
workspaceConfigImports: readonly string[]
|
|
813
|
+
packageDirs: ReadonlySet<string>
|
|
320
814
|
}
|
|
321
815
|
|
|
816
|
+
const PROJECT_CONFIGS: ReadonlySet<string> = new Set(PROJECT_CONFIG_FILENAMES)
|
|
817
|
+
|
|
818
|
+
/**
|
|
819
|
+
* A file whose edit can change what the loop watches: a `package.json`
|
|
820
|
+
* (a dependency added under `--filter` widens the closure), a project
|
|
821
|
+
* config (a task that starts declaring `workspaceFiles` needs the root
|
|
822
|
+
* watcher, a new output must stop being an event) or the workspace config
|
|
823
|
+
* (a plugin's `project` stage). Until item 891 only a member directory
|
|
824
|
+
* coming or going re-read the set, so each of these waited for a restart
|
|
825
|
+
* while the loop looked alive.
|
|
826
|
+
*/
|
|
827
|
+
function shapesWatchedSet(filename: string): boolean {
|
|
828
|
+
const base = path.basename(filename)
|
|
829
|
+
return base === 'package.json' || PROJECT_CONFIGS.has(base) || isWorkspaceConfigFile(base)
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
const CLOSED: WatchHandle = { close() {} }
|
|
833
|
+
|
|
322
834
|
async function runWatchLoop(args: WatchLoopArgs): Promise<number> {
|
|
323
|
-
const { opts, workspaceRoot, projects,
|
|
835
|
+
const { opts, stop, workspaceRoot, projects, cacheDir, memberBases } = args
|
|
836
|
+
// The watched set as of the last cycle: `rearm` replaces these after an
|
|
837
|
+
// event that can change it, and every filter below reads the current one.
|
|
838
|
+
let workspaceWide = args.workspaceWide
|
|
839
|
+
let packageDirs = args.packageDirs
|
|
840
|
+
let projectDirs = args.projectDirs
|
|
841
|
+
let workspaceInputs = args.workspaceInputs
|
|
842
|
+
let outputs = args.outputs
|
|
843
|
+
let inputs = args.inputs
|
|
844
|
+
let uncached = args.uncached
|
|
845
|
+
let configImportFiles = args.configImports
|
|
846
|
+
let wsConfigImportFiles = args.workspaceConfigImports
|
|
847
|
+
// A dev server stays up while the loop idles; the cycle that replaces it
|
|
848
|
+
// stops it first, so the new one never meets the old one's port.
|
|
849
|
+
let held = args.held
|
|
324
850
|
|
|
325
|
-
// Reentrancy guard — never two orchestrator runs in flight.
|
|
326
|
-
// one is running
|
|
327
|
-
//
|
|
851
|
+
// Reentrancy guard — never two orchestrator runs in flight. Events that
|
|
852
|
+
// land while one is running wait in `pendingPaths` and are judged, on
|
|
853
|
+
// settled bytes, one debounce window after it ends.
|
|
328
854
|
let running = false
|
|
329
|
-
|
|
855
|
+
/** The cycle in flight, so the stop path can wait for its teardown before resolving. */
|
|
856
|
+
let inFlight: Promise<void> = Promise.resolve()
|
|
330
857
|
let debounceTimer: ReturnType<typeof setTimeout> | null = null
|
|
331
858
|
|
|
332
|
-
|
|
859
|
+
// Declared outputs are ignored by PATH above. A task with no `cache`
|
|
860
|
+
// block declares none and still writes into its project, and the
|
|
861
|
+
// watcher sees the write: run 1 writes dist/x, the event re-runs, run 2
|
|
862
|
+
// writes the same bytes, the event re-runs — forever (the init
|
|
863
|
+
// walkthrough, 2026-09-04: every fresh workspace, since `init` emits no
|
|
864
|
+
// cache block). An undeclared write is caught by STATE, judged on what
|
|
865
|
+
// has settled (see `trigger`): a path whose settled state equals what
|
|
866
|
+
// this loop last saw for it is not a change. A file's state is its
|
|
867
|
+
// bytes; a directory's is its entries' names and sizes (a nested edit
|
|
868
|
+
// arrives as that path's own event); a path that is gone is one more
|
|
869
|
+
// state. A real edit changes the state; a first sighting passes through.
|
|
870
|
+
// So `rm -rf dist && tsc` — the shape of most build scripts — settles
|
|
871
|
+
// to the same `dist` it left and is one redundant cycle, not a loop
|
|
872
|
+
// (2026-09-10: 780 executions in two minutes from one edit, when a
|
|
873
|
+
// deletion and a directory each passed the gate unconditionally).
|
|
874
|
+
const ABSENT = -1n
|
|
875
|
+
const lastState = new Map<string, bigint>()
|
|
876
|
+
const settledState = (abs: string): bigint => {
|
|
877
|
+
let st: fs.Stats
|
|
878
|
+
try {
|
|
879
|
+
st = fs.statSync(abs)
|
|
880
|
+
} catch {
|
|
881
|
+
return ABSENT
|
|
882
|
+
}
|
|
883
|
+
if (!st.isDirectory()) {
|
|
884
|
+
try {
|
|
885
|
+
return xxh3(fs.readFileSync(abs))
|
|
886
|
+
} catch {
|
|
887
|
+
return ABSENT
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
const entries: string[] = []
|
|
891
|
+
try {
|
|
892
|
+
for (const e of fs.readdirSync(abs, { withFileTypes: true })) {
|
|
893
|
+
let size = 0
|
|
894
|
+
if (e.isFile()) {
|
|
895
|
+
try {
|
|
896
|
+
size = fs.statSync(path.join(abs, e.name)).size
|
|
897
|
+
} catch {
|
|
898
|
+
size = -1
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
entries.push(`${e.name}\0${e.isDirectory() ? 'd' : size}`)
|
|
902
|
+
}
|
|
903
|
+
} catch {
|
|
904
|
+
return ABSENT
|
|
905
|
+
}
|
|
906
|
+
entries.sort()
|
|
907
|
+
return xxh3(Buffer.from(entries.join('\n')))
|
|
908
|
+
}
|
|
909
|
+
// A path this loop has never judged is a change only if it moved since
|
|
910
|
+
// the watchers went live (its mtime or ctime, `modifiedBefore`). macOS delivers the initial run's own writes
|
|
911
|
+
// AFTER the arm (CI, 2026-09-11: `app dist; re-running...` with no edit
|
|
912
|
+
// made — FSEvents hands a stream what landed just before it started),
|
|
913
|
+
// and a first sighting used to pass unconditionally; the path's mtime
|
|
914
|
+
// says which side of the arm it belongs to. A path already gone is a
|
|
915
|
+
// change: a deletion has no date to read.
|
|
916
|
+
const sameState = (abs: string): boolean => {
|
|
917
|
+
const state = settledState(abs)
|
|
918
|
+
const prev = lastState.get(abs)
|
|
919
|
+
lastState.set(abs, state)
|
|
920
|
+
if (prev !== undefined) return prev === state
|
|
921
|
+
return state !== ABSENT && modifiedBefore(abs, armedAt)
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
// Paths that fired since the last judgement, first label wins. The state
|
|
925
|
+
// check runs when the timer fires, on SETTLED state: per event it is
|
|
926
|
+
// wrong on Linux, where a shell redirect truncates the file (one event,
|
|
927
|
+
// empty) and then writes it (another, full), so consecutive events never
|
|
928
|
+
// agree and a self-write loops anyway (CI, 2026-09-04: 9 re-runs where
|
|
929
|
+
// macOS, which coalesces the two, saw 2). While a cycle runs, nothing is
|
|
930
|
+
// judged: the run's own writes are mid-flight (a `dist` deleted and not
|
|
931
|
+
// yet rebuilt is a state the tree will not keep), so the paths wait and
|
|
932
|
+
// are judged one window after the run ends, all together — an edit made
|
|
933
|
+
// meanwhile still differs from what the loop last saw and re-runs.
|
|
934
|
+
const pendingPaths = new Map<string, string>()
|
|
935
|
+
// A path that starts cycle after cycle from the run's own writes is a
|
|
936
|
+
// task rewriting a file with different bytes every run (a pid file, a
|
|
937
|
+
// timestamped log): the state gate cannot settle it, and nothing here
|
|
938
|
+
// can tell the third such write from a user's third save mid-run — so
|
|
939
|
+
// watch names it once, with the remedy, and keeps going. "The run's
|
|
940
|
+
// own write" is read off the path itself: its mtime falls inside the
|
|
941
|
+
// previous cycle's window. Not off which judgement started the cycle:
|
|
942
|
+
// macOS delivers a run's writes late, after the loop's own post-run
|
|
943
|
+
// judgement found nothing and broke out, so there every such cycle
|
|
944
|
+
// starts from the idle timer (CI, 2026-09-16: the storm ran, the
|
|
945
|
+
// notice never came).
|
|
946
|
+
const streak = { abs: '', n: 0 }
|
|
947
|
+
const noticed = new Set<string>()
|
|
948
|
+
let lastCycle: { start: number; end: number } | undefined
|
|
949
|
+
const writtenDuringLastCycle = (abs: string, openWhileHeld = false): boolean => {
|
|
950
|
+
// A server the last cycle left running is still that cycle's: its
|
|
951
|
+
// writes land after the cycle ended, and with a closed window a dev
|
|
952
|
+
// server that rewrites a log in its project restarted itself forever
|
|
953
|
+
// with no word of it, 12 restarts in 8 s (item 948). The initial run's
|
|
954
|
+
// server is one too, from the arm on.
|
|
955
|
+
const open = openWhileHeld && held !== undefined
|
|
956
|
+
if (lastCycle === undefined && !open) return false
|
|
957
|
+
try {
|
|
958
|
+
const m = fs.statSync(abs).mtimeMs
|
|
959
|
+
const start = lastCycle?.start ?? armedAt
|
|
960
|
+
return m >= start && (open || m <= lastCycle!.end)
|
|
961
|
+
} catch {
|
|
962
|
+
return false
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
const judge = (): string | undefined => {
|
|
966
|
+
const ignored = gitIgnored(workspaceRoot, [...pendingPaths.keys()])
|
|
967
|
+
let first: string | undefined
|
|
968
|
+
let firstAbs: string | undefined
|
|
969
|
+
// `sameState` before the `first` test, never after it: it is what
|
|
970
|
+
// RECORDS a path's settled state, and every path this judgement saw
|
|
971
|
+
// must be recorded even though only the first change names the cycle.
|
|
972
|
+
// Short-circuiting after the winner leaves the rest unjudged, and
|
|
973
|
+
// their next event is a first sighting stamped after the arm — so a
|
|
974
|
+
// batch edit (a `git checkout`) makes the same bytes written to any
|
|
975
|
+
// of the others a change.
|
|
976
|
+
// A git-ignored path keys nothing, but a task with no cache has no key:
|
|
977
|
+
// it reads what it likes, and its `.env.local` edit re-ran nothing
|
|
978
|
+
// (item 947). Under such a project an ignored path is judged when the
|
|
979
|
+
// user wrote it; one the last cycle wrote (the pid file an uncached
|
|
980
|
+
// task rewrites every run, the loop the filter exists for) stays out.
|
|
981
|
+
const editForUncached = (p: string): boolean =>
|
|
982
|
+
[...uncached].some((dir) => p.startsWith(dir + path.sep)) && !writtenDuringLastCycle(p)
|
|
983
|
+
for (const [p, l] of pendingPaths) {
|
|
984
|
+
if (ignored.has(p) && !editForUncached(p)) continue
|
|
985
|
+
if (!sameState(p) && first === undefined) {
|
|
986
|
+
first = l
|
|
987
|
+
firstAbs = p
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
pendingPaths.clear()
|
|
991
|
+
const byServer = firstAbs !== undefined && held !== undefined
|
|
992
|
+
if (firstAbs === undefined || !writtenDuringLastCycle(firstAbs, true)) {
|
|
993
|
+
streak.n = 0
|
|
994
|
+
return first
|
|
995
|
+
}
|
|
996
|
+
streak.n = streak.abs === firstAbs ? streak.n + 1 : 1
|
|
997
|
+
streak.abs = firstAbs
|
|
998
|
+
if (streak.n >= 3 && !noticed.has(firstAbs)) {
|
|
999
|
+
noticed.add(firstAbs)
|
|
1000
|
+
process.stdout.write(
|
|
1001
|
+
byServer
|
|
1002
|
+
? `vx watch: ${first} has started 3 cycles in a row, written while a server the cycle before started was running — a persistent task rewrites it. Add it to .gitignore (a git-ignored path never starts a cycle); until then every write restarts the server.\n`
|
|
1003
|
+
: `vx watch: ${first} has started 3 cycles in a row, written by the cycle before each — a task rewrites it every run. Declare it in cache.outputs (an output never starts a cycle) or add it to .gitignore (a git-ignored path never does); until then every run re-runs.\n`,
|
|
1004
|
+
)
|
|
1005
|
+
}
|
|
1006
|
+
return first
|
|
1007
|
+
}
|
|
1008
|
+
const trigger = (label: string, abs: string): void => {
|
|
1009
|
+
if (!pendingPaths.has(abs)) pendingPaths.set(abs, label)
|
|
1010
|
+
if (running) return
|
|
333
1011
|
if (debounceTimer) clearTimeout(debounceTimer)
|
|
334
1012
|
debounceTimer = setTimeout(() => {
|
|
335
1013
|
debounceTimer = null
|
|
336
|
-
|
|
1014
|
+
if (running) return
|
|
1015
|
+
const first = judge()
|
|
1016
|
+
if (first !== undefined) void cycle(first)
|
|
337
1017
|
}, DEBOUNCE_MS)
|
|
338
1018
|
}
|
|
339
1019
|
|
|
340
1020
|
const cycle = async (label: string): Promise<void> => {
|
|
341
|
-
if (running)
|
|
342
|
-
pending = true
|
|
343
|
-
return
|
|
344
|
-
}
|
|
1021
|
+
if (stop.aborted || running) return
|
|
345
1022
|
running = true
|
|
1023
|
+
const done = (inFlight = runCycle(label))
|
|
1024
|
+
await done
|
|
1025
|
+
}
|
|
1026
|
+
const runCycle = async (first: string): Promise<void> => {
|
|
346
1027
|
try {
|
|
347
|
-
|
|
348
|
-
|
|
1028
|
+
let label: string | undefined = first
|
|
1029
|
+
while (label !== undefined && !stop.aborted) {
|
|
349
1030
|
process.stdout.write(`\nvx watch: ${label}; re-running...\n\n`)
|
|
350
1031
|
try {
|
|
351
|
-
await
|
|
1032
|
+
await held?.stop()
|
|
1033
|
+
held = undefined
|
|
1034
|
+
const start = Date.now()
|
|
1035
|
+
held = (await runOrchestrator(opts)).persistent
|
|
1036
|
+
lastCycle = { start, end: Date.now() }
|
|
1037
|
+
if (reread && !stop.aborted) {
|
|
1038
|
+
reread = false
|
|
1039
|
+
await rearm()
|
|
1040
|
+
}
|
|
352
1041
|
} catch (err) {
|
|
353
1042
|
// A re-run can fail catastrophically when the workspace
|
|
354
1043
|
// itself moved out from under us — e.g. the user deleted
|
|
@@ -360,10 +1049,18 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<number> {
|
|
|
360
1049
|
const message = err instanceof Error ? err.message : String(err)
|
|
361
1050
|
process.stderr.write(`vx watch: cycle failed: ${message}\n`)
|
|
362
1051
|
}
|
|
363
|
-
//
|
|
364
|
-
|
|
1052
|
+
// What landed mid-run is judged on settled state, one window
|
|
1053
|
+
// after the run, under the label of what actually arrived.
|
|
1054
|
+
if (pendingPaths.size === 0 || stop.aborted) break
|
|
1055
|
+
await Bun.sleep(DEBOUNCE_MS)
|
|
1056
|
+
label = judge()
|
|
1057
|
+
}
|
|
365
1058
|
} finally {
|
|
366
1059
|
running = false
|
|
1060
|
+
// Anything that landed after the last judgement waits for a timer
|
|
1061
|
+
// like any other event.
|
|
1062
|
+
const next = pendingAfterCycle(pendingPaths, stop.aborted)
|
|
1063
|
+
if (next !== undefined) trigger(next[1], next[0])
|
|
367
1064
|
}
|
|
368
1065
|
}
|
|
369
1066
|
|
|
@@ -371,84 +1068,337 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<number> {
|
|
|
371
1068
|
// project's dir recursively, so a `node_modules` write under a
|
|
372
1069
|
// project would otherwise trigger every save during `bun install` —
|
|
373
1070
|
// and vx's own cache writes would trigger a cycle that writes again.
|
|
374
|
-
|
|
1071
|
+
let isIgnoredPath = makeWatchIgnore(cacheDir, outputs, inputs)
|
|
1072
|
+
let matters = makeRootEventFilter(
|
|
1073
|
+
workspaceRoot,
|
|
1074
|
+
projectDirs,
|
|
1075
|
+
workspaceInputs,
|
|
1076
|
+
args.claimedRootFiles,
|
|
1077
|
+
)
|
|
1078
|
+
/** Since the last cycle, a member came or went, or a file that shapes the watched set changed (`shapesWatchedSet`). */
|
|
1079
|
+
let reread = false
|
|
375
1080
|
|
|
376
|
-
const watchers:
|
|
1081
|
+
const watchers: WatchHandle[] = []
|
|
377
1082
|
const proofs: Promise<void>[] = []
|
|
378
|
-
|
|
1083
|
+
// `VX_WATCH_POLL=1` skips the OS watcher entirely. Where it is known not
|
|
1084
|
+
// to work — a sandbox with no `machLookup` for `com.apple.FSEvents`, a
|
|
1085
|
+
// network mount, a container bind — the attempt costs a denied syscall
|
|
1086
|
+
// and a two-second wait before the fallback takes over anyway.
|
|
1087
|
+
const forcePoll = (process.env['VX_WATCH_POLL'] ?? '') !== ''
|
|
1088
|
+
if (forcePoll)
|
|
1089
|
+
process.stderr.write(`vx watch: polling every ${POLL_INTERVAL_MS} ms (VX_WATCH_POLL)\n`)
|
|
1090
|
+
// The poller skips exactly what the event filter would drop: the
|
|
1091
|
+
// unconditional segments AND this run's declared output containers. Read
|
|
1092
|
+
// through `isIgnoredPath` rather than captured, because `rearm` replaces
|
|
1093
|
+
// the filter when the selection changes.
|
|
1094
|
+
const skipUnder =
|
|
1095
|
+
(dir: string) =>
|
|
1096
|
+
(rel: string): boolean =>
|
|
1097
|
+
isIgnoredPath(dir, rel)
|
|
1098
|
+
// By slot, not by handle: an OS watcher that never proves delivery is
|
|
1099
|
+
// swapped for a poller in place, and a drop must close what is there.
|
|
1100
|
+
const arm = (
|
|
1101
|
+
dir: string,
|
|
1102
|
+
recursive: boolean,
|
|
1103
|
+
onEvent: (filename: string) => void,
|
|
1104
|
+
): WatchHandle => {
|
|
1105
|
+
const at = watchers.length
|
|
1106
|
+
const handle: WatchHandle = {
|
|
1107
|
+
close: () => {
|
|
1108
|
+
watchers[at]?.close()
|
|
1109
|
+
watchers[at] = CLOSED
|
|
1110
|
+
},
|
|
1111
|
+
}
|
|
1112
|
+
if (forcePoll) {
|
|
1113
|
+
watchers.push(pollWatcher(dir, recursive, onEvent, POLL_INTERVAL_MS, skipUnder(dir)))
|
|
1114
|
+
return handle
|
|
1115
|
+
}
|
|
379
1116
|
const armed = armWatcher(dir, recursive, onEvent)
|
|
380
1117
|
watchers.push(armed.watcher)
|
|
381
1118
|
proofs.push(
|
|
382
1119
|
armed.ready.then((ok) => {
|
|
383
|
-
if (
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
1120
|
+
if (ok) return
|
|
1121
|
+
armed.watcher.close()
|
|
1122
|
+
// Dropped by a rearm before its proof settled: nothing to swap in.
|
|
1123
|
+
if (watchers[at] !== armed.watcher) return
|
|
1124
|
+
// The watcher never proved delivery, so it is not one: an FSEvents
|
|
1125
|
+
// stream the OS refused, a filesystem that reports nothing. Swap in
|
|
1126
|
+
// the poller rather than run a loop that silently never fires.
|
|
1127
|
+
watchers[at] = pollWatcher(dir, recursive, onEvent, POLL_INTERVAL_MS, skipUnder(dir))
|
|
1128
|
+
process.stderr.write(
|
|
1129
|
+
`vx watch: ${dir}: no OS watch events within ${WATCH_PROBE_TIMEOUT_MS} ms; polling every ${POLL_INTERVAL_MS} ms instead\n`,
|
|
1130
|
+
)
|
|
388
1131
|
}),
|
|
389
1132
|
)
|
|
1133
|
+
return handle
|
|
390
1134
|
}
|
|
391
1135
|
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
1136
|
+
/** 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. */
|
|
1137
|
+
const armedAt = fsClockNow(cacheDir)
|
|
1138
|
+
/** Per-project arms by directory, so `rearm` can add and drop them. */
|
|
1139
|
+
const perProject = new Map<string, WatchHandle>()
|
|
1140
|
+
/** The root arm: recursive when workspace-wide, else the root's own files only. */
|
|
1141
|
+
let rootArm: WatchHandle = CLOSED
|
|
1142
|
+
const armProject = (proj: ProjectMeta): void => {
|
|
398
1143
|
try {
|
|
399
|
-
arm(
|
|
400
|
-
if (isIgnoredPath(
|
|
401
|
-
|
|
1144
|
+
const handle = arm(proj.dir, true, (filename) => {
|
|
1145
|
+
if (isIgnoredPath(proj.dir, filename)) return
|
|
1146
|
+
if (shapesWatchedSet(filename)) reread = true
|
|
1147
|
+
trigger(`${proj.name} ${filename}`, path.join(proj.dir, filename))
|
|
402
1148
|
})
|
|
1149
|
+
perProject.set(proj.dir, handle)
|
|
1150
|
+
} catch (err) {
|
|
1151
|
+
const msg = err instanceof Error ? err.message : String(err)
|
|
1152
|
+
process.stderr.write(`vx watch: cannot watch ${proj.dir}: ${msg}\n`)
|
|
1153
|
+
}
|
|
1154
|
+
}
|
|
1155
|
+
const armRoot = (): void => {
|
|
1156
|
+
try {
|
|
1157
|
+
rootArm = workspaceWide
|
|
1158
|
+
? // workspaceFiles inputs in play: a root-relative glob can name a
|
|
1159
|
+
// file anywhere, so one recursive root watcher replaces the
|
|
1160
|
+
// per-project ones (it also covers lockfile / pnpm-workspace.yaml
|
|
1161
|
+
// edits). `matters` keeps the events a key can see — project
|
|
1162
|
+
// trees, root fingerprint files, the declared globs — and drops
|
|
1163
|
+
// the rest of the tree; the ignore filter then keeps node_modules
|
|
1164
|
+
// / .git / .vx and declared outputs out of what remains.
|
|
1165
|
+
arm(workspaceRoot, true, (filename) => {
|
|
1166
|
+
if (!matters(filename) || isIgnoredPath(workspaceRoot, filename)) return
|
|
1167
|
+
if (shapesWatchedSet(filename) || filename === LOCKFILE_NAME) reread = true
|
|
1168
|
+
trigger(`root ${filename}`, path.join(workspaceRoot, filename))
|
|
1169
|
+
})
|
|
1170
|
+
: // The root itself, non-recursive, beside the per-project arms, so
|
|
1171
|
+
// lockfile + pnpm-workspace.yaml edits trigger re-runs even when
|
|
1172
|
+
// no project dir saw the change.
|
|
1173
|
+
arm(workspaceRoot, false, (filename) => {
|
|
1174
|
+
if (
|
|
1175
|
+
isWorkspaceFingerprintFile(filename) ||
|
|
1176
|
+
isWorkspaceConfigFile(filename) ||
|
|
1177
|
+
args.claimedRootFiles.has(filename)
|
|
1178
|
+
) {
|
|
1179
|
+
if (shapesWatchedSet(filename) || filename === LOCKFILE_NAME) reread = true
|
|
1180
|
+
trigger(`root ${filename}`, path.join(workspaceRoot, filename))
|
|
1181
|
+
}
|
|
1182
|
+
})
|
|
403
1183
|
} catch (err) {
|
|
404
1184
|
const msg = err instanceof Error ? err.message : String(err)
|
|
405
1185
|
process.stderr.write(`vx watch: cannot watch workspace root: ${msg}\n`)
|
|
406
1186
|
}
|
|
407
|
-
}
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
1187
|
+
}
|
|
1188
|
+
// One recursive watcher per project, unless workspace-wide: each project
|
|
1189
|
+
// owns its own subtree; the root is never watched recursively for them
|
|
1190
|
+
// (it would cover every project + node_modules + caches).
|
|
1191
|
+
const armMode = (watched: readonly ProjectMeta[]): void => {
|
|
1192
|
+
if (!workspaceWide) for (const proj of watched) armProject(proj)
|
|
1193
|
+
armRoot()
|
|
1194
|
+
}
|
|
1195
|
+
const dropMode = (): void => {
|
|
1196
|
+
for (const handle of perProject.values()) handle.close()
|
|
1197
|
+
perProject.clear()
|
|
1198
|
+
rootArm.close()
|
|
1199
|
+
rootArm = CLOSED
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
/**
|
|
1203
|
+
* A directory under a member base with no package in it yet. The base's
|
|
1204
|
+
* own watcher is not recursive, so the `package.json` that makes it a
|
|
1205
|
+
* package is an event only here; an editor or `git checkout` that makes
|
|
1206
|
+
* the directory first and the manifest after left the package unwatched
|
|
1207
|
+
* for good (item 891). On a rearm a new entry is armed before its
|
|
1208
|
+
* manifest is checked, so one that landed after the re-read is an event
|
|
1209
|
+
* or is seen by the check. At start there is no re-read to race, and a
|
|
1210
|
+
* manifest already there is a directory the globs exclude.
|
|
1211
|
+
*/
|
|
1212
|
+
const pending = new Map<string, WatchHandle>()
|
|
1213
|
+
const armPending = (afterReread: boolean): void => {
|
|
1214
|
+
const want = new Set<string>()
|
|
1215
|
+
for (const base of memberBases)
|
|
1216
|
+
for (const name of memberEntries(base)) {
|
|
1217
|
+
const dir = path.join(base, name)
|
|
1218
|
+
if (!packageDirs.has(dir)) want.add(dir)
|
|
1219
|
+
}
|
|
1220
|
+
for (const [dir, handle] of pending) {
|
|
1221
|
+
if (want.has(dir)) continue
|
|
1222
|
+
handle.close()
|
|
1223
|
+
pending.delete(dir)
|
|
1224
|
+
}
|
|
1225
|
+
for (const dir of want) {
|
|
1226
|
+
if (pending.has(dir)) continue
|
|
1227
|
+
const manifest = path.join(dir, 'package.json')
|
|
1228
|
+
const arrived = (): void => {
|
|
1229
|
+
reread = true
|
|
1230
|
+
trigger(path.relative(workspaceRoot, manifest), manifest)
|
|
1231
|
+
}
|
|
1232
|
+
try {
|
|
1233
|
+
pending.set(
|
|
1234
|
+
dir,
|
|
1235
|
+
arm(dir, false, (filename) => {
|
|
1236
|
+
if (filename === 'package.json') arrived()
|
|
1237
|
+
}),
|
|
1238
|
+
)
|
|
1239
|
+
} catch (err) {
|
|
1240
|
+
const msg = err instanceof Error ? err.message : String(err)
|
|
1241
|
+
process.stderr.write(`vx watch: cannot watch ${dir}: ${msg}\n`)
|
|
1242
|
+
continue
|
|
1243
|
+
}
|
|
1244
|
+
if (afterReread && fs.existsSync(manifest)) arrived()
|
|
1245
|
+
}
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
/**
|
|
1249
|
+
* A config's imports from outside the watched projects: a shared preset
|
|
1250
|
+
* (`../../shared/preset.mjs`) changed what a run evaluates and no arm
|
|
1251
|
+
* saw it (item 949). One non-recursive arm per directory holding one.
|
|
1252
|
+
* A project config's import re-reads and runs; a workspace config's is
|
|
1253
|
+
* loaded once per process (Bun keeps the module), so its edit is named
|
|
1254
|
+
* with the restart it needs instead of a cycle that would run stale.
|
|
1255
|
+
*/
|
|
1256
|
+
const importArms = new Map<string, WatchHandle>()
|
|
1257
|
+
let importNames = new Map<string, Set<string>>()
|
|
1258
|
+
const staleNamed = new Map<string, number>()
|
|
1259
|
+
const armImports = (): void => {
|
|
1260
|
+
const inProject = (f: string): boolean => projectDirs.some((d) => f.startsWith(d + path.sep))
|
|
1261
|
+
const want = new Map<string, Set<string>>()
|
|
1262
|
+
for (const f of new Set([...configImportFiles, ...wsConfigImportFiles])) {
|
|
1263
|
+
if (inProject(f)) continue
|
|
1264
|
+
const names = want.get(path.dirname(f)) ?? new Set<string>()
|
|
1265
|
+
names.add(path.basename(f))
|
|
1266
|
+
want.set(path.dirname(f), names)
|
|
1267
|
+
}
|
|
1268
|
+
importNames = want
|
|
1269
|
+
for (const [dir, handle] of importArms) {
|
|
1270
|
+
if (want.has(dir)) continue
|
|
1271
|
+
handle.close()
|
|
1272
|
+
importArms.delete(dir)
|
|
1273
|
+
}
|
|
1274
|
+
for (const dir of want.keys()) {
|
|
1275
|
+
if (importArms.has(dir)) continue
|
|
412
1276
|
try {
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
1277
|
+
importArms.set(
|
|
1278
|
+
dir,
|
|
1279
|
+
arm(dir, false, (filename) => {
|
|
1280
|
+
if (!(importNames.get(dir)?.has(filename) ?? false)) return
|
|
1281
|
+
const abs = path.join(dir, filename)
|
|
1282
|
+
const rel = path.relative(workspaceRoot, abs)
|
|
1283
|
+
if (configImportFiles.includes(abs)) {
|
|
1284
|
+
reread = true
|
|
1285
|
+
trigger(`config import ${rel}`, abs)
|
|
1286
|
+
return
|
|
1287
|
+
}
|
|
1288
|
+
// One line per save, not per event: a save is often two.
|
|
1289
|
+
const now = Date.now()
|
|
1290
|
+
if (now - (staleNamed.get(abs) ?? 0) < 1_000) return
|
|
1291
|
+
staleNamed.set(abs, now)
|
|
1292
|
+
process.stdout.write(
|
|
1293
|
+
`vx watch: ${rel} changed; the workspace config imports it, and this process loaded it at start — restart vx watch to apply the edit\n`,
|
|
1294
|
+
)
|
|
1295
|
+
}),
|
|
1296
|
+
)
|
|
417
1297
|
} catch (err) {
|
|
418
1298
|
const msg = err instanceof Error ? err.message : String(err)
|
|
419
|
-
process.stderr.write(`vx watch: cannot watch ${
|
|
1299
|
+
process.stderr.write(`vx watch: cannot watch ${dir}: ${msg}\n`)
|
|
420
1300
|
}
|
|
421
1301
|
}
|
|
1302
|
+
}
|
|
422
1303
|
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
1304
|
+
const watchingLine = (count: number): string =>
|
|
1305
|
+
workspaceWide
|
|
1306
|
+
? 'vx watch: watching the workspace root (workspaceFiles inputs in use)'
|
|
1307
|
+
: `vx watch: watching ${count} project(s)`
|
|
1308
|
+
|
|
1309
|
+
const rearm = async (): Promise<void> => {
|
|
1310
|
+
let next: Rediscovered
|
|
1311
|
+
try {
|
|
1312
|
+
next = await args.rediscover()
|
|
1313
|
+
} catch (err) {
|
|
1314
|
+
const msg = err instanceof Error ? err.message : String(err)
|
|
1315
|
+
process.stderr.write(`vx watch: cannot re-read the workspace: ${msg}\n`)
|
|
1316
|
+
return
|
|
1317
|
+
}
|
|
1318
|
+
projectDirs = next.projects.map((p) => p.dir)
|
|
1319
|
+
workspaceInputs = next.workspaceInputs
|
|
1320
|
+
outputs = next.outputs
|
|
1321
|
+
inputs = next.inputs
|
|
1322
|
+
uncached = next.uncached
|
|
1323
|
+
configImportFiles = next.configImports
|
|
1324
|
+
wsConfigImportFiles = next.workspaceConfigImports
|
|
1325
|
+
packageDirs = next.packageDirs
|
|
1326
|
+
isIgnoredPath = makeWatchIgnore(cacheDir, outputs, inputs)
|
|
1327
|
+
matters = makeRootEventFilter(
|
|
1328
|
+
workspaceRoot,
|
|
1329
|
+
projectDirs,
|
|
1330
|
+
workspaceInputs,
|
|
1331
|
+
args.claimedRootFiles,
|
|
1332
|
+
)
|
|
1333
|
+
if (next.workspaceWide !== workspaceWide) {
|
|
1334
|
+
// A task started or stopped declaring `workspaceFiles`: the other
|
|
1335
|
+
// arm's shape. Until item 891 the choice was made once, at start, and
|
|
1336
|
+
// a root file a config newly named was never an event.
|
|
1337
|
+
dropMode()
|
|
1338
|
+
workspaceWide = next.workspaceWide
|
|
1339
|
+
armMode(next.projects)
|
|
1340
|
+
} else if (!workspaceWide) {
|
|
1341
|
+
const keep = new Set(projectDirs)
|
|
1342
|
+
for (const [dir, handle] of perProject) {
|
|
1343
|
+
if (keep.has(dir)) continue
|
|
1344
|
+
handle.close()
|
|
1345
|
+
perProject.delete(dir)
|
|
1346
|
+
}
|
|
1347
|
+
for (const proj of next.projects) if (!perProject.has(proj.dir)) armProject(proj)
|
|
1348
|
+
}
|
|
1349
|
+
armPending(true)
|
|
1350
|
+
armImports()
|
|
1351
|
+
// A new arm proves delivery like the first ones: an edit in the new
|
|
1352
|
+
// package right after this cycle is seen, not lost in the gap.
|
|
1353
|
+
await Promise.all(proofs)
|
|
1354
|
+
process.stdout.write(`${watchingLine(next.projects.length)}\n`)
|
|
1355
|
+
}
|
|
1356
|
+
|
|
1357
|
+
armMode(projects)
|
|
1358
|
+
|
|
1359
|
+
// A package added while the loop runs is a directory entry appearing
|
|
1360
|
+
// under the glob's directory (`packages/` for `packages/*`); one
|
|
1361
|
+
// non-recursive watcher there hears it come or go, and the cycle it
|
|
1362
|
+
// triggers re-reads the workspace (`rearm`) so the new package's own
|
|
1363
|
+
// edits are cycles from then on. Until 2026-09-10 the watched set was
|
|
1364
|
+
// fixed when the loop armed: the next cycle ran the new package, and
|
|
1365
|
+
// every edit inside it after that was silence.
|
|
1366
|
+
for (const base of memberBases) {
|
|
1367
|
+
let members = memberEntries(base)
|
|
426
1368
|
try {
|
|
427
|
-
arm(
|
|
428
|
-
if (
|
|
1369
|
+
arm(base, false, (filename) => {
|
|
1370
|
+
if (isIgnoredWatchPath(filename)) return
|
|
1371
|
+
// Only a member coming or going. On macOS a non-recursive watcher
|
|
1372
|
+
// also reports a member whose CONTENTS changed (FSEvents names the
|
|
1373
|
+
// directory a write landed in), so a task writing into its own
|
|
1374
|
+
// project — or the arm's own probe file — read as a member event and
|
|
1375
|
+
// cost an uncached task one execution per cycle (CI, 2026-09-10).
|
|
1376
|
+
const now = memberEntries(base)
|
|
1377
|
+
if (sameMembers(members, now)) return
|
|
1378
|
+
members = now
|
|
1379
|
+
reread = true
|
|
1380
|
+
trigger(`${path.relative(workspaceRoot, base)}/${filename}`, path.join(base, filename))
|
|
429
1381
|
})
|
|
430
1382
|
} catch (err) {
|
|
431
1383
|
const msg = err instanceof Error ? err.message : String(err)
|
|
432
|
-
process.stderr.write(`vx watch: cannot watch
|
|
1384
|
+
process.stderr.write(`vx watch: cannot watch ${base}: ${msg}\n`)
|
|
433
1385
|
}
|
|
434
1386
|
}
|
|
1387
|
+
armPending(false)
|
|
1388
|
+
armImports()
|
|
435
1389
|
|
|
436
|
-
// The orchestrator's writes
|
|
437
|
-
//
|
|
438
|
-
//
|
|
1390
|
+
// The orchestrator's own writes never kick the loop: `makeWatchIgnore`
|
|
1391
|
+
// closes over the RESOLVED cache dir (relocated or not) and the tasks'
|
|
1392
|
+
// declared outputs.
|
|
439
1393
|
|
|
440
1394
|
// "watching" is a promise that an edit from now on is seen; every
|
|
441
1395
|
// watcher has proved (or been given 2 s to prove) delivery first.
|
|
442
1396
|
await Promise.all(proofs)
|
|
443
1397
|
|
|
444
|
-
process.stdout.write(
|
|
445
|
-
workspaceWide
|
|
446
|
-
? `\nvx watch: watching the workspace root (workspaceFiles inputs in use); press Ctrl+C to stop\n`
|
|
447
|
-
: `\nvx watch: watching ${projects.length} project(s); press Ctrl+C to stop\n`,
|
|
448
|
-
)
|
|
1398
|
+
process.stdout.write(`\n${watchingLine(projects.length)}; press Ctrl+C to stop\n`)
|
|
449
1399
|
|
|
450
1400
|
return await new Promise<number>((resolve) => {
|
|
451
|
-
const cleanup = (): void => {
|
|
1401
|
+
const cleanup = async (): Promise<void> => {
|
|
452
1402
|
for (const w of watchers) {
|
|
453
1403
|
try {
|
|
454
1404
|
w.close()
|
|
@@ -457,16 +1407,112 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<number> {
|
|
|
457
1407
|
}
|
|
458
1408
|
}
|
|
459
1409
|
if (debounceTimer) clearTimeout(debounceTimer)
|
|
1410
|
+
// The aborted cycle is tearing its children down; resolve only once
|
|
1411
|
+
// it has returned, so the process never exits over a live child.
|
|
1412
|
+
await inFlight
|
|
1413
|
+
await held?.stop(forwardedSignal(stop.reason))
|
|
460
1414
|
resolve(0)
|
|
461
1415
|
}
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
}
|
|
466
|
-
|
|
1416
|
+
if (stop.aborted) {
|
|
1417
|
+
void cleanup()
|
|
1418
|
+
return
|
|
1419
|
+
}
|
|
1420
|
+
stop.addEventListener('abort', () => void cleanup(), { once: true })
|
|
467
1421
|
})
|
|
468
1422
|
}
|
|
469
1423
|
|
|
1424
|
+
/**
|
|
1425
|
+
* The event a finished cycle hands back to the timer, if any.
|
|
1426
|
+
*
|
|
1427
|
+
* The inner loop re-runs while anything is pending, so this covers only the
|
|
1428
|
+
* narrow gap between its LAST judgement and the loop going idle: an event
|
|
1429
|
+
* landing there would otherwise sit in `pendingPaths` with no timer armed
|
|
1430
|
+
* and no cycle to notice it — watch quietly idle over an edit the user
|
|
1431
|
+
* made. Insertion order, because the map is "first label wins" and the
|
|
1432
|
+
* label is what the cycle announces.
|
|
1433
|
+
*
|
|
1434
|
+
* A branch, not a race: extracted so it can be pinned at all. Deleting the
|
|
1435
|
+
* arming left every green test in the repo green (2026-09-20) — the e2e
|
|
1436
|
+
* watch fixture spawns a real `vx watch`, so it cannot deliver an event at
|
|
1437
|
+
* that instant, and the suites that could are the ones this repo's cloud
|
|
1438
|
+
* container already fails for timing reasons. What this pins is the
|
|
1439
|
+
* decision; the delivery window is the process's own.
|
|
1440
|
+
*/
|
|
1441
|
+
export function pendingAfterCycle(
|
|
1442
|
+
pending: ReadonlyMap<string, string>,
|
|
1443
|
+
aborted: boolean,
|
|
1444
|
+
): [abs: string, label: string] | undefined {
|
|
1445
|
+
if (aborted || pending.size === 0) return undefined
|
|
1446
|
+
const [abs, label] = [...pending][0]!
|
|
1447
|
+
return [abs, label]
|
|
1448
|
+
}
|
|
1449
|
+
|
|
1450
|
+
/** True when `abs` was last modified before `t` (epoch ms); false when it cannot be read. */
|
|
1451
|
+
export function modifiedBefore(abs: string, t: number): boolean {
|
|
1452
|
+
try {
|
|
1453
|
+
// The later of the two clocks: `mv`, `cp -p`, `rsync -a` and `tar x`
|
|
1454
|
+
// carry a file's OLD mtime onto the new one, and judged by mtime alone
|
|
1455
|
+
// such an edit was "before the arm" and never ran (item 945). No
|
|
1456
|
+
// process can set a ctime, and a rename or a write moves it.
|
|
1457
|
+
const st = fs.statSync(abs)
|
|
1458
|
+
return Math.max(st.mtimeMs, st.ctimeMs) < t
|
|
1459
|
+
} catch {
|
|
1460
|
+
return false
|
|
1461
|
+
}
|
|
1462
|
+
}
|
|
1463
|
+
|
|
1464
|
+
/**
|
|
1465
|
+
* "Now" as the filesystem will stamp the next write, not as `Date.now()`
|
|
1466
|
+
* reads it. A file's mtime comes from the kernel's coarse clock, which
|
|
1467
|
+
* runs up to a tick behind the fine clock `Date.now()` reads — measured
|
|
1468
|
+
* on the Linux bench box 2026-09-11: 2 of 3,000 tight writes carried an
|
|
1469
|
+
* mtime 5.8 ms EARLIER than a `Date.now()` taken before the write, and
|
|
1470
|
+
* more under CPU load, where the tick is skipped. An `armedAt` from the
|
|
1471
|
+
* fine clock then judged an edit made right after the ready line as
|
|
1472
|
+
* "modified before the arm" and dropped it (the watch e2e flake, three
|
|
1473
|
+
* shard runs that day; traced: raw event, trigger, `same=true`). A stamp
|
|
1474
|
+
* read off a file written here is on the mtime clock itself, and every
|
|
1475
|
+
* later write's mtime is at or after it.
|
|
1476
|
+
*/
|
|
1477
|
+
export function fsClockNow(dir: string): number {
|
|
1478
|
+
const stamp = path.join(dir, WATCH_CLOCK_STAMP)
|
|
1479
|
+
try {
|
|
1480
|
+
fs.writeFileSync(stamp, '')
|
|
1481
|
+
const at = fs.statSync(stamp).mtimeMs
|
|
1482
|
+
fs.unlinkSync(stamp)
|
|
1483
|
+
return at
|
|
1484
|
+
} catch {
|
|
1485
|
+
return Date.now()
|
|
1486
|
+
}
|
|
1487
|
+
}
|
|
1488
|
+
|
|
1489
|
+
/**
|
|
1490
|
+
* The directory entries under a package glob's directory that can be
|
|
1491
|
+
* members: directories (or links) whose name is not dotted and not
|
|
1492
|
+
* `node_modules`, the same rule discovery applies. A watcher on that
|
|
1493
|
+
* directory reacts only when this set changes.
|
|
1494
|
+
*/
|
|
1495
|
+
export function memberEntries(base: string): ReadonlySet<string> {
|
|
1496
|
+
const out = new Set<string>()
|
|
1497
|
+
let entries: fs.Dirent[]
|
|
1498
|
+
try {
|
|
1499
|
+
entries = fs.readdirSync(base, { withFileTypes: true })
|
|
1500
|
+
} catch {
|
|
1501
|
+
return out
|
|
1502
|
+
}
|
|
1503
|
+
for (const e of entries) {
|
|
1504
|
+
if (e.name.startsWith('.') || e.name === 'node_modules') continue
|
|
1505
|
+
if (e.isDirectory() || e.isSymbolicLink()) out.add(e.name)
|
|
1506
|
+
}
|
|
1507
|
+
return out
|
|
1508
|
+
}
|
|
1509
|
+
|
|
1510
|
+
function sameMembers(a: ReadonlySet<string>, b: ReadonlySet<string>): boolean {
|
|
1511
|
+
if (a.size !== b.size) return false
|
|
1512
|
+
for (const name of a) if (!b.has(name)) return false
|
|
1513
|
+
return true
|
|
1514
|
+
}
|
|
1515
|
+
|
|
470
1516
|
/**
|
|
471
1517
|
* True for a root file that re-keys EVERY task, so a change to one must trigger
|
|
472
1518
|
* a cycle even though it lives in no project dir.
|
|
@@ -483,3 +1529,16 @@ const FINGERPRINT_FILES: ReadonlySet<string> = new Set(WORKSPACE_FINGERPRINT_FIL
|
|
|
483
1529
|
function isWorkspaceFingerprintFile(name: string): boolean {
|
|
484
1530
|
return FINGERPRINT_FILES.has(name)
|
|
485
1531
|
}
|
|
1532
|
+
|
|
1533
|
+
/**
|
|
1534
|
+
* The workspace config is the one root file that shapes a run without
|
|
1535
|
+
* being any task's input — its plugins, `config` stage, concurrency, cache
|
|
1536
|
+
* dir — and a cycle re-evaluates it (its import is keyed on its bytes).
|
|
1537
|
+
* Until 2026-09-10 neither root arm listened for it: a plugin added under
|
|
1538
|
+
* `vx watch` waited for a restart while the loop looked alive.
|
|
1539
|
+
*/
|
|
1540
|
+
const WORKSPACE_CONFIGS: ReadonlySet<string> = new Set(WORKSPACE_CONFIG_FILENAMES)
|
|
1541
|
+
|
|
1542
|
+
function isWorkspaceConfigFile(name: string): boolean {
|
|
1543
|
+
return WORKSPACE_CONFIGS.has(name)
|
|
1544
|
+
}
|