@vzn/vx 0.0.21 → 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/README.md +21 -18
- package/package.json +6 -6
- package/src/bin.ts +52 -3
- package/src/cache/archive.ts +170 -21
- package/src/cache/cache.ts +667 -288
- package/src/cache/chained-cache.ts +14 -2
- package/src/cache/config-evals.ts +29 -6
- package/src/cache/file-hashes.ts +63 -35
- package/src/cache/git-inputs.ts +398 -69
- package/src/cache/index.ts +8 -1
- package/src/cache/inputs.ts +403 -102
- package/src/cache/key-fold.ts +232 -0
- package/src/cache/layer.ts +81 -11
- package/src/cache/layered-cache.ts +199 -32
- package/src/cache/output-index.ts +109 -10
- package/src/cache/run-history.ts +8 -3
- package/src/cache/zstd.ts +53 -30
- package/src/cli/cache.ts +51 -21
- package/src/cli/format.ts +4 -15
- package/src/cli/help.ts +13 -3
- package/src/cli/index.ts +35 -9
- package/src/cli/info.ts +49 -7
- package/src/cli/init.ts +17 -5
- package/src/cli/last.ts +96 -21
- package/src/cli/lock.ts +34 -17
- package/src/cli/plan-format.ts +4 -4
- package/src/cli/plugin-commands.ts +20 -5
- package/src/cli/run.ts +55 -12
- package/src/cli/select.ts +23 -13
- package/src/cli/show.ts +9 -4
- package/src/cli/upgrade.ts +134 -14
- package/src/cli/watch.ts +611 -102
- package/src/cli/why.ts +8 -16
- package/src/cli/workspace-config.ts +21 -9
- package/src/config.ts +46 -9
- package/src/exec/executor.ts +51 -5
- package/src/exec/index.ts +10 -0
- package/src/exec/kill-tree.ts +281 -0
- package/src/exec/local-executor.ts +1 -1
- package/src/exec/runner.ts +327 -82
- package/src/exec/sandbox-binds.ts +34 -5
- package/src/exec/sandbox-paths.ts +22 -2
- package/src/exec/sandbox-runtime.ts +627 -102
- package/src/exec/sandbox-violations.ts +36 -23
- package/src/graph/dependency-spec.ts +14 -14
- package/src/graph/index.ts +12 -2
- package/src/graph/priorities.ts +130 -0
- package/src/graph/scheduler.ts +182 -134
- package/src/graph/task-graph.ts +552 -188
- package/src/index.ts +16 -2
- package/src/orchestrator/admission.ts +41 -17
- package/src/orchestrator/colors.ts +8 -3
- package/src/orchestrator/deferred-outputs.ts +22 -7
- package/src/orchestrator/doctor.ts +122 -14
- package/src/orchestrator/events.ts +71 -2
- package/src/orchestrator/excluded-keys.ts +155 -0
- package/src/orchestrator/execute-task.ts +331 -37
- package/src/orchestrator/failure-recap.ts +121 -0
- package/src/orchestrator/fingerprint-watch.ts +89 -0
- package/src/orchestrator/framed-output.ts +81 -14
- package/src/orchestrator/history.ts +6 -7
- package/src/orchestrator/hit-restore.ts +82 -18
- package/src/orchestrator/index.ts +7 -1
- package/src/orchestrator/keyed-projects.ts +89 -0
- package/src/orchestrator/local-shortcircuit.ts +83 -23
- package/src/orchestrator/lockfile-claim.ts +152 -32
- package/src/orchestrator/logger.ts +116 -20
- package/src/orchestrator/metrics.ts +156 -60
- package/src/orchestrator/miss-save.ts +106 -43
- package/src/orchestrator/options.ts +30 -4
- package/src/orchestrator/persistent.ts +41 -21
- package/src/orchestrator/placement.ts +55 -25
- package/src/orchestrator/plan.ts +24 -11
- package/src/orchestrator/plugin-host.ts +17 -23
- package/src/orchestrator/plugin.ts +31 -8
- package/src/orchestrator/prepare.ts +99 -13
- package/src/orchestrator/projects.ts +19 -8
- package/src/orchestrator/remote-prefetch.ts +21 -1
- package/src/orchestrator/run-artifacts.ts +9 -0
- package/src/orchestrator/run-context.ts +19 -4
- package/src/orchestrator/run-lock.ts +349 -0
- package/src/orchestrator/run-records.ts +6 -23
- package/src/orchestrator/run-report.ts +4 -2
- package/src/orchestrator/run.ts +264 -57
- package/src/orchestrator/sandbox-request.ts +388 -62
- package/src/orchestrator/save-lane.ts +4 -7
- package/src/orchestrator/shell-verdict.ts +117 -0
- package/src/orchestrator/signals.ts +86 -30
- package/src/orchestrator/stable-keys.ts +202 -10
- package/src/orchestrator/status-line.ts +33 -1
- package/src/orchestrator/summary.ts +49 -2
- package/src/orchestrator/task-hash.ts +87 -7
- package/src/orchestrator/task-log-buffer.ts +7 -7
- package/src/orchestrator/telemetry-host.ts +16 -7
- package/src/orchestrator/telemetry.ts +69 -28
- package/src/orchestrator/upstream.ts +91 -45
- package/src/util/bun-version.ts +43 -0
- package/src/util/errors.ts +86 -0
- package/src/util/hash.ts +10 -1
- package/src/util/index.ts +32 -3
- package/src/util/paths.ts +147 -5
- package/src/util/procfs.ts +20 -0
- package/src/util/settle.ts +4 -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/which.ts +48 -0
- package/src/workspace/affected.ts +272 -64
- package/src/workspace/config-cache.ts +262 -32
- package/src/workspace/config-eval.ts +49 -12
- package/src/workspace/config-imports.ts +126 -5
- package/src/workspace/config-schema.ts +237 -41
- package/src/workspace/filter.ts +33 -18
- package/src/workspace/fingerprint.ts +29 -11
- package/src/workspace/index.ts +7 -0
- package/src/workspace/json-data.ts +90 -0
- package/src/workspace/load-reads.ts +32 -0
- package/src/workspace/lockfile.ts +33 -11
- package/src/workspace/migrate-scripts.ts +60 -7
- package/src/workspace/migration.ts +40 -1
- package/src/workspace/package-graph.ts +154 -16
- package/src/workspace/project-loader.ts +227 -77
- package/src/workspace/workspace.ts +173 -23
package/src/cli/watch.ts
CHANGED
|
@@ -14,18 +14,35 @@
|
|
|
14
14
|
|
|
15
15
|
import fs from 'node:fs'
|
|
16
16
|
import path from 'node:path'
|
|
17
|
-
import {
|
|
17
|
+
import {
|
|
18
|
+
executablePath,
|
|
19
|
+
isLiteralPattern,
|
|
20
|
+
normalizeGlob,
|
|
21
|
+
staticPrefix,
|
|
22
|
+
taskGlob,
|
|
23
|
+
xxh3,
|
|
24
|
+
} from '../util/index.js'
|
|
18
25
|
import { asTrees } from '../cache/index.js'
|
|
19
26
|
import { parseRunArgs, resolveRunOptions } from './run.js'
|
|
20
|
-
import {
|
|
27
|
+
import {
|
|
28
|
+
fingerprintClaims,
|
|
29
|
+
forwardedSignal,
|
|
30
|
+
run as runOrchestrator,
|
|
31
|
+
type HeldPersistent,
|
|
32
|
+
type RunOptions,
|
|
33
|
+
} from '../orchestrator/index.js'
|
|
21
34
|
import {
|
|
22
35
|
buildPackageGraph,
|
|
23
36
|
findWorkspaceRoot,
|
|
37
|
+
type LoadReads,
|
|
24
38
|
listProjects,
|
|
25
39
|
loadProjectConfig,
|
|
26
40
|
loadWorkspace,
|
|
41
|
+
LOCKFILE_NAME,
|
|
27
42
|
memberBaseDirs,
|
|
43
|
+
PROJECT_CONFIG_FILENAMES,
|
|
28
44
|
WORKSPACE_CONFIG_FILENAMES,
|
|
45
|
+
configImports,
|
|
29
46
|
WORKSPACE_FINGERPRINT_FILES,
|
|
30
47
|
type ProjectEntry,
|
|
31
48
|
type ProjectMeta,
|
|
@@ -72,6 +89,7 @@ export function isIgnoredWatchPath(rel: string): boolean {
|
|
|
72
89
|
export function makeWatchIgnore(
|
|
73
90
|
cacheDir: string,
|
|
74
91
|
outputs: ReadonlyMap<string, readonly string[]> = new Map(),
|
|
92
|
+
inputs: ReadonlyMap<string, readonly string[]> = new Map(),
|
|
75
93
|
): (base: string, filename: string) => boolean {
|
|
76
94
|
const cacheAbs = path.resolve(cacheDir)
|
|
77
95
|
// A task's own outputs are not edits: without this every cycle that
|
|
@@ -91,14 +109,36 @@ export function makeWatchIgnore(
|
|
|
91
109
|
([dir, globs]) =>
|
|
92
110
|
[
|
|
93
111
|
path.resolve(dir),
|
|
94
|
-
asTrees(globs).map((g) =>
|
|
112
|
+
asTrees(globs).map((g) => taskGlob(g)),
|
|
95
113
|
globs.map(outputContainer).filter((c) => c !== ''),
|
|
96
114
|
] as const,
|
|
97
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,
|
|
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
|
+
})
|
|
98
137
|
return (base, filename) => {
|
|
99
138
|
if (isIgnoredWatchPath(filename)) return true
|
|
100
139
|
const abs = path.resolve(base, filename)
|
|
101
140
|
if (abs === cacheAbs || abs.startsWith(cacheAbs + path.sep)) return true
|
|
141
|
+
if (isInput(abs)) return false
|
|
102
142
|
for (const [dir, globs, containers] of declared) {
|
|
103
143
|
if (!abs.startsWith(dir + path.sep)) continue
|
|
104
144
|
const rel = abs
|
|
@@ -119,10 +159,10 @@ export function makeWatchIgnore(
|
|
|
119
159
|
* container; a pattern's is `staticPrefix` — the same rule the sandbox
|
|
120
160
|
* baseline and the deferral gate read.
|
|
121
161
|
*/
|
|
122
|
-
|
|
162
|
+
function outputContainer(raw: string): string {
|
|
123
163
|
const glob = normalizeGlob(raw)
|
|
124
164
|
if (glob.startsWith('!')) return ''
|
|
125
|
-
if (
|
|
165
|
+
if (isLiteralPattern(glob)) return glob.replace(/\/+$/, '')
|
|
126
166
|
const prefix = staticPrefix(glob)
|
|
127
167
|
return prefix === '.' || prefix === '/' ? '' : prefix
|
|
128
168
|
}
|
|
@@ -134,6 +174,42 @@ export function outputContainer(raw: string): string {
|
|
|
134
174
|
*/
|
|
135
175
|
export const WATCH_PROBE = '.vx-watch-probe'
|
|
136
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
|
+
|
|
137
213
|
/** How long a watcher gets to report its own probe before the loop goes on without proof. */
|
|
138
214
|
const WATCH_PROBE_TIMEOUT_MS = 2_000
|
|
139
215
|
/** The file `fsClockNow` writes and removes, under the cache dir the watchers ignore. */
|
|
@@ -154,12 +230,22 @@ export interface ArmedWatcher {
|
|
|
154
230
|
const POLL_INTERVAL_MS = 250
|
|
155
231
|
|
|
156
232
|
/**
|
|
157
|
-
* Directory names the fallback never descends into
|
|
158
|
-
*
|
|
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
|
|
159
236
|
* `node_modules` is the difference between a cheap fallback and one that
|
|
160
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.
|
|
161
247
|
*/
|
|
162
|
-
const POLL_SKIP = new Set(
|
|
248
|
+
const POLL_SKIP = new Set(IGNORED_SEGMENTS)
|
|
163
249
|
|
|
164
250
|
/**
|
|
165
251
|
* A watcher built from `stat`, for when the OS one cannot deliver.
|
|
@@ -178,6 +264,13 @@ export function pollWatcher(
|
|
|
178
264
|
recursive: boolean,
|
|
179
265
|
onEvent: (filename: string) => void,
|
|
180
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)),
|
|
181
274
|
): WatchHandle {
|
|
182
275
|
let previous = new Map<string, number>()
|
|
183
276
|
let first = true
|
|
@@ -194,7 +287,7 @@ export function pollWatcher(
|
|
|
194
287
|
if (e.name === WATCH_PROBE) continue
|
|
195
288
|
const childRel = rel === '' ? e.name : `${rel}/${e.name}`
|
|
196
289
|
if (e.isDirectory()) {
|
|
197
|
-
if (recursive && !
|
|
290
|
+
if (recursive && !skipDir(childRel)) walk(path.join(abs, e.name), childRel)
|
|
198
291
|
continue
|
|
199
292
|
}
|
|
200
293
|
if (!e.isFile()) continue
|
|
@@ -262,6 +355,12 @@ export function armWatcher(
|
|
|
262
355
|
markReady(true)
|
|
263
356
|
return
|
|
264
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
|
|
265
364
|
onEvent(filename)
|
|
266
365
|
})
|
|
267
366
|
const probe = path.join(dir, WATCH_PROBE)
|
|
@@ -343,21 +442,34 @@ export async function watchCmd(args: readonly string[]): Promise<number> {
|
|
|
343
442
|
// run() must not install its exit-the-process handlers — Ctrl-C
|
|
344
443
|
// mid-cycle would kill the loop with 130 instead of the loop's own
|
|
345
444
|
// clean shutdown — so it gets `signal` instead: on SIGINT/SIGTERM the
|
|
346
|
-
// controller aborts
|
|
347
|
-
// (
|
|
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.
|
|
348
448
|
// Installed BEFORE the initial run: until 2026-09-10 the handlers went
|
|
349
449
|
// in with the loop, so a SIGTERM during the initial run took Bun's
|
|
350
450
|
// default (exit 143) and left the cycle's children running under init.
|
|
351
451
|
const stop = new AbortController()
|
|
352
|
-
const opts: RunOptions = {
|
|
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(' ')
|
|
353
462
|
// A staged load from the selection pass is one run's worth of configs;
|
|
354
463
|
// every cycle after an edit must evaluate live.
|
|
355
464
|
delete opts.staged
|
|
356
465
|
process.once('SIGINT', () => {
|
|
357
466
|
process.stdout.write('\nvx watch: stopped\n')
|
|
358
|
-
stop.abort()
|
|
467
|
+
stop.abort('SIGINT')
|
|
359
468
|
})
|
|
360
|
-
process.once('SIGTERM', () => stop.abort())
|
|
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'))
|
|
361
473
|
|
|
362
474
|
// Enumerate projects-in-scope so we know what dirs to watch.
|
|
363
475
|
// `opts.projects` is the resolved scope; undefined means "every
|
|
@@ -367,8 +479,9 @@ export async function watchCmd(args: readonly string[]): Promise<number> {
|
|
|
367
479
|
// closure `--filter 'app...'` walks, computed below once the initial
|
|
368
480
|
// run has staged the configs. Plus the workspace root, for lockfile
|
|
369
481
|
// changes.
|
|
370
|
-
const
|
|
371
|
-
const
|
|
482
|
+
const reads: LoadReads = new Map()
|
|
483
|
+
const workspaceRoot = await findWorkspaceRoot(cwd, reads)
|
|
484
|
+
const workspace = await loadWorkspace(workspaceRoot, reads)
|
|
372
485
|
const allProjects = await listProjects(workspace)
|
|
373
486
|
const inScope = (all: readonly ProjectMeta[]): ProjectMeta[] =>
|
|
374
487
|
opts.projects === undefined ? [...all] : all.filter((p) => opts.projects!.includes(p.name))
|
|
@@ -391,8 +504,11 @@ export async function watchCmd(args: readonly string[]): Promise<number> {
|
|
|
391
504
|
// closing it properly needs the workspaceWide decision made without loading
|
|
392
505
|
// configs.
|
|
393
506
|
process.stdout.write('vx watch: initial run...\n\n')
|
|
394
|
-
await runOrchestrator(opts)
|
|
395
|
-
if (stop.signal.aborted)
|
|
507
|
+
const initial = await runOrchestrator(opts)
|
|
508
|
+
if (stop.signal.aborted) {
|
|
509
|
+
await initial.persistent?.stop(forwardedSignal(stop.signal.reason))
|
|
510
|
+
return 0
|
|
511
|
+
}
|
|
396
512
|
|
|
397
513
|
const load: CliLoadOptions = {
|
|
398
514
|
...(opts.cacheDir !== undefined ? { cacheDir: opts.cacheDir } : {}),
|
|
@@ -400,8 +516,10 @@ export async function watchCmd(args: readonly string[]): Promise<number> {
|
|
|
400
516
|
}
|
|
401
517
|
const swept = await sweepConfigs(allProjects, workspaceRoot, load)
|
|
402
518
|
const watched = await watchedProjects(workspaceRoot, allProjects, scope, load, swept.staged)
|
|
519
|
+
const ws = await loadCliWorkspace(workspaceRoot)
|
|
403
520
|
return await runWatchLoop({
|
|
404
521
|
opts,
|
|
522
|
+
held: initial.persistent,
|
|
405
523
|
stop: stop.signal,
|
|
406
524
|
workspaceRoot,
|
|
407
525
|
projects: watched,
|
|
@@ -409,7 +527,12 @@ export async function watchCmd(args: readonly string[]): Promise<number> {
|
|
|
409
527
|
projectDirs: watched.map((p) => p.dir),
|
|
410
528
|
workspaceInputs: swept.workspaceInputs,
|
|
411
529
|
outputs: swept.outputs,
|
|
530
|
+
inputs: swept.inputs,
|
|
531
|
+
uncached: swept.uncached,
|
|
532
|
+
configImports: swept.configImports,
|
|
533
|
+
workspaceConfigImports: swept.workspaceConfigImports,
|
|
412
534
|
memberBases: memberBaseDirs(workspace),
|
|
535
|
+
packageDirs: new Set(allProjects.map((p) => p.dir)),
|
|
413
536
|
// The workspace as the cycle that just ran saw it: a package added or
|
|
414
537
|
// removed since the loop armed joins or leaves the watched set. The
|
|
415
538
|
// scope is the one resolved at start; a new package joins it only as a
|
|
@@ -418,10 +541,27 @@ export async function watchCmd(args: readonly string[]): Promise<number> {
|
|
|
418
541
|
const all = await listProjects(await loadWorkspace(workspaceRoot))
|
|
419
542
|
const sweep = await sweepConfigs(all, workspaceRoot, load)
|
|
420
543
|
const now = await watchedProjects(workspaceRoot, all, inScope(all), load, sweep.staged)
|
|
421
|
-
return {
|
|
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
|
+
}
|
|
422
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
|
+
]),
|
|
423
563
|
// The RESOLVED cache dir, not the `.vx` literal — see `makeWatchIgnore`.
|
|
424
|
-
cacheDir: opts.cacheDir ??
|
|
564
|
+
cacheDir: opts.cacheDir ?? ws.cacheDir,
|
|
425
565
|
})
|
|
426
566
|
}
|
|
427
567
|
|
|
@@ -479,18 +619,38 @@ export async function sweepConfigs(
|
|
|
479
619
|
workspaceWide: boolean
|
|
480
620
|
workspaceInputs: string[]
|
|
481
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[]
|
|
482
630
|
/** The staged load the sweep read, when the run path's load succeeded; `watchedProjects` reads the same one. */
|
|
483
631
|
staged: Map<string, ProjectEntry> | null
|
|
484
632
|
}> {
|
|
485
633
|
const outputs = new Map<string, string[]>()
|
|
486
|
-
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 => {
|
|
487
640
|
if (globs === undefined || globs.length === 0) return
|
|
488
|
-
|
|
641
|
+
into.set(dir, [...(into.get(dir) ?? []), ...globs])
|
|
489
642
|
}
|
|
643
|
+
const add = (dir: string, globs: readonly string[] | undefined): void =>
|
|
644
|
+
addTo(outputs, dir, globs)
|
|
490
645
|
const workspaceInputs = new Set<string>()
|
|
646
|
+
const uncached = new Set<string>()
|
|
491
647
|
const fold = (dir: string, config: ProjectConfig): void => {
|
|
492
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)
|
|
493
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)
|
|
494
654
|
add(dir, task.cache?.outputs?.files)
|
|
495
655
|
add(workspaceRoot, task.cache?.outputs?.workspaceFiles)
|
|
496
656
|
}
|
|
@@ -501,13 +661,32 @@ export async function sweepConfigs(
|
|
|
501
661
|
workspaceWide: boolean
|
|
502
662
|
workspaceInputs: string[]
|
|
503
663
|
outputs: Map<string, string[]>
|
|
664
|
+
inputs: Map<string, string[]>
|
|
665
|
+
uncached: Set<string>
|
|
666
|
+
configImports: string[]
|
|
667
|
+
workspaceConfigImports: string[]
|
|
504
668
|
staged: Map<string, ProjectEntry> | null
|
|
505
669
|
} => ({
|
|
506
670
|
workspaceWide: workspaceInputs.size > 0,
|
|
507
671
|
workspaceInputs: [...workspaceInputs],
|
|
508
672
|
outputs,
|
|
673
|
+
inputs,
|
|
674
|
+
uncached,
|
|
675
|
+
configImports: [...projectImports],
|
|
676
|
+
workspaceConfigImports: [...wsImports],
|
|
509
677
|
staged,
|
|
510
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
|
+
])
|
|
511
690
|
let staged: Map<string, ProjectEntry> | null = null
|
|
512
691
|
try {
|
|
513
692
|
staged = await loadCliProjects(workspaceRoot, projects, 'all', load)
|
|
@@ -551,15 +730,29 @@ export function makeRootEventFilter(
|
|
|
551
730
|
workspaceRoot: string,
|
|
552
731
|
projectDirs: readonly string[],
|
|
553
732
|
workspaceInputs: readonly string[],
|
|
733
|
+
claimedRootFiles: ReadonlySet<string> = new Set(),
|
|
554
734
|
): (filename: string) => boolean {
|
|
555
735
|
const dirs = projectDirs.map((d) => path.resolve(d))
|
|
556
736
|
const globs = workspaceInputs
|
|
557
737
|
.map(normalizeGlob)
|
|
558
738
|
.filter((g) => !g.startsWith('!'))
|
|
559
|
-
.map((g) =>
|
|
739
|
+
.map((g) => taskGlob(g))
|
|
560
740
|
return (filename: string): boolean => {
|
|
561
741
|
const rel = filename.split(path.sep).join('/')
|
|
562
|
-
|
|
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
|
+
) {
|
|
563
756
|
return true
|
|
564
757
|
}
|
|
565
758
|
const abs = path.resolve(workspaceRoot, filename)
|
|
@@ -571,6 +764,8 @@ export function makeRootEventFilter(
|
|
|
571
764
|
|
|
572
765
|
interface WatchLoopArgs {
|
|
573
766
|
opts: RunOptions
|
|
767
|
+
/** The persistent tasks the initial run left running; the next cycle stops them before it starts. */
|
|
768
|
+
held: HeldPersistent | undefined
|
|
574
769
|
/** Aborted by the SIGINT/SIGTERM handlers `watchCmd` installed; the loop drains its cycle and resolves. */
|
|
575
770
|
stop: AbortSignal
|
|
576
771
|
workspaceRoot: string
|
|
@@ -584,25 +779,74 @@ interface WatchLoopArgs {
|
|
|
584
779
|
cacheDir: string
|
|
585
780
|
/** Declared output globs per directory they are relative to (project dir, or the root for `workspaceFiles`). */
|
|
586
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>
|
|
587
796
|
/** The directory each `<dir>/*` package glob names; a member coming or going there is a cycle. */
|
|
588
797
|
memberBases: readonly string[]
|
|
589
|
-
/**
|
|
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. */
|
|
590
801
|
rediscover: () => Promise<Rediscovered>
|
|
591
802
|
}
|
|
592
803
|
|
|
593
804
|
interface Rediscovered {
|
|
594
805
|
projects: readonly ProjectMeta[]
|
|
806
|
+
workspaceWide: boolean
|
|
595
807
|
workspaceInputs: readonly string[]
|
|
596
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>
|
|
814
|
+
}
|
|
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)
|
|
597
830
|
}
|
|
598
831
|
|
|
832
|
+
const CLOSED: WatchHandle = { close() {} }
|
|
833
|
+
|
|
599
834
|
async function runWatchLoop(args: WatchLoopArgs): Promise<number> {
|
|
600
|
-
const { opts, stop, workspaceRoot, projects,
|
|
601
|
-
// The watched set as of the last cycle: `rearm` replaces these
|
|
602
|
-
//
|
|
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
|
|
603
840
|
let projectDirs = args.projectDirs
|
|
604
841
|
let workspaceInputs = args.workspaceInputs
|
|
605
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
|
|
606
850
|
|
|
607
851
|
// Reentrancy guard — never two orchestrator runs in flight. Events that
|
|
608
852
|
// land while one is running wait in `pendingPaths` and are judged, on
|
|
@@ -663,7 +907,7 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<number> {
|
|
|
663
907
|
return xxh3(Buffer.from(entries.join('\n')))
|
|
664
908
|
}
|
|
665
909
|
// 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
|
|
910
|
+
// the watchers went live (its mtime or ctime, `modifiedBefore`). macOS delivers the initial run's own writes
|
|
667
911
|
// AFTER the arm (CI, 2026-09-11: `app dist; re-running...` with no edit
|
|
668
912
|
// made — FSEvents hands a stream what landed just before it started),
|
|
669
913
|
// and a first sighting used to pass unconditionally; the path's mtime
|
|
@@ -688,12 +932,77 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<number> {
|
|
|
688
932
|
// are judged one window after the run ends, all together — an edit made
|
|
689
933
|
// meanwhile still differs from what the loop last saw and re-runs.
|
|
690
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
|
+
}
|
|
691
965
|
const judge = (): string | undefined => {
|
|
966
|
+
const ignored = gitIgnored(workspaceRoot, [...pendingPaths.keys()])
|
|
692
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)
|
|
693
983
|
for (const [p, l] of pendingPaths) {
|
|
694
|
-
if (!
|
|
984
|
+
if (ignored.has(p) && !editForUncached(p)) continue
|
|
985
|
+
if (!sameState(p) && first === undefined) {
|
|
986
|
+
first = l
|
|
987
|
+
firstAbs = p
|
|
988
|
+
}
|
|
695
989
|
}
|
|
696
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
|
+
}
|
|
697
1006
|
return first
|
|
698
1007
|
}
|
|
699
1008
|
const trigger = (label: string, abs: string): void => {
|
|
@@ -720,9 +1029,13 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<number> {
|
|
|
720
1029
|
while (label !== undefined && !stop.aborted) {
|
|
721
1030
|
process.stdout.write(`\nvx watch: ${label}; re-running...\n\n`)
|
|
722
1031
|
try {
|
|
723
|
-
await
|
|
724
|
-
|
|
725
|
-
|
|
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
|
|
726
1039
|
await rearm()
|
|
727
1040
|
}
|
|
728
1041
|
} catch (err) {
|
|
@@ -746,10 +1059,8 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<number> {
|
|
|
746
1059
|
running = false
|
|
747
1060
|
// Anything that landed after the last judgement waits for a timer
|
|
748
1061
|
// like any other event.
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
trigger(label, abs)
|
|
752
|
-
}
|
|
1062
|
+
const next = pendingAfterCycle(pendingPaths, stop.aborted)
|
|
1063
|
+
if (next !== undefined) trigger(next[1], next[0])
|
|
753
1064
|
}
|
|
754
1065
|
}
|
|
755
1066
|
|
|
@@ -757,10 +1068,15 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<number> {
|
|
|
757
1068
|
// project's dir recursively, so a `node_modules` write under a
|
|
758
1069
|
// project would otherwise trigger every save during `bun install` —
|
|
759
1070
|
// and vx's own cache writes would trigger a cycle that writes again.
|
|
760
|
-
let isIgnoredPath = makeWatchIgnore(cacheDir, outputs)
|
|
761
|
-
let matters = makeRootEventFilter(
|
|
762
|
-
|
|
763
|
-
|
|
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
|
|
764
1080
|
|
|
765
1081
|
const watchers: WatchHandle[] = []
|
|
766
1082
|
const proofs: Promise<void>[] = []
|
|
@@ -769,47 +1085,227 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<number> {
|
|
|
769
1085
|
// network mount, a container bind — the attempt costs a denied syscall
|
|
770
1086
|
// and a two-second wait before the fallback takes over anyway.
|
|
771
1087
|
const forcePoll = (process.env['VX_WATCH_POLL'] ?? '') !== ''
|
|
772
|
-
|
|
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
|
+
}
|
|
773
1112
|
if (forcePoll) {
|
|
774
|
-
watchers.push(pollWatcher(dir, recursive, onEvent))
|
|
775
|
-
return
|
|
1113
|
+
watchers.push(pollWatcher(dir, recursive, onEvent, POLL_INTERVAL_MS, skipUnder(dir)))
|
|
1114
|
+
return handle
|
|
776
1115
|
}
|
|
777
1116
|
const armed = armWatcher(dir, recursive, onEvent)
|
|
778
1117
|
watchers.push(armed.watcher)
|
|
779
1118
|
proofs.push(
|
|
780
1119
|
armed.ready.then((ok) => {
|
|
781
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
|
|
782
1124
|
// The watcher never proved delivery, so it is not one: an FSEvents
|
|
783
1125
|
// stream the OS refused, a filesystem that reports nothing. Swap in
|
|
784
1126
|
// the poller rather than run a loop that silently never fires.
|
|
785
|
-
|
|
786
|
-
watchers[watchers.indexOf(armed.watcher)] = pollWatcher(dir, recursive, onEvent)
|
|
1127
|
+
watchers[at] = pollWatcher(dir, recursive, onEvent, POLL_INTERVAL_MS, skipUnder(dir))
|
|
787
1128
|
process.stderr.write(
|
|
788
1129
|
`vx watch: ${dir}: no OS watch events within ${WATCH_PROBE_TIMEOUT_MS} ms; polling every ${POLL_INTERVAL_MS} ms instead\n`,
|
|
789
1130
|
)
|
|
790
1131
|
}),
|
|
791
1132
|
)
|
|
1133
|
+
return handle
|
|
792
1134
|
}
|
|
793
1135
|
|
|
794
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. */
|
|
795
1137
|
const armedAt = fsClockNow(cacheDir)
|
|
796
1138
|
/** Per-project arms by directory, so `rearm` can add and drop them. */
|
|
797
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
|
|
798
1142
|
const armProject = (proj: ProjectMeta): void => {
|
|
799
1143
|
try {
|
|
800
|
-
const
|
|
801
|
-
arm(proj.dir, true, (filename) => {
|
|
1144
|
+
const handle = arm(proj.dir, true, (filename) => {
|
|
802
1145
|
if (isIgnoredPath(proj.dir, filename)) return
|
|
1146
|
+
if (shapesWatchedSet(filename)) reread = true
|
|
803
1147
|
trigger(`${proj.name} ${filename}`, path.join(proj.dir, filename))
|
|
804
1148
|
})
|
|
805
|
-
|
|
806
|
-
// swapped for a poller in place, and a drop must close what is there.
|
|
807
|
-
perProject.set(proj.dir, { close: () => watchers[at]?.close() })
|
|
1149
|
+
perProject.set(proj.dir, handle)
|
|
808
1150
|
} catch (err) {
|
|
809
1151
|
const msg = err instanceof Error ? err.message : String(err)
|
|
810
1152
|
process.stderr.write(`vx watch: cannot watch ${proj.dir}: ${msg}\n`)
|
|
811
1153
|
}
|
|
812
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
|
+
})
|
|
1183
|
+
} catch (err) {
|
|
1184
|
+
const msg = err instanceof Error ? err.message : String(err)
|
|
1185
|
+
process.stderr.write(`vx watch: cannot watch workspace root: ${msg}\n`)
|
|
1186
|
+
}
|
|
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
|
|
1276
|
+
try {
|
|
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
|
+
)
|
|
1297
|
+
} catch (err) {
|
|
1298
|
+
const msg = err instanceof Error ? err.message : String(err)
|
|
1299
|
+
process.stderr.write(`vx watch: cannot watch ${dir}: ${msg}\n`)
|
|
1300
|
+
}
|
|
1301
|
+
}
|
|
1302
|
+
}
|
|
1303
|
+
|
|
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
|
+
|
|
813
1309
|
const rearm = async (): Promise<void> => {
|
|
814
1310
|
let next: Rediscovered
|
|
815
1311
|
try {
|
|
@@ -822,9 +1318,26 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<number> {
|
|
|
822
1318
|
projectDirs = next.projects.map((p) => p.dir)
|
|
823
1319
|
workspaceInputs = next.workspaceInputs
|
|
824
1320
|
outputs = next.outputs
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
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) {
|
|
828
1341
|
const keep = new Set(projectDirs)
|
|
829
1342
|
for (const [dir, handle] of perProject) {
|
|
830
1343
|
if (keep.has(dir)) continue
|
|
@@ -832,50 +1345,16 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<number> {
|
|
|
832
1345
|
perProject.delete(dir)
|
|
833
1346
|
}
|
|
834
1347
|
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
1348
|
}
|
|
839
|
-
|
|
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`)
|
|
840
1355
|
}
|
|
841
1356
|
|
|
842
|
-
|
|
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.
|
|
850
|
-
try {
|
|
851
|
-
arm(workspaceRoot, true, (filename) => {
|
|
852
|
-
if (!matters(filename) || isIgnoredPath(workspaceRoot, filename)) return
|
|
853
|
-
trigger(`root ${filename}`, path.join(workspaceRoot, filename))
|
|
854
|
-
})
|
|
855
|
-
} catch (err) {
|
|
856
|
-
const msg = err instanceof Error ? err.message : String(err)
|
|
857
|
-
process.stderr.write(`vx watch: cannot watch workspace root: ${msg}\n`)
|
|
858
|
-
}
|
|
859
|
-
} else {
|
|
860
|
-
// One recursive watcher per project. Each project owns its own
|
|
861
|
-
// subtree; we don't watch the workspace root recursively (would
|
|
862
|
-
// cover every project + node_modules + caches).
|
|
863
|
-
for (const proj of projects) armProject(proj)
|
|
864
|
-
|
|
865
|
-
// Plus the workspace root itself (non-recursive) so lockfile +
|
|
866
|
-
// pnpm-workspace.yaml edits trigger re-runs even when no project
|
|
867
|
-
// dir saw the change.
|
|
868
|
-
try {
|
|
869
|
-
arm(workspaceRoot, false, (filename) => {
|
|
870
|
-
if (isWorkspaceFingerprintFile(filename) || isWorkspaceConfigFile(filename)) {
|
|
871
|
-
trigger(`root ${filename}`, path.join(workspaceRoot, filename))
|
|
872
|
-
}
|
|
873
|
-
})
|
|
874
|
-
} catch (err) {
|
|
875
|
-
const msg = err instanceof Error ? err.message : String(err)
|
|
876
|
-
process.stderr.write(`vx watch: cannot watch workspace root: ${msg}\n`)
|
|
877
|
-
}
|
|
878
|
-
}
|
|
1357
|
+
armMode(projects)
|
|
879
1358
|
|
|
880
1359
|
// A package added while the loop runs is a directory entry appearing
|
|
881
1360
|
// under the glob's directory (`packages/` for `packages/*`); one
|
|
@@ -897,7 +1376,7 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<number> {
|
|
|
897
1376
|
const now = memberEntries(base)
|
|
898
1377
|
if (sameMembers(members, now)) return
|
|
899
1378
|
members = now
|
|
900
|
-
|
|
1379
|
+
reread = true
|
|
901
1380
|
trigger(`${path.relative(workspaceRoot, base)}/${filename}`, path.join(base, filename))
|
|
902
1381
|
})
|
|
903
1382
|
} catch (err) {
|
|
@@ -905,6 +1384,8 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<number> {
|
|
|
905
1384
|
process.stderr.write(`vx watch: cannot watch ${base}: ${msg}\n`)
|
|
906
1385
|
}
|
|
907
1386
|
}
|
|
1387
|
+
armPending(false)
|
|
1388
|
+
armImports()
|
|
908
1389
|
|
|
909
1390
|
// The orchestrator's own writes never kick the loop: `makeWatchIgnore`
|
|
910
1391
|
// closes over the RESOLVED cache dir (relocated or not) and the tasks'
|
|
@@ -914,11 +1395,7 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<number> {
|
|
|
914
1395
|
// watcher has proved (or been given 2 s to prove) delivery first.
|
|
915
1396
|
await Promise.all(proofs)
|
|
916
1397
|
|
|
917
|
-
process.stdout.write(
|
|
918
|
-
workspaceWide
|
|
919
|
-
? `\nvx watch: watching the workspace root (workspaceFiles inputs in use); press Ctrl+C to stop\n`
|
|
920
|
-
: `\nvx watch: watching ${projects.length} project(s); press Ctrl+C to stop\n`,
|
|
921
|
-
)
|
|
1398
|
+
process.stdout.write(`\n${watchingLine(projects.length)}; press Ctrl+C to stop\n`)
|
|
922
1399
|
|
|
923
1400
|
return await new Promise<number>((resolve) => {
|
|
924
1401
|
const cleanup = async (): Promise<void> => {
|
|
@@ -933,6 +1410,7 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<number> {
|
|
|
933
1410
|
// The aborted cycle is tearing its children down; resolve only once
|
|
934
1411
|
// it has returned, so the process never exits over a live child.
|
|
935
1412
|
await inFlight
|
|
1413
|
+
await held?.stop(forwardedSignal(stop.reason))
|
|
936
1414
|
resolve(0)
|
|
937
1415
|
}
|
|
938
1416
|
if (stop.aborted) {
|
|
@@ -943,10 +1421,41 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<number> {
|
|
|
943
1421
|
})
|
|
944
1422
|
}
|
|
945
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
|
+
|
|
946
1450
|
/** True when `abs` was last modified before `t` (epoch ms); false when it cannot be read. */
|
|
947
1451
|
export function modifiedBefore(abs: string, t: number): boolean {
|
|
948
1452
|
try {
|
|
949
|
-
|
|
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
|
|
950
1459
|
} catch {
|
|
951
1460
|
return false
|
|
952
1461
|
}
|