@vzn/vx 0.0.21 → 0.0.23
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 +622 -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,22 @@ 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
|
-
|
|
395
|
-
|
|
507
|
+
// A run that throws (a config that does not parse) is a failed cycle, as
|
|
508
|
+
// it is once the loop runs: watch keeps watching, so the fix re-runs.
|
|
509
|
+
// Uncaught, it ended watch at start while the same break mid-watch did
|
|
510
|
+
// not (item 1017).
|
|
511
|
+
let held: HeldPersistent | undefined
|
|
512
|
+
try {
|
|
513
|
+
held = (await runOrchestrator(opts)).persistent
|
|
514
|
+
} catch (err) {
|
|
515
|
+
process.stderr.write(
|
|
516
|
+
`vx watch: cycle failed: ${err instanceof Error ? err.message : String(err)}\n`,
|
|
517
|
+
)
|
|
518
|
+
}
|
|
519
|
+
if (stop.signal.aborted) {
|
|
520
|
+
await held?.stop(forwardedSignal(stop.signal.reason))
|
|
521
|
+
return 0
|
|
522
|
+
}
|
|
396
523
|
|
|
397
524
|
const load: CliLoadOptions = {
|
|
398
525
|
...(opts.cacheDir !== undefined ? { cacheDir: opts.cacheDir } : {}),
|
|
@@ -400,8 +527,10 @@ export async function watchCmd(args: readonly string[]): Promise<number> {
|
|
|
400
527
|
}
|
|
401
528
|
const swept = await sweepConfigs(allProjects, workspaceRoot, load)
|
|
402
529
|
const watched = await watchedProjects(workspaceRoot, allProjects, scope, load, swept.staged)
|
|
530
|
+
const ws = await loadCliWorkspace(workspaceRoot)
|
|
403
531
|
return await runWatchLoop({
|
|
404
532
|
opts,
|
|
533
|
+
held,
|
|
405
534
|
stop: stop.signal,
|
|
406
535
|
workspaceRoot,
|
|
407
536
|
projects: watched,
|
|
@@ -409,7 +538,12 @@ export async function watchCmd(args: readonly string[]): Promise<number> {
|
|
|
409
538
|
projectDirs: watched.map((p) => p.dir),
|
|
410
539
|
workspaceInputs: swept.workspaceInputs,
|
|
411
540
|
outputs: swept.outputs,
|
|
541
|
+
inputs: swept.inputs,
|
|
542
|
+
uncached: swept.uncached,
|
|
543
|
+
configImports: swept.configImports,
|
|
544
|
+
workspaceConfigImports: swept.workspaceConfigImports,
|
|
412
545
|
memberBases: memberBaseDirs(workspace),
|
|
546
|
+
packageDirs: new Set(allProjects.map((p) => p.dir)),
|
|
413
547
|
// The workspace as the cycle that just ran saw it: a package added or
|
|
414
548
|
// removed since the loop armed joins or leaves the watched set. The
|
|
415
549
|
// scope is the one resolved at start; a new package joins it only as a
|
|
@@ -418,10 +552,27 @@ export async function watchCmd(args: readonly string[]): Promise<number> {
|
|
|
418
552
|
const all = await listProjects(await loadWorkspace(workspaceRoot))
|
|
419
553
|
const sweep = await sweepConfigs(all, workspaceRoot, load)
|
|
420
554
|
const now = await watchedProjects(workspaceRoot, all, inScope(all), load, sweep.staged)
|
|
421
|
-
return {
|
|
555
|
+
return {
|
|
556
|
+
projects: now,
|
|
557
|
+
workspaceWide: sweep.workspaceWide,
|
|
558
|
+
workspaceInputs: sweep.workspaceInputs,
|
|
559
|
+
outputs: sweep.outputs,
|
|
560
|
+
inputs: sweep.inputs,
|
|
561
|
+
uncached: sweep.uncached,
|
|
562
|
+
configImports: sweep.configImports,
|
|
563
|
+
workspaceConfigImports: sweep.workspaceConfigImports,
|
|
564
|
+
packageDirs: new Set(all.map((p) => p.dir)),
|
|
565
|
+
}
|
|
422
566
|
},
|
|
567
|
+
// Under --frozen every cycle's configs are the lock's, so a re-lock is
|
|
568
|
+
// the one edit that changes what a cycle runs; unheard, the loop ran
|
|
569
|
+
// the old lock until a restart (item 971).
|
|
570
|
+
claimedRootFiles: new Set([
|
|
571
|
+
...fingerprintClaims(ws.plugins).keys(),
|
|
572
|
+
...(opts.frozen === true ? [LOCKFILE_NAME] : []),
|
|
573
|
+
]),
|
|
423
574
|
// The RESOLVED cache dir, not the `.vx` literal — see `makeWatchIgnore`.
|
|
424
|
-
cacheDir: opts.cacheDir ??
|
|
575
|
+
cacheDir: opts.cacheDir ?? ws.cacheDir,
|
|
425
576
|
})
|
|
426
577
|
}
|
|
427
578
|
|
|
@@ -479,18 +630,38 @@ export async function sweepConfigs(
|
|
|
479
630
|
workspaceWide: boolean
|
|
480
631
|
workspaceInputs: string[]
|
|
481
632
|
outputs: Map<string, string[]>
|
|
633
|
+
/** Declared input globs per directory they are relative to, the counterweight to `outputs`. */
|
|
634
|
+
inputs: Map<string, string[]>
|
|
635
|
+
/** Projects with a task that has a command and no cache: it reads what it likes, git-ignored files included. */
|
|
636
|
+
uncached: Set<string>
|
|
637
|
+
/** Files the project configs import by relative path (item 949). */
|
|
638
|
+
configImports: string[]
|
|
639
|
+
/** Files the workspace config imports by relative path: loaded once per process (item 949). */
|
|
640
|
+
workspaceConfigImports: string[]
|
|
482
641
|
/** The staged load the sweep read, when the run path's load succeeded; `watchedProjects` reads the same one. */
|
|
483
642
|
staged: Map<string, ProjectEntry> | null
|
|
484
643
|
}> {
|
|
485
644
|
const outputs = new Map<string, string[]>()
|
|
486
|
-
const
|
|
645
|
+
const inputs = new Map<string, string[]>()
|
|
646
|
+
const addTo = (
|
|
647
|
+
into: Map<string, string[]>,
|
|
648
|
+
dir: string,
|
|
649
|
+
globs: readonly string[] | undefined,
|
|
650
|
+
): void => {
|
|
487
651
|
if (globs === undefined || globs.length === 0) return
|
|
488
|
-
|
|
652
|
+
into.set(dir, [...(into.get(dir) ?? []), ...globs])
|
|
489
653
|
}
|
|
654
|
+
const add = (dir: string, globs: readonly string[] | undefined): void =>
|
|
655
|
+
addTo(outputs, dir, globs)
|
|
490
656
|
const workspaceInputs = new Set<string>()
|
|
657
|
+
const uncached = new Set<string>()
|
|
491
658
|
const fold = (dir: string, config: ProjectConfig): void => {
|
|
492
659
|
for (const task of Object.values(config.tasks ?? {})) {
|
|
660
|
+
if (task.cache === undefined && task.exec !== undefined && task.exec.persistent === undefined)
|
|
661
|
+
uncached.add(dir)
|
|
493
662
|
for (const g of task.cache?.inputs?.workspaceFiles ?? []) workspaceInputs.add(g)
|
|
663
|
+
addTo(inputs, dir, task.cache?.inputs?.files)
|
|
664
|
+
addTo(inputs, workspaceRoot, task.cache?.inputs?.workspaceFiles)
|
|
494
665
|
add(dir, task.cache?.outputs?.files)
|
|
495
666
|
add(workspaceRoot, task.cache?.outputs?.workspaceFiles)
|
|
496
667
|
}
|
|
@@ -501,13 +672,32 @@ export async function sweepConfigs(
|
|
|
501
672
|
workspaceWide: boolean
|
|
502
673
|
workspaceInputs: string[]
|
|
503
674
|
outputs: Map<string, string[]>
|
|
675
|
+
inputs: Map<string, string[]>
|
|
676
|
+
uncached: Set<string>
|
|
677
|
+
configImports: string[]
|
|
678
|
+
workspaceConfigImports: string[]
|
|
504
679
|
staged: Map<string, ProjectEntry> | null
|
|
505
680
|
} => ({
|
|
506
681
|
workspaceWide: workspaceInputs.size > 0,
|
|
507
682
|
workspaceInputs: [...workspaceInputs],
|
|
508
683
|
outputs,
|
|
684
|
+
inputs,
|
|
685
|
+
uncached,
|
|
686
|
+
configImports: [...projectImports],
|
|
687
|
+
workspaceConfigImports: [...wsImports],
|
|
509
688
|
staged,
|
|
510
689
|
})
|
|
690
|
+
const projectImports = new Set<string>()
|
|
691
|
+
const wsImports = new Set<string>()
|
|
692
|
+
await Promise.all([
|
|
693
|
+
...projects.map(async (p) => {
|
|
694
|
+
if (p.configPath === null) return
|
|
695
|
+
for (const f of await configImports(p.configPath)) projectImports.add(f)
|
|
696
|
+
}),
|
|
697
|
+
...WORKSPACE_CONFIG_FILENAMES.map(async (name) => {
|
|
698
|
+
for (const f of await configImports(path.join(workspaceRoot, name))) wsImports.add(f)
|
|
699
|
+
}),
|
|
700
|
+
])
|
|
511
701
|
let staged: Map<string, ProjectEntry> | null = null
|
|
512
702
|
try {
|
|
513
703
|
staged = await loadCliProjects(workspaceRoot, projects, 'all', load)
|
|
@@ -551,15 +741,29 @@ export function makeRootEventFilter(
|
|
|
551
741
|
workspaceRoot: string,
|
|
552
742
|
projectDirs: readonly string[],
|
|
553
743
|
workspaceInputs: readonly string[],
|
|
744
|
+
claimedRootFiles: ReadonlySet<string> = new Set(),
|
|
554
745
|
): (filename: string) => boolean {
|
|
555
746
|
const dirs = projectDirs.map((d) => path.resolve(d))
|
|
556
747
|
const globs = workspaceInputs
|
|
557
748
|
.map(normalizeGlob)
|
|
558
749
|
.filter((g) => !g.startsWith('!'))
|
|
559
|
-
.map((g) =>
|
|
750
|
+
.map((g) => taskGlob(g))
|
|
560
751
|
return (filename: string): boolean => {
|
|
561
752
|
const rel = filename.split(path.sep).join('/')
|
|
562
|
-
|
|
753
|
+
// The depth test is a READING AID, not a guard: both predicates below
|
|
754
|
+
// are exact membership in a set of BARE names, so a `rel` carrying a
|
|
755
|
+
// slash can never be in one. Measured with it removed (item 502) over
|
|
756
|
+
// `nested/bun.lock`, `./bun.lock`, `a/b/c/pnpm-workspace.yaml` and
|
|
757
|
+
// eight more: every answer identical. The two table rows that look
|
|
758
|
+
// like they pin it — `nested/pnpm-lock.yaml → false` and
|
|
759
|
+
// `nested/vx.workspace.ts → false` — pass either way; what they
|
|
760
|
+
// actually pin is that the predicates stay exact rather than becoming
|
|
761
|
+
// a basename or suffix match, which is the change that WOULD make
|
|
762
|
+
// this line load-bearing.
|
|
763
|
+
if (
|
|
764
|
+
!rel.includes('/') &&
|
|
765
|
+
(isWorkspaceFingerprintFile(rel) || isWorkspaceConfigFile(rel) || claimedRootFiles.has(rel))
|
|
766
|
+
) {
|
|
563
767
|
return true
|
|
564
768
|
}
|
|
565
769
|
const abs = path.resolve(workspaceRoot, filename)
|
|
@@ -571,6 +775,8 @@ export function makeRootEventFilter(
|
|
|
571
775
|
|
|
572
776
|
interface WatchLoopArgs {
|
|
573
777
|
opts: RunOptions
|
|
778
|
+
/** The persistent tasks the initial run left running; the next cycle stops them before it starts. */
|
|
779
|
+
held: HeldPersistent | undefined
|
|
574
780
|
/** Aborted by the SIGINT/SIGTERM handlers `watchCmd` installed; the loop drains its cycle and resolves. */
|
|
575
781
|
stop: AbortSignal
|
|
576
782
|
workspaceRoot: string
|
|
@@ -584,25 +790,74 @@ interface WatchLoopArgs {
|
|
|
584
790
|
cacheDir: string
|
|
585
791
|
/** Declared output globs per directory they are relative to (project dir, or the root for `workspaceFiles`). */
|
|
586
792
|
outputs: ReadonlyMap<string, readonly string[]>
|
|
793
|
+
/** Declared input globs per directory: never ignored as another task's output. */
|
|
794
|
+
inputs: ReadonlyMap<string, readonly string[]>
|
|
795
|
+
/** Projects whose uncached task may read a git-ignored file: such a path there is still an edit. */
|
|
796
|
+
uncached: ReadonlySet<string>
|
|
797
|
+
/** Files the configs import: an edit to one outside the watched projects is a cycle that re-reads them. */
|
|
798
|
+
configImports: readonly string[]
|
|
799
|
+
/** Files the workspace config imports: loaded once per process, so an edit is named, not run. */
|
|
800
|
+
workspaceConfigImports: readonly string[]
|
|
801
|
+
/**
|
|
802
|
+
* Root files a plugin claims (`VxPlugin.fingerprint`): a lockfile, or a
|
|
803
|
+
* file its stages read (`turbo.json`, item 961). The workspace config is
|
|
804
|
+
* loaded once per process, so the set is fixed for the loop.
|
|
805
|
+
*/
|
|
806
|
+
claimedRootFiles: ReadonlySet<string>
|
|
587
807
|
/** The directory each `<dir>/*` package glob names; a member coming or going there is a cycle. */
|
|
588
808
|
memberBases: readonly string[]
|
|
589
|
-
/**
|
|
809
|
+
/** Every package's directory, in scope or not: a member base's other entries are packages still to come. */
|
|
810
|
+
packageDirs: ReadonlySet<string>
|
|
811
|
+
/** The watched set again, after a cycle that followed an event which can change it. */
|
|
590
812
|
rediscover: () => Promise<Rediscovered>
|
|
591
813
|
}
|
|
592
814
|
|
|
593
815
|
interface Rediscovered {
|
|
594
816
|
projects: readonly ProjectMeta[]
|
|
817
|
+
workspaceWide: boolean
|
|
595
818
|
workspaceInputs: readonly string[]
|
|
596
819
|
outputs: ReadonlyMap<string, readonly string[]>
|
|
820
|
+
inputs: ReadonlyMap<string, readonly string[]>
|
|
821
|
+
uncached: ReadonlySet<string>
|
|
822
|
+
configImports: readonly string[]
|
|
823
|
+
workspaceConfigImports: readonly string[]
|
|
824
|
+
packageDirs: ReadonlySet<string>
|
|
597
825
|
}
|
|
598
826
|
|
|
827
|
+
const PROJECT_CONFIGS: ReadonlySet<string> = new Set(PROJECT_CONFIG_FILENAMES)
|
|
828
|
+
|
|
829
|
+
/**
|
|
830
|
+
* A file whose edit can change what the loop watches: a `package.json`
|
|
831
|
+
* (a dependency added under `--filter` widens the closure), a project
|
|
832
|
+
* config (a task that starts declaring `workspaceFiles` needs the root
|
|
833
|
+
* watcher, a new output must stop being an event) or the workspace config
|
|
834
|
+
* (a plugin's `project` stage). Until item 891 only a member directory
|
|
835
|
+
* coming or going re-read the set, so each of these waited for a restart
|
|
836
|
+
* while the loop looked alive.
|
|
837
|
+
*/
|
|
838
|
+
function shapesWatchedSet(filename: string): boolean {
|
|
839
|
+
const base = path.basename(filename)
|
|
840
|
+
return base === 'package.json' || PROJECT_CONFIGS.has(base) || isWorkspaceConfigFile(base)
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
const CLOSED: WatchHandle = { close() {} }
|
|
844
|
+
|
|
599
845
|
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
|
-
//
|
|
846
|
+
const { opts, stop, workspaceRoot, projects, cacheDir, memberBases } = args
|
|
847
|
+
// The watched set as of the last cycle: `rearm` replaces these after an
|
|
848
|
+
// event that can change it, and every filter below reads the current one.
|
|
849
|
+
let workspaceWide = args.workspaceWide
|
|
850
|
+
let packageDirs = args.packageDirs
|
|
603
851
|
let projectDirs = args.projectDirs
|
|
604
852
|
let workspaceInputs = args.workspaceInputs
|
|
605
853
|
let outputs = args.outputs
|
|
854
|
+
let inputs = args.inputs
|
|
855
|
+
let uncached = args.uncached
|
|
856
|
+
let configImportFiles = args.configImports
|
|
857
|
+
let wsConfigImportFiles = args.workspaceConfigImports
|
|
858
|
+
// A dev server stays up while the loop idles; the cycle that replaces it
|
|
859
|
+
// stops it first, so the new one never meets the old one's port.
|
|
860
|
+
let held = args.held
|
|
606
861
|
|
|
607
862
|
// Reentrancy guard — never two orchestrator runs in flight. Events that
|
|
608
863
|
// land while one is running wait in `pendingPaths` and are judged, on
|
|
@@ -663,7 +918,7 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<number> {
|
|
|
663
918
|
return xxh3(Buffer.from(entries.join('\n')))
|
|
664
919
|
}
|
|
665
920
|
// 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
|
|
921
|
+
// the watchers went live (its mtime or ctime, `modifiedBefore`). macOS delivers the initial run's own writes
|
|
667
922
|
// AFTER the arm (CI, 2026-09-11: `app dist; re-running...` with no edit
|
|
668
923
|
// made — FSEvents hands a stream what landed just before it started),
|
|
669
924
|
// and a first sighting used to pass unconditionally; the path's mtime
|
|
@@ -688,12 +943,77 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<number> {
|
|
|
688
943
|
// are judged one window after the run ends, all together — an edit made
|
|
689
944
|
// meanwhile still differs from what the loop last saw and re-runs.
|
|
690
945
|
const pendingPaths = new Map<string, string>()
|
|
946
|
+
// A path that starts cycle after cycle from the run's own writes is a
|
|
947
|
+
// task rewriting a file with different bytes every run (a pid file, a
|
|
948
|
+
// timestamped log): the state gate cannot settle it, and nothing here
|
|
949
|
+
// can tell the third such write from a user's third save mid-run — so
|
|
950
|
+
// watch names it once, with the remedy, and keeps going. "The run's
|
|
951
|
+
// own write" is read off the path itself: its mtime falls inside the
|
|
952
|
+
// previous cycle's window. Not off which judgement started the cycle:
|
|
953
|
+
// macOS delivers a run's writes late, after the loop's own post-run
|
|
954
|
+
// judgement found nothing and broke out, so there every such cycle
|
|
955
|
+
// starts from the idle timer (CI, 2026-09-16: the storm ran, the
|
|
956
|
+
// notice never came).
|
|
957
|
+
const streak = { abs: '', n: 0 }
|
|
958
|
+
const noticed = new Set<string>()
|
|
959
|
+
let lastCycle: { start: number; end: number } | undefined
|
|
960
|
+
const writtenDuringLastCycle = (abs: string, openWhileHeld = false): boolean => {
|
|
961
|
+
// A server the last cycle left running is still that cycle's: its
|
|
962
|
+
// writes land after the cycle ended, and with a closed window a dev
|
|
963
|
+
// server that rewrites a log in its project restarted itself forever
|
|
964
|
+
// with no word of it, 12 restarts in 8 s (item 948). The initial run's
|
|
965
|
+
// server is one too, from the arm on.
|
|
966
|
+
const open = openWhileHeld && held !== undefined
|
|
967
|
+
if (lastCycle === undefined && !open) return false
|
|
968
|
+
try {
|
|
969
|
+
const m = fs.statSync(abs).mtimeMs
|
|
970
|
+
const start = lastCycle?.start ?? armedAt
|
|
971
|
+
return m >= start && (open || m <= lastCycle!.end)
|
|
972
|
+
} catch {
|
|
973
|
+
return false
|
|
974
|
+
}
|
|
975
|
+
}
|
|
691
976
|
const judge = (): string | undefined => {
|
|
977
|
+
const ignored = gitIgnored(workspaceRoot, [...pendingPaths.keys()])
|
|
692
978
|
let first: string | undefined
|
|
979
|
+
let firstAbs: string | undefined
|
|
980
|
+
// `sameState` before the `first` test, never after it: it is what
|
|
981
|
+
// RECORDS a path's settled state, and every path this judgement saw
|
|
982
|
+
// must be recorded even though only the first change names the cycle.
|
|
983
|
+
// Short-circuiting after the winner leaves the rest unjudged, and
|
|
984
|
+
// their next event is a first sighting stamped after the arm — so a
|
|
985
|
+
// batch edit (a `git checkout`) makes the same bytes written to any
|
|
986
|
+
// of the others a change.
|
|
987
|
+
// A git-ignored path keys nothing, but a task with no cache has no key:
|
|
988
|
+
// it reads what it likes, and its `.env.local` edit re-ran nothing
|
|
989
|
+
// (item 947). Under such a project an ignored path is judged when the
|
|
990
|
+
// user wrote it; one the last cycle wrote (the pid file an uncached
|
|
991
|
+
// task rewrites every run, the loop the filter exists for) stays out.
|
|
992
|
+
const editForUncached = (p: string): boolean =>
|
|
993
|
+
[...uncached].some((dir) => p.startsWith(dir + path.sep)) && !writtenDuringLastCycle(p)
|
|
693
994
|
for (const [p, l] of pendingPaths) {
|
|
694
|
-
if (!
|
|
995
|
+
if (ignored.has(p) && !editForUncached(p)) continue
|
|
996
|
+
if (!sameState(p) && first === undefined) {
|
|
997
|
+
first = l
|
|
998
|
+
firstAbs = p
|
|
999
|
+
}
|
|
695
1000
|
}
|
|
696
1001
|
pendingPaths.clear()
|
|
1002
|
+
const byServer = firstAbs !== undefined && held !== undefined
|
|
1003
|
+
if (firstAbs === undefined || !writtenDuringLastCycle(firstAbs, true)) {
|
|
1004
|
+
streak.n = 0
|
|
1005
|
+
return first
|
|
1006
|
+
}
|
|
1007
|
+
streak.n = streak.abs === firstAbs ? streak.n + 1 : 1
|
|
1008
|
+
streak.abs = firstAbs
|
|
1009
|
+
if (streak.n >= 3 && !noticed.has(firstAbs)) {
|
|
1010
|
+
noticed.add(firstAbs)
|
|
1011
|
+
process.stdout.write(
|
|
1012
|
+
byServer
|
|
1013
|
+
? `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`
|
|
1014
|
+
: `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`,
|
|
1015
|
+
)
|
|
1016
|
+
}
|
|
697
1017
|
return first
|
|
698
1018
|
}
|
|
699
1019
|
const trigger = (label: string, abs: string): void => {
|
|
@@ -720,9 +1040,13 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<number> {
|
|
|
720
1040
|
while (label !== undefined && !stop.aborted) {
|
|
721
1041
|
process.stdout.write(`\nvx watch: ${label}; re-running...\n\n`)
|
|
722
1042
|
try {
|
|
723
|
-
await
|
|
724
|
-
|
|
725
|
-
|
|
1043
|
+
await held?.stop()
|
|
1044
|
+
held = undefined
|
|
1045
|
+
const start = Date.now()
|
|
1046
|
+
held = (await runOrchestrator(opts)).persistent
|
|
1047
|
+
lastCycle = { start, end: Date.now() }
|
|
1048
|
+
if (reread && !stop.aborted) {
|
|
1049
|
+
reread = false
|
|
726
1050
|
await rearm()
|
|
727
1051
|
}
|
|
728
1052
|
} catch (err) {
|
|
@@ -746,10 +1070,8 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<number> {
|
|
|
746
1070
|
running = false
|
|
747
1071
|
// Anything that landed after the last judgement waits for a timer
|
|
748
1072
|
// like any other event.
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
trigger(label, abs)
|
|
752
|
-
}
|
|
1073
|
+
const next = pendingAfterCycle(pendingPaths, stop.aborted)
|
|
1074
|
+
if (next !== undefined) trigger(next[1], next[0])
|
|
753
1075
|
}
|
|
754
1076
|
}
|
|
755
1077
|
|
|
@@ -757,10 +1079,15 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<number> {
|
|
|
757
1079
|
// project's dir recursively, so a `node_modules` write under a
|
|
758
1080
|
// project would otherwise trigger every save during `bun install` —
|
|
759
1081
|
// 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
|
-
|
|
1082
|
+
let isIgnoredPath = makeWatchIgnore(cacheDir, outputs, inputs)
|
|
1083
|
+
let matters = makeRootEventFilter(
|
|
1084
|
+
workspaceRoot,
|
|
1085
|
+
projectDirs,
|
|
1086
|
+
workspaceInputs,
|
|
1087
|
+
args.claimedRootFiles,
|
|
1088
|
+
)
|
|
1089
|
+
/** Since the last cycle, a member came or went, or a file that shapes the watched set changed (`shapesWatchedSet`). */
|
|
1090
|
+
let reread = false
|
|
764
1091
|
|
|
765
1092
|
const watchers: WatchHandle[] = []
|
|
766
1093
|
const proofs: Promise<void>[] = []
|
|
@@ -769,47 +1096,227 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<number> {
|
|
|
769
1096
|
// network mount, a container bind — the attempt costs a denied syscall
|
|
770
1097
|
// and a two-second wait before the fallback takes over anyway.
|
|
771
1098
|
const forcePoll = (process.env['VX_WATCH_POLL'] ?? '') !== ''
|
|
772
|
-
|
|
1099
|
+
if (forcePoll)
|
|
1100
|
+
process.stderr.write(`vx watch: polling every ${POLL_INTERVAL_MS} ms (VX_WATCH_POLL)\n`)
|
|
1101
|
+
// The poller skips exactly what the event filter would drop: the
|
|
1102
|
+
// unconditional segments AND this run's declared output containers. Read
|
|
1103
|
+
// through `isIgnoredPath` rather than captured, because `rearm` replaces
|
|
1104
|
+
// the filter when the selection changes.
|
|
1105
|
+
const skipUnder =
|
|
1106
|
+
(dir: string) =>
|
|
1107
|
+
(rel: string): boolean =>
|
|
1108
|
+
isIgnoredPath(dir, rel)
|
|
1109
|
+
// By slot, not by handle: an OS watcher that never proves delivery is
|
|
1110
|
+
// swapped for a poller in place, and a drop must close what is there.
|
|
1111
|
+
const arm = (
|
|
1112
|
+
dir: string,
|
|
1113
|
+
recursive: boolean,
|
|
1114
|
+
onEvent: (filename: string) => void,
|
|
1115
|
+
): WatchHandle => {
|
|
1116
|
+
const at = watchers.length
|
|
1117
|
+
const handle: WatchHandle = {
|
|
1118
|
+
close: () => {
|
|
1119
|
+
watchers[at]?.close()
|
|
1120
|
+
watchers[at] = CLOSED
|
|
1121
|
+
},
|
|
1122
|
+
}
|
|
773
1123
|
if (forcePoll) {
|
|
774
|
-
watchers.push(pollWatcher(dir, recursive, onEvent))
|
|
775
|
-
return
|
|
1124
|
+
watchers.push(pollWatcher(dir, recursive, onEvent, POLL_INTERVAL_MS, skipUnder(dir)))
|
|
1125
|
+
return handle
|
|
776
1126
|
}
|
|
777
1127
|
const armed = armWatcher(dir, recursive, onEvent)
|
|
778
1128
|
watchers.push(armed.watcher)
|
|
779
1129
|
proofs.push(
|
|
780
1130
|
armed.ready.then((ok) => {
|
|
781
1131
|
if (ok) return
|
|
1132
|
+
armed.watcher.close()
|
|
1133
|
+
// Dropped by a rearm before its proof settled: nothing to swap in.
|
|
1134
|
+
if (watchers[at] !== armed.watcher) return
|
|
782
1135
|
// The watcher never proved delivery, so it is not one: an FSEvents
|
|
783
1136
|
// stream the OS refused, a filesystem that reports nothing. Swap in
|
|
784
1137
|
// the poller rather than run a loop that silently never fires.
|
|
785
|
-
|
|
786
|
-
watchers[watchers.indexOf(armed.watcher)] = pollWatcher(dir, recursive, onEvent)
|
|
1138
|
+
watchers[at] = pollWatcher(dir, recursive, onEvent, POLL_INTERVAL_MS, skipUnder(dir))
|
|
787
1139
|
process.stderr.write(
|
|
788
1140
|
`vx watch: ${dir}: no OS watch events within ${WATCH_PROBE_TIMEOUT_MS} ms; polling every ${POLL_INTERVAL_MS} ms instead\n`,
|
|
789
1141
|
)
|
|
790
1142
|
}),
|
|
791
1143
|
)
|
|
1144
|
+
return handle
|
|
792
1145
|
}
|
|
793
1146
|
|
|
794
1147
|
/** 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
1148
|
const armedAt = fsClockNow(cacheDir)
|
|
796
1149
|
/** Per-project arms by directory, so `rearm` can add and drop them. */
|
|
797
1150
|
const perProject = new Map<string, WatchHandle>()
|
|
1151
|
+
/** The root arm: recursive when workspace-wide, else the root's own files only. */
|
|
1152
|
+
let rootArm: WatchHandle = CLOSED
|
|
798
1153
|
const armProject = (proj: ProjectMeta): void => {
|
|
799
1154
|
try {
|
|
800
|
-
const
|
|
801
|
-
arm(proj.dir, true, (filename) => {
|
|
1155
|
+
const handle = arm(proj.dir, true, (filename) => {
|
|
802
1156
|
if (isIgnoredPath(proj.dir, filename)) return
|
|
1157
|
+
if (shapesWatchedSet(filename)) reread = true
|
|
803
1158
|
trigger(`${proj.name} ${filename}`, path.join(proj.dir, filename))
|
|
804
1159
|
})
|
|
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() })
|
|
1160
|
+
perProject.set(proj.dir, handle)
|
|
808
1161
|
} catch (err) {
|
|
809
1162
|
const msg = err instanceof Error ? err.message : String(err)
|
|
810
1163
|
process.stderr.write(`vx watch: cannot watch ${proj.dir}: ${msg}\n`)
|
|
811
1164
|
}
|
|
812
1165
|
}
|
|
1166
|
+
const armRoot = (): void => {
|
|
1167
|
+
try {
|
|
1168
|
+
rootArm = workspaceWide
|
|
1169
|
+
? // workspaceFiles inputs in play: a root-relative glob can name a
|
|
1170
|
+
// file anywhere, so one recursive root watcher replaces the
|
|
1171
|
+
// per-project ones (it also covers lockfile / pnpm-workspace.yaml
|
|
1172
|
+
// edits). `matters` keeps the events a key can see — project
|
|
1173
|
+
// trees, root fingerprint files, the declared globs — and drops
|
|
1174
|
+
// the rest of the tree; the ignore filter then keeps node_modules
|
|
1175
|
+
// / .git / .vx and declared outputs out of what remains.
|
|
1176
|
+
arm(workspaceRoot, true, (filename) => {
|
|
1177
|
+
if (!matters(filename) || isIgnoredPath(workspaceRoot, filename)) return
|
|
1178
|
+
if (shapesWatchedSet(filename) || filename === LOCKFILE_NAME) reread = true
|
|
1179
|
+
trigger(`root ${filename}`, path.join(workspaceRoot, filename))
|
|
1180
|
+
})
|
|
1181
|
+
: // The root itself, non-recursive, beside the per-project arms, so
|
|
1182
|
+
// lockfile + pnpm-workspace.yaml edits trigger re-runs even when
|
|
1183
|
+
// no project dir saw the change.
|
|
1184
|
+
arm(workspaceRoot, false, (filename) => {
|
|
1185
|
+
if (
|
|
1186
|
+
isWorkspaceFingerprintFile(filename) ||
|
|
1187
|
+
isWorkspaceConfigFile(filename) ||
|
|
1188
|
+
args.claimedRootFiles.has(filename)
|
|
1189
|
+
) {
|
|
1190
|
+
if (shapesWatchedSet(filename) || filename === LOCKFILE_NAME) reread = true
|
|
1191
|
+
trigger(`root ${filename}`, path.join(workspaceRoot, filename))
|
|
1192
|
+
}
|
|
1193
|
+
})
|
|
1194
|
+
} catch (err) {
|
|
1195
|
+
const msg = err instanceof Error ? err.message : String(err)
|
|
1196
|
+
process.stderr.write(`vx watch: cannot watch workspace root: ${msg}\n`)
|
|
1197
|
+
}
|
|
1198
|
+
}
|
|
1199
|
+
// One recursive watcher per project, unless workspace-wide: each project
|
|
1200
|
+
// owns its own subtree; the root is never watched recursively for them
|
|
1201
|
+
// (it would cover every project + node_modules + caches).
|
|
1202
|
+
const armMode = (watched: readonly ProjectMeta[]): void => {
|
|
1203
|
+
if (!workspaceWide) for (const proj of watched) armProject(proj)
|
|
1204
|
+
armRoot()
|
|
1205
|
+
}
|
|
1206
|
+
const dropMode = (): void => {
|
|
1207
|
+
for (const handle of perProject.values()) handle.close()
|
|
1208
|
+
perProject.clear()
|
|
1209
|
+
rootArm.close()
|
|
1210
|
+
rootArm = CLOSED
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
/**
|
|
1214
|
+
* A directory under a member base with no package in it yet. The base's
|
|
1215
|
+
* own watcher is not recursive, so the `package.json` that makes it a
|
|
1216
|
+
* package is an event only here; an editor or `git checkout` that makes
|
|
1217
|
+
* the directory first and the manifest after left the package unwatched
|
|
1218
|
+
* for good (item 891). On a rearm a new entry is armed before its
|
|
1219
|
+
* manifest is checked, so one that landed after the re-read is an event
|
|
1220
|
+
* or is seen by the check. At start there is no re-read to race, and a
|
|
1221
|
+
* manifest already there is a directory the globs exclude.
|
|
1222
|
+
*/
|
|
1223
|
+
const pending = new Map<string, WatchHandle>()
|
|
1224
|
+
const armPending = (afterReread: boolean): void => {
|
|
1225
|
+
const want = new Set<string>()
|
|
1226
|
+
for (const base of memberBases)
|
|
1227
|
+
for (const name of memberEntries(base)) {
|
|
1228
|
+
const dir = path.join(base, name)
|
|
1229
|
+
if (!packageDirs.has(dir)) want.add(dir)
|
|
1230
|
+
}
|
|
1231
|
+
for (const [dir, handle] of pending) {
|
|
1232
|
+
if (want.has(dir)) continue
|
|
1233
|
+
handle.close()
|
|
1234
|
+
pending.delete(dir)
|
|
1235
|
+
}
|
|
1236
|
+
for (const dir of want) {
|
|
1237
|
+
if (pending.has(dir)) continue
|
|
1238
|
+
const manifest = path.join(dir, 'package.json')
|
|
1239
|
+
const arrived = (): void => {
|
|
1240
|
+
reread = true
|
|
1241
|
+
trigger(path.relative(workspaceRoot, manifest), manifest)
|
|
1242
|
+
}
|
|
1243
|
+
try {
|
|
1244
|
+
pending.set(
|
|
1245
|
+
dir,
|
|
1246
|
+
arm(dir, false, (filename) => {
|
|
1247
|
+
if (filename === 'package.json') arrived()
|
|
1248
|
+
}),
|
|
1249
|
+
)
|
|
1250
|
+
} catch (err) {
|
|
1251
|
+
const msg = err instanceof Error ? err.message : String(err)
|
|
1252
|
+
process.stderr.write(`vx watch: cannot watch ${dir}: ${msg}\n`)
|
|
1253
|
+
continue
|
|
1254
|
+
}
|
|
1255
|
+
if (afterReread && fs.existsSync(manifest)) arrived()
|
|
1256
|
+
}
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
/**
|
|
1260
|
+
* A config's imports from outside the watched projects: a shared preset
|
|
1261
|
+
* (`../../shared/preset.mjs`) changed what a run evaluates and no arm
|
|
1262
|
+
* saw it (item 949). One non-recursive arm per directory holding one.
|
|
1263
|
+
* A project config's import re-reads and runs; a workspace config's is
|
|
1264
|
+
* loaded once per process (Bun keeps the module), so its edit is named
|
|
1265
|
+
* with the restart it needs instead of a cycle that would run stale.
|
|
1266
|
+
*/
|
|
1267
|
+
const importArms = new Map<string, WatchHandle>()
|
|
1268
|
+
let importNames = new Map<string, Set<string>>()
|
|
1269
|
+
const staleNamed = new Map<string, number>()
|
|
1270
|
+
const armImports = (): void => {
|
|
1271
|
+
const inProject = (f: string): boolean => projectDirs.some((d) => f.startsWith(d + path.sep))
|
|
1272
|
+
const want = new Map<string, Set<string>>()
|
|
1273
|
+
for (const f of new Set([...configImportFiles, ...wsConfigImportFiles])) {
|
|
1274
|
+
if (inProject(f)) continue
|
|
1275
|
+
const names = want.get(path.dirname(f)) ?? new Set<string>()
|
|
1276
|
+
names.add(path.basename(f))
|
|
1277
|
+
want.set(path.dirname(f), names)
|
|
1278
|
+
}
|
|
1279
|
+
importNames = want
|
|
1280
|
+
for (const [dir, handle] of importArms) {
|
|
1281
|
+
if (want.has(dir)) continue
|
|
1282
|
+
handle.close()
|
|
1283
|
+
importArms.delete(dir)
|
|
1284
|
+
}
|
|
1285
|
+
for (const dir of want.keys()) {
|
|
1286
|
+
if (importArms.has(dir)) continue
|
|
1287
|
+
try {
|
|
1288
|
+
importArms.set(
|
|
1289
|
+
dir,
|
|
1290
|
+
arm(dir, false, (filename) => {
|
|
1291
|
+
if (!(importNames.get(dir)?.has(filename) ?? false)) return
|
|
1292
|
+
const abs = path.join(dir, filename)
|
|
1293
|
+
const rel = path.relative(workspaceRoot, abs)
|
|
1294
|
+
if (configImportFiles.includes(abs)) {
|
|
1295
|
+
reread = true
|
|
1296
|
+
trigger(`config import ${rel}`, abs)
|
|
1297
|
+
return
|
|
1298
|
+
}
|
|
1299
|
+
// One line per save, not per event: a save is often two.
|
|
1300
|
+
const now = Date.now()
|
|
1301
|
+
if (now - (staleNamed.get(abs) ?? 0) < 1_000) return
|
|
1302
|
+
staleNamed.set(abs, now)
|
|
1303
|
+
process.stdout.write(
|
|
1304
|
+
`vx watch: ${rel} changed; the workspace config imports it, and this process loaded it at start — restart vx watch to apply the edit\n`,
|
|
1305
|
+
)
|
|
1306
|
+
}),
|
|
1307
|
+
)
|
|
1308
|
+
} catch (err) {
|
|
1309
|
+
const msg = err instanceof Error ? err.message : String(err)
|
|
1310
|
+
process.stderr.write(`vx watch: cannot watch ${dir}: ${msg}\n`)
|
|
1311
|
+
}
|
|
1312
|
+
}
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
const watchingLine = (count: number): string =>
|
|
1316
|
+
workspaceWide
|
|
1317
|
+
? 'vx watch: watching the workspace root (workspaceFiles inputs in use)'
|
|
1318
|
+
: `vx watch: watching ${count} project(s)`
|
|
1319
|
+
|
|
813
1320
|
const rearm = async (): Promise<void> => {
|
|
814
1321
|
let next: Rediscovered
|
|
815
1322
|
try {
|
|
@@ -822,9 +1329,26 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<number> {
|
|
|
822
1329
|
projectDirs = next.projects.map((p) => p.dir)
|
|
823
1330
|
workspaceInputs = next.workspaceInputs
|
|
824
1331
|
outputs = next.outputs
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
1332
|
+
inputs = next.inputs
|
|
1333
|
+
uncached = next.uncached
|
|
1334
|
+
configImportFiles = next.configImports
|
|
1335
|
+
wsConfigImportFiles = next.workspaceConfigImports
|
|
1336
|
+
packageDirs = next.packageDirs
|
|
1337
|
+
isIgnoredPath = makeWatchIgnore(cacheDir, outputs, inputs)
|
|
1338
|
+
matters = makeRootEventFilter(
|
|
1339
|
+
workspaceRoot,
|
|
1340
|
+
projectDirs,
|
|
1341
|
+
workspaceInputs,
|
|
1342
|
+
args.claimedRootFiles,
|
|
1343
|
+
)
|
|
1344
|
+
if (next.workspaceWide !== workspaceWide) {
|
|
1345
|
+
// A task started or stopped declaring `workspaceFiles`: the other
|
|
1346
|
+
// arm's shape. Until item 891 the choice was made once, at start, and
|
|
1347
|
+
// a root file a config newly named was never an event.
|
|
1348
|
+
dropMode()
|
|
1349
|
+
workspaceWide = next.workspaceWide
|
|
1350
|
+
armMode(next.projects)
|
|
1351
|
+
} else if (!workspaceWide) {
|
|
828
1352
|
const keep = new Set(projectDirs)
|
|
829
1353
|
for (const [dir, handle] of perProject) {
|
|
830
1354
|
if (keep.has(dir)) continue
|
|
@@ -832,50 +1356,16 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<number> {
|
|
|
832
1356
|
perProject.delete(dir)
|
|
833
1357
|
}
|
|
834
1358
|
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
1359
|
}
|
|
839
|
-
|
|
1360
|
+
armPending(true)
|
|
1361
|
+
armImports()
|
|
1362
|
+
// A new arm proves delivery like the first ones: an edit in the new
|
|
1363
|
+
// package right after this cycle is seen, not lost in the gap.
|
|
1364
|
+
await Promise.all(proofs)
|
|
1365
|
+
process.stdout.write(`${watchingLine(next.projects.length)}\n`)
|
|
840
1366
|
}
|
|
841
1367
|
|
|
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
|
-
}
|
|
1368
|
+
armMode(projects)
|
|
879
1369
|
|
|
880
1370
|
// A package added while the loop runs is a directory entry appearing
|
|
881
1371
|
// under the glob's directory (`packages/` for `packages/*`); one
|
|
@@ -897,7 +1387,7 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<number> {
|
|
|
897
1387
|
const now = memberEntries(base)
|
|
898
1388
|
if (sameMembers(members, now)) return
|
|
899
1389
|
members = now
|
|
900
|
-
|
|
1390
|
+
reread = true
|
|
901
1391
|
trigger(`${path.relative(workspaceRoot, base)}/${filename}`, path.join(base, filename))
|
|
902
1392
|
})
|
|
903
1393
|
} catch (err) {
|
|
@@ -905,6 +1395,8 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<number> {
|
|
|
905
1395
|
process.stderr.write(`vx watch: cannot watch ${base}: ${msg}\n`)
|
|
906
1396
|
}
|
|
907
1397
|
}
|
|
1398
|
+
armPending(false)
|
|
1399
|
+
armImports()
|
|
908
1400
|
|
|
909
1401
|
// The orchestrator's own writes never kick the loop: `makeWatchIgnore`
|
|
910
1402
|
// closes over the RESOLVED cache dir (relocated or not) and the tasks'
|
|
@@ -914,11 +1406,7 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<number> {
|
|
|
914
1406
|
// watcher has proved (or been given 2 s to prove) delivery first.
|
|
915
1407
|
await Promise.all(proofs)
|
|
916
1408
|
|
|
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
|
-
)
|
|
1409
|
+
process.stdout.write(`\n${watchingLine(projects.length)}; press Ctrl+C to stop\n`)
|
|
922
1410
|
|
|
923
1411
|
return await new Promise<number>((resolve) => {
|
|
924
1412
|
const cleanup = async (): Promise<void> => {
|
|
@@ -933,6 +1421,7 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<number> {
|
|
|
933
1421
|
// The aborted cycle is tearing its children down; resolve only once
|
|
934
1422
|
// it has returned, so the process never exits over a live child.
|
|
935
1423
|
await inFlight
|
|
1424
|
+
await held?.stop(forwardedSignal(stop.reason))
|
|
936
1425
|
resolve(0)
|
|
937
1426
|
}
|
|
938
1427
|
if (stop.aborted) {
|
|
@@ -943,10 +1432,41 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<number> {
|
|
|
943
1432
|
})
|
|
944
1433
|
}
|
|
945
1434
|
|
|
1435
|
+
/**
|
|
1436
|
+
* The event a finished cycle hands back to the timer, if any.
|
|
1437
|
+
*
|
|
1438
|
+
* The inner loop re-runs while anything is pending, so this covers only the
|
|
1439
|
+
* narrow gap between its LAST judgement and the loop going idle: an event
|
|
1440
|
+
* landing there would otherwise sit in `pendingPaths` with no timer armed
|
|
1441
|
+
* and no cycle to notice it — watch quietly idle over an edit the user
|
|
1442
|
+
* made. Insertion order, because the map is "first label wins" and the
|
|
1443
|
+
* label is what the cycle announces.
|
|
1444
|
+
*
|
|
1445
|
+
* A branch, not a race: extracted so it can be pinned at all. Deleting the
|
|
1446
|
+
* arming left every green test in the repo green (2026-09-20) — the e2e
|
|
1447
|
+
* watch fixture spawns a real `vx watch`, so it cannot deliver an event at
|
|
1448
|
+
* that instant, and the suites that could are the ones this repo's cloud
|
|
1449
|
+
* container already fails for timing reasons. What this pins is the
|
|
1450
|
+
* decision; the delivery window is the process's own.
|
|
1451
|
+
*/
|
|
1452
|
+
export function pendingAfterCycle(
|
|
1453
|
+
pending: ReadonlyMap<string, string>,
|
|
1454
|
+
aborted: boolean,
|
|
1455
|
+
): [abs: string, label: string] | undefined {
|
|
1456
|
+
if (aborted || pending.size === 0) return undefined
|
|
1457
|
+
const [abs, label] = [...pending][0]!
|
|
1458
|
+
return [abs, label]
|
|
1459
|
+
}
|
|
1460
|
+
|
|
946
1461
|
/** True when `abs` was last modified before `t` (epoch ms); false when it cannot be read. */
|
|
947
1462
|
export function modifiedBefore(abs: string, t: number): boolean {
|
|
948
1463
|
try {
|
|
949
|
-
|
|
1464
|
+
// The later of the two clocks: `mv`, `cp -p`, `rsync -a` and `tar x`
|
|
1465
|
+
// carry a file's OLD mtime onto the new one, and judged by mtime alone
|
|
1466
|
+
// such an edit was "before the arm" and never ran (item 945). No
|
|
1467
|
+
// process can set a ctime, and a rename or a write moves it.
|
|
1468
|
+
const st = fs.statSync(abs)
|
|
1469
|
+
return Math.max(st.mtimeMs, st.ctimeMs) < t
|
|
950
1470
|
} catch {
|
|
951
1471
|
return false
|
|
952
1472
|
}
|